Skip to content
This repository has been archived by the owner on Mar 20, 2022. It is now read-only.

Commit

Permalink
Initial working release
Browse files Browse the repository at this point in the history
  • Loading branch information
gaearon committed Aug 20, 2014
1 parent b5193fb commit d62506e
Show file tree
Hide file tree
Showing 6 changed files with 720 additions and 6 deletions.
17 changes: 17 additions & 0 deletions ArraySchema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use strict';

var isObject = require('lodash-node/modern/objects/isObject');

function ArraySchema(itemSchema) {
if (!isObject(itemSchema)) {
throw new Error('ArraySchema requires item schema to be an object.');
}

this._itemSchema = itemSchema;
}

ArraySchema.prototype.getItemSchema = function () {
return this._itemSchema;
};

module.exports = ArraySchema;
23 changes: 23 additions & 0 deletions EntitySchema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use strict';

function EntitySchema(key) {
if (!key || typeof key !== 'string') {
throw new Error('A string non-empty key is required');
}

this._key = key;
}

EntitySchema.prototype.getKey = function () {
return this._key;
};

EntitySchema.prototype.define = function (nestedSchema) {
for (var prop in nestedSchema) {
if (nestedSchema.hasOwnProperty(prop)) {
this[prop] = nestedSchema[prop];
}
}
};

module.exports = EntitySchema;
163 changes: 161 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,168 @@
normalizr
=========

Normalizes JSON API responses according to schema for Flux application.
Normalizes deeply nested JSON API responses according to a schema for Flux application.
Kudos to Jing Chen for suggesting this approach.

(Work in progress)
### The Problem

You have a legacy API that returns deeply nested objects.
You want to port your app to [Flux](https://github.com/facebook/flux) but [it's hard](https://groups.google.com/forum/#!topic/reactjs/jbh50-GJxpg) for Stores to read their data from deeply nested API responses.

Say, you have `/articles` API with the following schema:

```
articles: article*
article: {
author: user,
likers: user*
primary_collection: collection?
collections: collection*
}
collection: {
curator: user
}
```

Without normalizr, your Stores would need to know too much about API response schema.
For example, `UserStore` would include a lot of boilerplate to extract fresh user info when articles are fetched:

```javascript
// Without normalizr, you'd have to do this in every store:

AppDispatcher.register(function (payload) {
var action = payload.action;

switch (action.type) {
case ActionTypes.RECEIVE_USERS:
mergeUsers(action.rawUsers);
break;

case ActionTypes.RECEIVE_ARTICLES:
action.rawArticles.forEach(rawArticle => {
mergeUsers([rawArticle.user]);
mergeUsers(rawArticle.likers);

mergeUsers([rawArticle.primaryCollection.curator]);
rawArticle.collections.forEach(rawCollection => {
mergeUsers(rawCollection.curator);
});
});

UserStore.emitChange();
break;
}
});
```

Normalizr solves the problem by converting API responses to a flat form where nested entities are replaced with IDs:

```javascript
{
result: [12, 10, 3, ...],
entities: {
articles: {
12: {
authorId: 3,
likers: [2, 1, 4],
primaryCollection: 12,
collections: [12, 11]
},
...
},
users: {
3: {
name: 'Dan'
},
2: ...,
4: ....
},
collections: {
12: {
curator: 2,
name: 'Stuff'
},
...
}
}
}
```

Then `UserStore` code can be rewritten as:

```javascript
// With normalizr, users are always in action.entities.users

AppDispatcher.register(function (payload) {
var action = payload.action;

switch (action.type) {
case ActionTypes.RECEIVE_ARTICLES:
case ActionTypes.RECEIVE_USERS:
mergeUsers(action.entities.users);
UserStore.emitChange();
break;
}
});
```

### Usage

```javascript
var normalizr = require('normalizr'),
normalize = normalizr.normalize,
Schema = normalizr.Schema,
arrayOf = normalizr.arrayOf;

// First, define a schema:

var article = new Schema('articles'),
user = new Schema('users'),
collection = new Schema('collections');

// Define nesting rules

article.define({
author: user,
collections: arrayOf(collection)
});

collection.define({
curator: user
});

// Now we can use this schema in our API response code

var ServerActionCreators = {
receiveArticles: function (response) {
var normalized = normalize(response, {
articles: arrayOf(article) // Use our schema
});

AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVE_RAW_ARTICLES,
rawArticles: normalized
});
},

receiveUsers: function (response) {
var normalized = normalize(response, {
users: arrayOf(users) // Use our schema
});

AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVE_RAW_USERS,
rawUsers: normalized
});
}
}
```

### Dependencies

* lodash-node for `isObject` and `isEqual`

### Installing

Expand Down
111 changes: 110 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,112 @@
'use strict';

module.exports = 'TODO';
var EntitySchema = require('./EntitySchema'),
ArraySchema = require('./ArraySchema'),
isObject = require('lodash-node/modern/objects/isObject'),
isEqual = require('lodash-node/modern/objects/isEqual');

function visitObject(obj, schema, bag) {
var normalized = {};

for (var prop in obj) {
if (obj.hasOwnProperty(prop)) {
normalized[prop] = visit(obj[prop], schema[prop], bag);
}
}

return normalized;
}

function visitArray(obj, arraySchema, bag) {
var itemSchema = arraySchema.getItemSchema(),
normalized;

normalized = obj.map(function (childObj) {
return visit(childObj, itemSchema, bag);
});

return normalized;
}


function mergeIntoEntity(entityA, entityB, entityKey) {
for (var prop in entityB) {
if (!entityB.hasOwnProperty(prop)) {
continue;
}

if (!entityA.hasOwnProperty(prop) || isEqual(entityA[prop], entityB[prop])) {
entityA[prop] = entityB[prop];
continue;
}

console.warn(
'When merging two ' + entityKey + ', found shallowly unequal data in their "' + prop + '" values. Using the earlier value.',
entityA[prop], entityB[prop]
);
}
}

function visitEntity(entity, entitySchema, bag) {
var entityKey = entitySchema.getKey(),
id = entity.id,
stored,
normalized;

if (!bag[entityKey]) {
bag[entityKey] = {};
}

if (!bag[entityKey][id]) {
bag[entityKey][id] = {};
}

stored = bag[entityKey][id];
normalized = visitObject(entity, entitySchema, bag);

mergeIntoEntity(stored, normalized, entityKey);

return id;
}

function visit(obj, schema, bag) {
if (!isObject(obj) || !isObject(schema)) {
return obj;
}

if (schema instanceof EntitySchema) {
return visitEntity(obj, schema, bag);
} else if (schema instanceof ArraySchema) {
return visitArray(obj, schema, bag);
} else {
return visitObject(obj, schema, bag);
}
}

function normalize(obj, schema) {
if (!isObject(obj) && !Array.isArray(obj)) {
throw new Error('Normalize accepts an object or an array as its input.');
}

if (!isObject(schema) || Array.isArray(schema)) {
throw new Error('Normalize accepts an object for schema.');
}

var bag = {},
result = visit(obj, schema, bag);

return {
entities: bag,
result: result
};
}

function arrayOf(schema) {
return new ArraySchema(schema);
}

module.exports = {
Schema: EntitySchema,
arrayOf: arrayOf,
normalize: normalize
};
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,8 @@
"devDependencies": {
"chai": "^1.9.1",
"mocha": "^1.21.4"
},
"dependencies": {
"lodash-node": "^2.4.1"
}
}
Loading

0 comments on commit d62506e

Please sign in to comment.