├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── examples └── app │ ├── App.js │ ├── modules │ └── article │ │ ├── __tests__ │ │ └── mocks │ │ │ └── articles.json │ │ ├── actions │ │ └── articleActions.js │ │ ├── articleActionTypes.js │ │ ├── components │ │ └── ArticleList.js │ │ ├── reducers │ │ └── articleReducer.js │ │ └── schemas │ │ ├── articleRecord.js │ │ ├── articleSchema.js │ │ ├── tagRecord.js │ │ └── userRecord.js │ └── store │ ├── configureStore.js │ └── reducers.js ├── package.json ├── src ├── IterableSchema.js ├── RecordEntitySchema.js ├── UnionSchema.js └── index.js ├── test ├── mocha.opts ├── mocks │ ├── article_comments.json │ ├── articles.json │ ├── articles_update.json │ └── users.json └── normalizerTest.spec.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | /* 2 | "react-native", 3 | "react-native-stage-0", 4 | "react-native-stage-0/decorator-support" 5 | */ 6 | { 7 | "presets": [ 8 | "es2015" 9 | ] 10 | 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | lib 5 | *.tgz 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | BSD License 3 | 4 | Copyright (c) 2016-present, Ology, Consultoria e Participações. All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, 7 | are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name Ology nor the names of its contributors may be used to 17 | endorse or promote products derived from this software without specific 18 | prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 24 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 26 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 27 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Normalizr-Immutable is an opiniated immutable version of Dan Abramov's [Normalizr](https://github.com/gaearon/normalizr) using Facebook's [Immutable](https://facebook.github.io/immutable-js). 2 | We recommend reading the documentation for Normalizr and Immutable first, to get a basic idea of the intent of these concepts. 3 | 4 | ### Installation 5 | ``` 6 | npm install --save normalizr-immutable 7 | ``` 8 | 9 | ### Changes to API version 0.0.3! 10 | Based on user feedback I decided to make some changes to the API: 11 | * `reducerKey` is now an attribute for Schema. This makes it possible to reference entities that are stored in other reducers. 12 | 13 | It does mean that if you receive different levels of detail for a single type of entity across REST endpoints, or you want to maintain the original functionality of referencing entities within one reducer, you may need to maintain different Schema definitions for that entity. 14 | 15 | If you do want to maintain entities across reducers, you have to be careful not to reference a reducer through the Proxy that has not been hydrated yet. 16 | * The Record object is now part of the method signature for Schema. Since it's not optional, it shouldn't be an option. 17 | * added a new option `useMapsForEntityObjects` to the `options` object, which defaults to `false`. When `useMapsForEntityObjects` is set to `true`, it will use a Map for the entity objects (e.g. articles). When set to `false`, it will use a Record for this. See the API description for more info. 18 | 19 | ```javascript 20 | normalize(json.articles.items, arrayOf(schemas.article),{ 21 | getState: store.getState, 22 | useMapsForEntityObjects: true 23 | }); 24 | ``` 25 | 26 | * added a new option `useProxyForResults` to the `options` object, which defaults to `false`. When `useProxyForResults` is set to `true`, it will set a Proxy *also* in the result key object or `List`. This will allow you to reference the object directly from the result. 27 | 28 | ```javascript 29 | normalize(json.articles.items, arrayOf(schemas.article),{ 30 | getState: store.getState, 31 | useProxyForResults: true 32 | }); 33 | ``` 34 | 35 | ### What does Normalizr-Immutable do? 36 | It normalizes a deeply nested json structure according to a schema for Redux apps and makes the resulting object immutable. 37 | It does this in a way that preserves the ability to reference objects using traditional javascript object notation. 38 | So, after normalizing an object, you can still reference the tree in the normalized object like a traditional javascript object: 39 | 40 | Before normalization 41 | ```json 42 | "article": { 43 | "id": 1, 44 | "txt": "Bla", 45 | "user":{ 46 | "id":15, 47 | "name":"Marc" 48 | } 49 | } 50 | ``` 51 | 52 | After normalization 53 | ```javascript 54 | const normalized = { 55 | entities:{//Record with keys: articles, users 56 | articles: {//Record with keys: 1 57 | 1: {//Record with keys: id, txt, user 58 | id:1, 59 | txt: 'Bla', 60 | user: 15 //Optionally a proxy 61 | } 62 | }, 63 | users:{//Record with keys: 15 64 | 15:{//Record with keys: id, name 65 | id:15, 66 | name:'Marc' 67 | } 68 | } 69 | }, 70 | result:[1]//List 71 | } 72 | ``` 73 | 74 | If you use Redux, it optionally, allows you to reference the normalized object through a proxy. This should also work in other environments, but this has not been tested. 75 | This allows you to say: 76 | 77 | ``` 78 | normalized.entities.articles[1].user.name 79 | ``` 80 | 81 | ### How is this different from Normalizr? 82 | * Normalizr-Immutable is immutable 83 | * Normalizr puts an id in the place of the object reference, Normalizr-Immutable (optionally) places a proxy there so you can keep using the object as if nothing changed. 84 | * Normalizr-Immutable adds an extra attribute to a schema: Record. This is an Immutable Record that defines the contract for the referenced 'class'. 85 | 86 | ### What are the benefits of Normalizr-Immutable? 87 | * Because each Schema uses a Record to define its contract, there is a clearly understandable and visible contract that any developer can read and understand. 88 | * Because Record defines defaults, any unexpected changes to the incoming json structure will be less likely to create unforeseen errors and it will be easier to identify them. 89 | * It gives you the benefit of immutability without sacrificing the convenience of object.property access. 90 | * When you render your data, you don't want to retrieve objects separately for normalized references or marshal your normalized object back into a denormalized one. The use of the Proxy allows you to use your normalized structure as if it was a normal object. 91 | * You can transition to using immutable with minimal changes. 92 | * If you use the proxy, you can serialize a normalized structure back to its original JSON structure with `normalized.toJSON()`. 93 | 94 | ### How about Maps, Lists, etc? 95 | Normalizr-Immutable uses Records where possible in order to maintain object.property style access. Sequences are implemented through Lists. 96 | If you defined an object reference on your to-be-normalized object, it will be processed as a Record if the property has a Schema defined for it. Otherwise, it will become a Map (and require object.get('property') style access). 97 | 98 | When you work with Lists and Maps, such as with loops, you should use es6 style `.forEach`, `.map`, etc. Using `for...in`, `for...of` and the like will not work. 99 | 100 | If you use the `useMapsForEntityObjects: true` option when you normalize an object, the entity objects will be stored in a map, to allow you to merge new values into them. Be aware, that Map convert id keys to strings. 101 | 102 | ```javascript 103 | const normalized = { 104 | entities:{//Record with keys: articles, users 105 | articles: {//Map with keys: '1' 106 | '1': {//Record with keys: id, txt, user 107 | id:1, 108 | txt: 'Bla', 109 | user: 15 //Optionally a proxy 110 | } 111 | }, 112 | users:{//Map with keys: '15' 113 | '15':{//Record with keys: id, name 114 | id:15, 115 | name:'Marc' 116 | } 117 | } 118 | }, 119 | result:[1]//List 120 | } 121 | ``` 122 | 123 | ### Creating a schema 124 | Creating a schema is the same as originally in Normalizr, but we now add a Record to the definition. Please note that you need to use arrayOf, unionOf and valuesOf of Normalizr-Immutable. 125 | 126 | ```javascript 127 | import { Record, List, Map } from 'immutable'; 128 | import { Schema, arrayOf } from 'normalizr-immutable'; 129 | 130 | const User = new Record({ 131 | id:null, 132 | name:null 133 | }); 134 | 135 | const Tag = new Record({ 136 | id:null, 137 | label:null 138 | }); 139 | 140 | const Article = new Record({ 141 | id:null, 142 | txt:null, 143 | user: new User({}), 144 | tags: new List() 145 | }); 146 | 147 | const schemas = { 148 | article : new Schema('articles', Article), 149 | user : new Schema('users', User), 150 | tag : new Schema('tags', Tag) 151 | }; 152 | 153 | schemas.article.define({ 154 | user : schemas.user, 155 | tags : arrayOf(schemas.tag) 156 | }); 157 | 158 | ``` 159 | 160 | ### Normalizing your dataSource 161 | Normalizing data is executed as follows. 162 | 163 | ```javascript 164 | import { normalize, arrayOf } from 'normalizr-immutable'; 165 | 166 | const normalized = normalize(json, arrayOf(schemas.article),{}); 167 | ``` 168 | 169 | ### Working with Proxies 170 | Normally, if you normalize an object, the resulting structure will look something like this (All the Object definitions except for `List` are `Record` implementations). 171 | 172 | ```javascript 173 | new NormalizedRecord({ 174 | result: new List([1, 2]), 175 | entities: new EntityStructure({ 176 | articles: new ValueStructure({ 177 | 1: new Article({ 178 | id : 1, 179 | title : 'Some Article', 180 | user : 1, 181 | tags : new List([5]) 182 | }) 183 | }), 184 | users: new ValueStructure({ 185 | 1: new User({ 186 | id: 1, 187 | name: 'Marc' 188 | }) 189 | }), 190 | tags: new ValueStructure({ 191 | 5: new Tag({ 192 | id:5, 193 | label:'React' 194 | }) 195 | }) 196 | }) 197 | }) 198 | 199 | ``` 200 | 201 | So, if you're rendering an Article, in order to render the associated user, you will have to retrieve it from the entity structure. You could do this manually, or you could denormalize/marshal your structure when you retrieve it for rendering. But this can be expensive. 202 | 203 | For this purpose, we introduce the proxy. The idea, is that you can simply reference `articles[1].user.name`. The proxy will take care of looking up the related object. 204 | 205 | Please note that `Proxy` support is not yet consistent across browsers and can also give headaches in testing environments with incomplete support (I've had stuff like infinite loops happen using node-inspector, etc). Tread with care. 206 | 207 | So, with the proxy, an Article Record essentially looks like this: 208 | 209 | ```javascript 210 | new Article({ 211 | id : 1, 212 | title : 'Some Article', 213 | author : new Proxy({id:1}), 214 | tags : new List([new Proxy({id:5})]) 215 | }) 216 | ``` 217 | 218 | In order to use the proxy, you will need to give it access to the actual object structure. We have developed this feature testing against Redux, so we pass it the getState function reference and the reference to the reducer inside the state structure. 219 | 220 | ```javascript 221 | const schemas = { 222 | article : new Schema('articles', Article, { idAttribute: 'id', reducerKey: 'articleReducer' }), 223 | user : new Schema('users', User, { idAttribute: 'id', reducerKey: 'userReducer' }), 224 | tag : new Schema('tags', Tag, { idAttribute: 'id', reducerKey: 'tagReducer' }) 225 | }; 226 | 227 | 228 | const normalized = normalize(json.articles.items, arrayOf(schemas.article),{ 229 | getState, 230 | reducerKey:'articleReducer' 231 | }); 232 | ``` 233 | 234 | Please note that we pass `getState` and not `getState()`. `getState` is a function reference to the method that will return the current state of the Redux store. If you are using Redux, you can get a reference to this method like so 235 | 236 | ```javascript 237 | export function loadArticles(){ 238 | 239 | return ( dispatch, getState) => { 240 | [...] 241 | 242 | const normalized = normalize(json, schema,{ 243 | getState 244 | }); 245 | 246 | [...] 247 | } 248 | } 249 | ``` 250 | 251 | `articleReducer` in this case, is the name of the reducer. Currently we assume that the `result` and `entitites` keys are available in the root of the referenced reducer. This will be made more flexible in future versions. 252 | 253 | ### Browser support 254 | This library has currently only been tested against React-Native, so I would like to hear about experiences in the browser. For a list of browsers with appropriate Proxy support [http://caniuse.com/#feat=proxy](http://caniuse.com/#feat=proxy). 255 | 256 | ## API Reference 257 | This API Reference borrows heavily from the original Normalizr project. 258 | 259 | ### `new Schema(key, [options])` 260 | 261 | Schema lets you define a type of entity returned by your API. 262 | This should correspond to model in your server code. 263 | 264 | The `key` parameter lets you specify the name of the dictionary for this kind of entity. 265 | The `record` parameter lets you specify the Record that defines your entity. 266 | 267 | ```javascript 268 | const User = new Record({ 269 | id:null, 270 | nickName: null, 271 | }); 272 | 273 | const Article = new Record({ 274 | //base comment 275 | id:null, 276 | txt:null, 277 | author:new User(), 278 | }); 279 | 280 | const article = new Schema('articles', Article); 281 | 282 | // You can use a custom id attribute 283 | const article = new Schema('articles', Article, { idAttribute: 'slug' }); 284 | 285 | // Or you can specify a function to infer it 286 | function generateSlug(entity) { /* ... */ } 287 | const article = new Schema('articles', Article { idAttribute: generateSlug }); 288 | ``` 289 | 290 | ### `Schema.prototype.define(nestedSchema)` 291 | 292 | Lets you specify relationships between different entities. 293 | 294 | ```javascript 295 | const article = new Schema('articles', Article); 296 | const user = new Schema('users', User); 297 | 298 | article.define({ 299 | author: user 300 | }); 301 | ``` 302 | 303 | ### `Schema.prototype.getKey()` 304 | 305 | Returns the key of the schema. 306 | 307 | ```javascript 308 | const article = new Schema('articles', Article); 309 | 310 | article.getKey(); 311 | // articles 312 | ``` 313 | 314 | ### `Schema.prototype.getIdAttribute()` 315 | 316 | Returns the idAttribute of the schema. 317 | 318 | ```javascript 319 | const article = new Schema('articles', Article); 320 | const slugArticle = new Schema('articles', Article, { idAttribute: 'slug' }); 321 | 322 | article.getIdAttribute(); 323 | // id 324 | slugArticle.getIdAttribute(); 325 | // slug 326 | ``` 327 | 328 | ### `Schema.prototype.getRecord()` 329 | 330 | Returns the Record of the schema. 331 | 332 | ```javascript 333 | const article = new Schema('articles', Article); 334 | 335 | article.getRecord(); 336 | // Article Record object 337 | ``` 338 | 339 | 340 | ### `arrayOf(schema, [options])` 341 | 342 | Describes an array of the schema passed as argument. 343 | 344 | ```javascript 345 | const article = new Schema('articles', Article); 346 | const user = new Schema('users', User); 347 | 348 | article.define({ 349 | author: user, 350 | contributors: arrayOf(user) 351 | }); 352 | ``` 353 | 354 | If the array contains entities with different schemas, you can use the `schemaAttribute` option to specify which schema to use for each entity: 355 | 356 | ```javascript 357 | const article = new Schema('articles', Article); 358 | const image = new Schema('images', Image); 359 | const video = new Schema('videos', Video); 360 | const asset = { 361 | images: image, 362 | videos: video 363 | }; 364 | 365 | // You can specify the name of the attribute that determines the schema 366 | article.define({ 367 | assets: arrayOf(asset, { schemaAttribute: 'type' }) 368 | }); 369 | 370 | // Or you can specify a function to infer it 371 | function inferSchema(entity) { /* ... */ } 372 | article.define({ 373 | assets: arrayOf(asset, { schemaAttribute: inferSchema }) 374 | }); 375 | ``` 376 | 377 | ### `normalize(obj, schema, [options])` 378 | 379 | Normalizes object according to schema. 380 | Passed `schema` should be a nested object reflecting the structure of API response. 381 | 382 | You may optionally specify any of the following options: 383 | 384 | * `useMapsForEntityObjects` (boolean): When `useMapsForEntityObjects` is set to `true`, it will use a Map for the entity objects (e.g. articles). When set to `false`, it will use a Record for this, but this comes at the expense of not being able to merge new entity objects into the resulting Record object. The advantage of using Records, is that you have dot-property access, but if you use the Proxy, the impact on your code of `useMapsForEntityObjects: true` is really minimal. I recommend using it. 385 | 386 | * `useProxyForResults` (boolean): When `useProxyForResults` is set to `true`, it will set a Proxy *also* in the result key object or `List`. This will allow you to reference the object directly from the result. 387 | 388 | ```javascript 389 | const normalized = normalize(json.articles.items, arrayOf(schemas.article),{ 390 | getState:store.getState, 391 | useProxyForResults:true 392 | }); 393 | 394 | //resulting object looks like this 395 | const normalized = {//Record 396 | entities:{ 397 | articles: { 398 | 1: { 399 | id:1, 400 | txt: 'Bla', 401 | user: new Proxy({id: 15, key: 'users'})//reference to users 402 | } 403 | }, 404 | users:{//Record with keys: 15 405 | 15:{//Record with keys: id, name 406 | id:15, 407 | name:'Marc' 408 | } 409 | } 410 | }, 411 | result:new List([new Proxy({id: 1, key: 'articles'})]) 412 | }; 413 | 414 | console.log(normalized.result.get(0).user.name);//Prints 'Marc' 415 | ``` 416 | 417 | * `assignEntity` (function): This is useful if your backend emits additional fields, such as separate ID fields, you'd like to delete in the normalized entity. See [the tests](https://github.com/gaearon/normalizr/blob/a0931d7c953b24f8f680b537b5f23a20e8483be1/test/index.js#L89-L200) and the [discussion](https://github.com/gaearon/normalizr/issues/10) for a usage example. 418 | 419 | * `mergeIntoEntity` (function): You can use this to resolve conflicts when merging entities with the same key. See [the test](https://github.com/gaearon/normalizr/blob/47ed0ecd973da6fa7c8b2de461e35b293ae52047/test/index.js#L132-L197) and the [discussion](https://github.com/gaearon/normalizr/issues/34) for a usage example. 420 | 421 | ```javascript 422 | const article = new Schema('articles', Article); 423 | const user = new Schema('users', User); 424 | 425 | article.define({ 426 | author: user, 427 | contributors: arrayOf(user), 428 | meta: { 429 | likes: arrayOf({ 430 | user: user 431 | }) 432 | } 433 | }); 434 | 435 | // ... 436 | 437 | // Normalize one article object 438 | const json = { id: 1, author: ... }; 439 | const normalized = normalize(json, article); 440 | 441 | // Normalize an array of article objects 442 | const arr = [{ id: 1, author: ... }, ...] 443 | const normalized = normalize(arr, arrayOf(article)); 444 | 445 | // Normalize an array of article objects, referenced by an object key: 446 | const wrappedArr = { articles: [{ id: 1, author: ... }, ...] } 447 | const normalized = normalize(wrappedArr, { 448 | articles: arrayOf(article) 449 | }); 450 | ``` 451 | 452 | ### Final remarks 453 | The use of the Proxy as a way of accessing the entity structure transparently, would be totally possible also in the original Normalizr library as well. I'm still studying on ways to override functions in a non class structure. If anyone has any suggestions on this, I could spin off the Proxy functionality into a separate library that could serve both libraries. 454 | 455 | The way I turn a list of entities into Records (the ValueStructure Record) is a bit of a hack. I basically create the Record with the actual values as defaults, which is not the way you should be using Records. I apply this hack to ensure that we can keep referencing objects through dot notation. If someone has any problems with this in terms of performance, I would like to hear about it. 456 | 457 | This library has been developed as part of [Ology](https://www.ology.com.br), the social network for physicians. 458 | 459 | I removed harmony-reflect because it's a rather big library and more recent versions of v8 don't need it. I'm just maintaining the harmony-proxy shim. 460 | 461 | ### TODO 462 | * Verify working of unionOf and valuesOf. I haven't really worked with that yet. 463 | 464 | ### Troubleshooting 465 | * If you get any error message with regards to the Proxy object being unknown, please make sure you have set up your babel presets correctly to support proxies. If you use mocha for testing, you will need to add `--harmony-proxies` to the mocha command 466 | * If you get unexpected results, please check that you are not accidently using arrayOf, unionOf and valuesOf of the original Normalizr library. Because this library doesn't export some of the components these functions use, I had to copy them and they will fail instanceof even though they are functionally equivalent. 467 | -------------------------------------------------------------------------------- /examples/app/App.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React, { View, Text } from 'react-native'; 4 | 5 | import { Provider } from 'react-redux'; 6 | import configureStore from './store/configureStore'; 7 | import ArticleList from './modules/article/components/ArticleList'; 8 | 9 | const store = configureStore({}); 10 | 11 | export default class Root extends React.Component{ 12 | 13 | render() { 14 | return ( 15 | 16 | 17 | 18 | ); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /examples/app/modules/article/__tests__/mocks/articles.json: -------------------------------------------------------------------------------- 1 | { 2 | "articles" : { 3 | "items" : [ 4 | { 5 | "user" : { 6 | "nickName" : "Diogenes", 7 | "id" : 193 8 | }, 9 | "id" : 49441, 10 | "txt" : "bla" 11 | }, 12 | { 13 | "user" : { 14 | "nickName" : "Marc", 15 | "id" : 192 16 | }, 17 | "id" : 49442, 18 | "txt" : "bla die bla" 19 | }, 20 | { 21 | "user" : { 22 | "nickName" : "Marc", 23 | "id" : 192 24 | }, 25 | "id" : 49443, 26 | "txt" : "bla die bla 2", 27 | "tags": [{ 28 | "id" : 5, 29 | "label" : "React" 30 | }, 31 | { 32 | "id" : 6, 33 | "label" : "Normalizr" 34 | } 35 | ] 36 | } 37 | 38 | ], 39 | "resultCount" : 114 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/app/modules/article/actions/articleActions.js: -------------------------------------------------------------------------------- 1 | import * as types from './../articleActionTypes'; 2 | 3 | import { articleSchema } from '../schemas/articleSchema'; 4 | import { normalize, arrayOf } from 'normalizr-immutable'; 5 | 6 | export function processArticles(payload) { 7 | return { 8 | type: types.LOAD_ARTICLES, 9 | payload 10 | }; 11 | } 12 | 13 | export function loadArticles(){ 14 | 15 | return ( dispatch, getState) => { 16 | 17 | return fetch('../__tests__/mocks/articles.json',{ 18 | method:'GET' 19 | }) 20 | .then(response => response.json()) 21 | .then(json => { 22 | 23 | const normalized = normalize(json.articles.items, arrayOf(articleSchema),{ 24 | getState, 25 | useMapsForEntityObjects: true, 26 | useProxyForResults:true 27 | }); 28 | 29 | dispatch(processArticles(normalized)); 30 | }) 31 | .catch(err => { 32 | console.log(err); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/app/modules/article/articleActionTypes.js: -------------------------------------------------------------------------------- 1 | export const LOAD_ARTICLES = 'LOAD_ARTICLES'; 2 | -------------------------------------------------------------------------------- /examples/app/modules/article/components/ArticleList.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by dbuarque on 3/17/16. 3 | */ 4 | 'use strict'; 5 | 6 | import React, { Component, PropTypes } from 'react'; 7 | import { View, Text, ListView, StyleSheet } from 'react-native'; 8 | 9 | import { bindActionCreators } from 'redux'; 10 | import { connect } from 'react-redux'; 11 | 12 | import Article from './../../../components/wallpost/Article'; 13 | 14 | import * as articleActions from '../modules/article/actions/articleActions'; 15 | import { articleSchema } from './../schemas/articleSchema'; 16 | import { is } from 'immutable'; 17 | 18 | @connect( 19 | function(state) { 20 | const { articleReducer } = state; 21 | return { 22 | articleReducer 23 | }; 24 | }, 25 | function(dispatch) { 26 | return { 27 | actions: bindActionCreators(articleActions, dispatch) 28 | } 29 | } 30 | ) 31 | export default class ArticleList extends Component { 32 | 33 | constructor(props){ 34 | super(props); 35 | 36 | this.state = { 37 | dataSource: new ListView.DataSource({ rowHasChanged: (r1, r2) => !is(r1,r2) }) 38 | }; 39 | } 40 | 41 | componentDidMount(){ 42 | this.props.actions.loadArticles(); 43 | } 44 | 45 | shouldComponentUpdate(nextProps){ 46 | return !is(this.props.articleReducer.result,nextProps.articleReducer.result); 47 | } 48 | 49 | renderRow (articleObject) { 50 | 51 | const { actions } = this.props; 52 | 53 | return ( 54 |
57 | ); 58 | } 59 | 60 | render () { 61 | 62 | return ( 63 | 67 | ); 68 | } 69 | 70 | }; 71 | -------------------------------------------------------------------------------- /examples/app/modules/article/reducers/articleReducer.js: -------------------------------------------------------------------------------- 1 | import * as types from '../articleActionTypes'; 2 | import { NormalizedRecord } from 'normalizr-immutable'; 3 | 4 | //NormalizedRecord is simply a convenience object that is the base record that we use to return the normalized structure. 5 | const initialState = new NormalizedRecord({}); 6 | 7 | export default function articleReducer(state = initialState, action = {}) { 8 | switch (action.type) { 9 | case types.LOAD_ARTICLES: 10 | return state.merge(action.payload); 11 | default: 12 | return state; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/app/modules/article/schemas/articleRecord.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { Record, List } from 'immutable'; 4 | import User from './userRecord'; 5 | 6 | const Article = new Record({ 7 | id:null, 8 | txt: null, 9 | tags: new List(), 10 | user:new User() 11 | }); 12 | 13 | export default Article; 14 | -------------------------------------------------------------------------------- /examples/app/modules/article/schemas/articleSchema.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Schema, arrayOf } from 'normalizr-immutable'; 4 | 5 | import Article from './articleRecord'; 6 | import User from './userRecord'; 7 | import Tag from './tagRecord'; 8 | 9 | const schemas = { 10 | article : new Schema('articles', Article, { reducerKey:'articleReducer' }), 11 | user : new Schema('users', User, { reducerKey:'articleReducer' }), 12 | tag : new Schema('tags', Tag, { reducerKey:'articleReducer' }), 13 | }; 14 | 15 | schemas.article.define({ 16 | user: schemas.user, 17 | tags: arrayOf(schemas.tag) 18 | }); 19 | 20 | const articleSchema = schemas.article; 21 | 22 | export { 23 | articleSchema 24 | }; 25 | -------------------------------------------------------------------------------- /examples/app/modules/article/schemas/tagRecord.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { Record } from 'immutable'; 4 | 5 | const Tag = new Record({ 6 | id:null, 7 | label: null 8 | }); 9 | 10 | export default Tag; 11 | -------------------------------------------------------------------------------- /examples/app/modules/article/schemas/userRecord.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { Record } from 'immutable'; 4 | 5 | const User = new Record({ 6 | id:null, 7 | name: null 8 | }); 9 | 10 | export default User; 11 | -------------------------------------------------------------------------------- /examples/app/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | 3 | import thunkMiddleware from 'redux-thunk'; 4 | import rootReducer from './reducers'; 5 | 6 | export default function configureStore(initialState) { 7 | 8 | const enhancer = compose( 9 | applyMiddleware( 10 | thunkMiddleware, 11 | ) 12 | ); 13 | 14 | const store = createStore(rootReducer, initialState, enhancer); 15 | 16 | return store; 17 | } 18 | -------------------------------------------------------------------------------- /examples/app/store/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import articleReducer from '../modules/article/reducers/articleReducer'; 4 | 5 | export default combineReducers({ 6 | articleReducer 7 | }); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "normalizr-immutable", 3 | "version": "0.0.3", 4 | "description": "Normalizes JSON to immutable Records according to schema for Redux applications and provide proxied access to properties", 5 | "main": "lib/index.js", 6 | "private": false, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/mschipperheyn/normalizr-immutable.git" 10 | }, 11 | "keywords": [ 12 | "redux", 13 | "normalize", 14 | "proxy", 15 | "json" 16 | ], 17 | "files": [ 18 | "dist", 19 | "lib", 20 | "src" 21 | ], 22 | "author": "Marc Schipperheyn", 23 | "license": "BSD", 24 | "bugs": { 25 | "url": "https://github.com/mschipperheyn/normalizr-immutable/issues" 26 | }, 27 | "homepage": "https://github.com/mschipperheyn/normalizr-immutable", 28 | "scripts": { 29 | "test": "mocha --compilers js:babel-register --harmony-proxies --opts test/mocha.opts test/**/*.spec.js", 30 | "prebuild": "rimraf dist lib", 31 | "build": "webpack && babel src --out-dir lib", 32 | "prepublish": "npm run build" 33 | }, 34 | "dependencies": { 35 | "harmony-proxy": "^1.0.1", 36 | "immutable": "^3.7.6", 37 | "lodash": "^4.2.1", 38 | "lodash-es": "^4.2.1", 39 | "normalizr": "^2.0.2" 40 | }, 41 | "devDependencies": { 42 | "babel-cli": "^6.8.0", 43 | "babel-loader": "^6.2.4", 44 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 45 | "babel-preset-es2015": "^6.6.0", 46 | "babel-preset-react-native": "^1.7.0", 47 | "babel-preset-react-native-stage-0": "^1.0.1", 48 | "babel-preset-stage-1": "^6.5.0", 49 | "chai": "^3.5.0", 50 | "chai-immutable": "^1.5.4", 51 | "mocha": "^2.4.5", 52 | "redux": "^3.5.2", 53 | "redux-logger": "^2.6.1", 54 | "rimraf": "^2.5.2", 55 | "webpack": "^1.13.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/IterableSchema.js: -------------------------------------------------------------------------------- 1 | import isObject from 'lodash/isObject'; 2 | import UnionSchema from './UnionSchema'; 3 | 4 | export default class ArraySchema { 5 | constructor(itemSchema, options = {}) { 6 | if (!isObject(itemSchema)) { 7 | throw new Error('ArraySchema requires item schema to be an object.'); 8 | } 9 | 10 | if (options.schemaAttribute) { 11 | const schemaAttribute = options.schemaAttribute; 12 | this._itemSchema = new UnionSchema(itemSchema, { schemaAttribute }) 13 | } else { 14 | this._itemSchema = itemSchema; 15 | } 16 | } 17 | 18 | getItemSchema() { 19 | return this._itemSchema; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/RecordEntitySchema.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'normalizr'; 2 | import { Record } from 'immutable'; 3 | 4 | export default class RecordEntitySchema extends Schema{ 5 | constructor(key, record, options = {}) { 6 | super(key, options); 7 | if (!key || typeof key !== 'string') { 8 | throw new Error('A string non-empty key is required'); 9 | } 10 | 11 | if(!record || typeof record !== 'function') 12 | throw new Error('A record is required'); 13 | 14 | this._key = key; 15 | this._record = record; 16 | 17 | const idAttribute = options.idAttribute || 'id'; 18 | this._getId = typeof idAttribute === 'function' ? idAttribute : x => x[idAttribute]; 19 | this._idAttribute = idAttribute; 20 | this._reducerKey = options.reducerKey; 21 | } 22 | 23 | getRecord() { 24 | return this._record; 25 | } 26 | 27 | getReducerKey() { 28 | return this._reducerKey; 29 | } 30 | 31 | toString(){ 32 | return `EntitySchema, key: ${this._key}, idAttribute: ${this._idAttribute}, reducerKey: ${this._reducerKey}`; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/UnionSchema.js: -------------------------------------------------------------------------------- 1 | import isObject from 'lodash/isObject'; 2 | 3 | export default class UnionSchema { 4 | constructor(itemSchema, options) { 5 | if (!isObject(itemSchema)) { 6 | throw new Error('UnionSchema requires item schema to be an object.'); 7 | } 8 | 9 | if (!options || !options.schemaAttribute) { 10 | throw new Error('UnionSchema requires schemaAttribute option.'); 11 | } 12 | 13 | this._itemSchema = itemSchema; 14 | 15 | const schemaAttribute = options.schemaAttribute; 16 | this._getSchema = typeof schemaAttribute === 'function' ? schemaAttribute : x => x[schemaAttribute]; 17 | } 18 | 19 | getItemSchema() { 20 | return this._itemSchema; 21 | } 22 | 23 | getSchemaKey(item) { 24 | return this._getSchema(item); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Based on Normalizr 2.0.1 2 | 'use strict'; 3 | // import { arrayOf, valuesOf, unionOf } from 'normalizr'; 4 | import { Record, Map, List, Iterable } from 'immutable'; 5 | 6 | //Shim for new Proxy instead of Proxy.create 7 | import Proxy from 'harmony-proxy'; 8 | //Should patch proxy to work properly 9 | // import Reflect from 'harmony-reflect'; 10 | 11 | import RecordEntitySchema from './RecordEntitySchema'; 12 | import IterableSchema from './IterableSchema'; 13 | import UnionSchema from './UnionSchema'; 14 | import lodashIsEqual from 'lodash/isEqual'; 15 | import lodashIsObject from 'lodash/isObject'; 16 | 17 | const NormalizedRecord = new Record({entities:null, result: null}, 'NormalizedRecord'); 18 | const PolymorphicMapper = new Record({id:null, schema: null}); 19 | 20 | function defaultAssignEntity(normalized, key, entity) { 21 | normalized[key] = entity; 22 | } 23 | 24 | function proxy(id, schema, bag, options){ 25 | /** 26 | * if options contains getState reference and reducer key we can create a proxyHandler 27 | */ 28 | if(typeof Proxy === 'undefined') 29 | return id; 30 | 31 | return new Proxy({id: id, key: schema.getKey()},{ 32 | get(target, name, receiver) { 33 | 34 | if(name === 'id') 35 | return target.id; 36 | 37 | const state = options.getState(); 38 | 39 | if(state[schema.getReducerKey()].entities){ 40 | if(options.useMapsForEntityObjects){ 41 | return state[schema.getReducerKey()].entities[schema.getKey()].get(target.id + '')[name]; 42 | }else{ 43 | return state[schema.getReducerKey()].entities[schema.getKey()][target.id][name]; 44 | } 45 | } 46 | return undefined; 47 | }, 48 | set(k,v){ 49 | throw new Error('Not supported'); 50 | }, 51 | has(name){ 52 | if(options.useMapsForEntityObjects){ 53 | return options.getState()[schema.getReducerKey()].entities[schema.getKey()].get(id + '').has(name); 54 | }else{ 55 | return options.getState()[schema.getReducerKey()].entities[schema.getKey()][id].has(name); 56 | } 57 | }, 58 | valueOf : function () { 59 | return 0; 60 | } 61 | }); 62 | } 63 | 64 | function visitObject(obj, schema, bag, options) { 65 | const _options$assignEntity = options.assignEntity; 66 | const assignEntity = _options$assignEntity === undefined ? defaultAssignEntity : _options$assignEntity; 67 | 68 | let normalized = {}; 69 | for (let key in obj) { 70 | if (obj.hasOwnProperty(key)) { 71 | const entity = visit(obj[key], schema[key], bag, options); 72 | assignEntity.call(null, normalized, key, entity, obj); 73 | } 74 | } 75 | return new Map(normalized); 76 | } 77 | 78 | function visitRecord(obj, schema, bag, options){ 79 | const { assignEntity = defaultAssignEntity } = options; 80 | 81 | let normalized = {}; 82 | 83 | for (let key in obj) { 84 | if (obj.hasOwnProperty(key)) { 85 | const entity = visit(obj[key], schema[key], bag, options); 86 | 87 | assignEntity.call(null, normalized, key, entity, obj); 88 | } 89 | } 90 | 91 | const Record = schema.getRecord(); 92 | return new Record(normalized); 93 | } 94 | 95 | function defaultMapper(iterableSchema, itemSchema, bag, options) { 96 | return function (obj) { 97 | return visit(obj, itemSchema, bag, options); 98 | }; 99 | } 100 | 101 | function polymorphicMapper(iterableSchema, itemSchema, bag, options) { 102 | return function (obj) { 103 | const schemaKey = iterableSchema.getSchemaKey(obj); 104 | const result = visit(obj, itemSchema[schemaKey], bag, options); 105 | return new PolymorphicMapper({ id: result, schema: schemaKey }); 106 | }; 107 | } 108 | 109 | function visitIterable(obj, iterableSchema, bag, options) { 110 | const itemSchema = iterableSchema.getItemSchema(); 111 | const curriedItemMapper = defaultMapper(iterableSchema, itemSchema, bag, options); 112 | 113 | if (Array.isArray(obj)) { 114 | return new List(obj.map(curriedItemMapper)); 115 | } else { 116 | const mp = Object.keys(obj).reduce(function (objMap, key) { 117 | objMap[key] = curriedItemMapper(obj[key]); 118 | return objMap; 119 | }, {}); 120 | return new Map(mp); 121 | } 122 | } 123 | 124 | function visitUnion(obj, unionSchema, bag, options) { 125 | const itemSchema = unionSchema.getItemSchema(); 126 | return polymorphicMapper(unionSchema, itemSchema, bag, options)(obj); 127 | } 128 | 129 | function defaultMergeIntoEntity(entityA, entityB, entityKey) { 130 | if(entityA === null) 131 | return entityB; 132 | 133 | if(!entityA.equals(entityB)){ 134 | 135 | console.info( 136 | `When checking two ${entityKey}, found unequal data. Merging the data. You should consider making sure these objects are equal.`, 137 | entityA, entityB 138 | ); 139 | 140 | return entityA.merge(entityB); 141 | } 142 | 143 | return entityA; 144 | } 145 | 146 | function visitEntity(entity, entitySchema, bag, options) { 147 | // if(!(entitySchema instanceof RecordEntitySchema)) 148 | // throw new Error('Immutable Normalizr expects a Record object as part of the Schema') 149 | 150 | const _options$mergeIntoEntity = options.mergeIntoEntity; 151 | const mergeIntoEntity = _options$mergeIntoEntity === undefined ? defaultMergeIntoEntity : _options$mergeIntoEntity; 152 | 153 | const entityKey = entitySchema.getKey(); 154 | const id = entitySchema.getId(entity); 155 | 156 | if (!bag.hasOwnProperty(entityKey)) { 157 | bag[entityKey] = {}; 158 | } 159 | 160 | if (!bag[entityKey].hasOwnProperty(id)) { 161 | bag[entityKey][id] = null; 162 | } 163 | 164 | let stored = bag[entityKey][id]; 165 | let normalized = visitRecord(entity, entitySchema, bag, options); 166 | bag[entityKey][id] = mergeIntoEntity(stored, normalized, entityKey); 167 | 168 | if(options.getState){ 169 | return proxy(id, entitySchema, bag, options); 170 | } 171 | 172 | return id; 173 | } 174 | 175 | function visit(obj, schema, bag, options = {}) { 176 | if (!lodashIsObject(schema)) { 177 | return obj; 178 | } 179 | 180 | if (!lodashIsObject(obj) && schema._mappedBy) { 181 | obj = { 182 | [schema.getIdAttribute()]: obj, 183 | }; 184 | } else if (!lodashIsObject(obj)) { 185 | return obj; 186 | } 187 | 188 | if (schema instanceof RecordEntitySchema) { 189 | return visitEntity(obj, schema, bag, options); 190 | } else if (schema instanceof IterableSchema) { 191 | return visitIterable(obj, schema, bag, options); 192 | } else if (schema instanceof UnionSchema) { 193 | return visitUnion(obj, schema, bag, options); 194 | } else { 195 | //we want the root object to be processed as a record, all others, not managed by a record should become a Map 196 | return visitObject(obj, schema, bag, options); 197 | } 198 | } 199 | 200 | function arrayOf(schema, options) { 201 | return new IterableSchema(schema, options); 202 | } 203 | 204 | function valuesOf(schema, options) { 205 | return new IterableSchema(schema, options); 206 | } 207 | 208 | function unionOf(schema, options) { 209 | return new UnionSchema(schema, options); 210 | } 211 | 212 | /** 213 | * object: a javascript object 214 | * schema: a RecordEntitySchema 215 | * options: an object with the following optional keys 216 | * getState: a function reference that returns the current state. If available, a proxy will be placed in place of an id reference (the key needs to be reference in the schema definition) 217 | * useMapsForEntityObjects: boolean. If true, will use a Map in stead of a Record to store id-RecordObject pairs. This means that you have to access a specific entity object like so: 218 | * ` 219 | * useMapsForEntityObjects:false, this.props.articleReducer.entities.articles[5].label 220 | * { 221 | * entities:{//Record key 222 | * articles:{//Record key 223 | * 5:{//Record 224 | * label: 'article label' 225 | * } 226 | * } 227 | * } 228 | * } 229 | * useMapsForEntityObjects:true, this.props.articleReducer.entities.articles.get(5).label 230 | * ` 231 | * { 232 | * entities:{//Record key 233 | * articles:{//Record key 234 | * 5:{//Map key 235 | * label: 'article label' 236 | * } 237 | * } 238 | * } 239 | * } 240 | * ` 241 | * If you use proxies, the impact on your code will be minimal. 242 | * The disadvantage of using Records in stead of Maps for the entity objects is that when you try to merge new content into the Record, you will fail. 243 | */ 244 | function normalize(obj, schema, options = { 245 | getState: undefined, 246 | useMapsForEntityObjects: false, 247 | useProxyForResults:false 248 | }) { 249 | 250 | if (!lodashIsObject(obj) && !Array.isArray(obj)) { 251 | throw new Error('Normalize accepts an object or an array as its input.'); 252 | } 253 | 254 | if (!lodashIsObject(schema) || Array.isArray(schema)) { 255 | throw new Error('Normalize accepts an object for schema.'); 256 | } 257 | 258 | if(options.getState && typeof Proxy === 'undefined'){ 259 | console.warn('Proxies not supported in this environment'); 260 | } 261 | 262 | let bag = {}; 263 | let entityStructure = {}; 264 | let keyStructure = {}; 265 | let results = []; 266 | 267 | //This will either return a sequence, an id or a Proxy object 268 | let result = visit(obj, schema, bag, options); 269 | 270 | //we are now assuming that the returned "ids" are actually proxies if there is a getState method 271 | if(options.getState && !options.useProxyForResults){ 272 | results = result instanceof List? 273 | result.map(function(val){ 274 | return val.id; 275 | }) : 276 | result.id 277 | }else{ 278 | results = result; 279 | } 280 | 281 | let entities = null; 282 | 283 | for(let schemaKey in bag){ 284 | keyStructure[schemaKey] = null; 285 | 286 | if(options.useMapsForEntityObjects){ 287 | entityStructure[schemaKey] = new Map(bag[schemaKey]); 288 | }else{ 289 | const ValueStructure = new Record(bag[schemaKey]); 290 | entityStructure[schemaKey] = new ValueStructure({}); 291 | } 292 | 293 | } 294 | 295 | const EntityStructure = new Record(keyStructure); 296 | entities = new EntityStructure(entityStructure); 297 | 298 | return new NormalizedRecord({ 299 | entities: entities, 300 | result: results 301 | }); 302 | } 303 | 304 | export { 305 | NormalizedRecord, 306 | arrayOf, 307 | valuesOf, 308 | unionOf, 309 | normalize, 310 | RecordEntitySchema as Schema 311 | }; 312 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mschipperheyn/normalizr-immutable/b449a8bbe74eebd2b2b9f57719925be236c7e37a/test/mocha.opts -------------------------------------------------------------------------------- /test/mocks/article_comments.json: -------------------------------------------------------------------------------- 1 | { 2 | "id" : 49443, 3 | "txt" : "Testing Normalizr", 4 | "user" : { 5 | "nickName" : "Marc", 6 | "id" : 192 7 | }, 8 | "tags": [{ 9 | "id" : 5, 10 | "label" : "React" 11 | }, 12 | { 13 | "id" : 6, 14 | "label" : "Normalizr" 15 | } 16 | ], 17 | "comments" : [ 18 | { 19 | "user" : { 20 | "nickName" : "Diogenes", 21 | "id" : 193 22 | }, 23 | "id" : 50001, 24 | "txt" : "bla" 25 | }, 26 | { 27 | "user" : { 28 | "nickName" : "Marc", 29 | "id" : 192 30 | }, 31 | "id" : 50002, 32 | "txt" : "bla die bla 2" 33 | } 34 | ], 35 | "resultCount" : 114 36 | } 37 | -------------------------------------------------------------------------------- /test/mocks/articles.json: -------------------------------------------------------------------------------- 1 | { 2 | "articles" : { 3 | "items" : [ 4 | { 5 | "user" : { 6 | "nickName" : "Diogenes", 7 | "id" : 193 8 | }, 9 | "id" : 49441, 10 | "txt" : "bla" 11 | }, 12 | { 13 | "user" : { 14 | "nickName" : "Marc", 15 | "id" : 192 16 | }, 17 | "id" : 49442, 18 | "txt" : "bla die bla" 19 | }, 20 | { 21 | "user" : { 22 | "nickName" : "Marc", 23 | "id" : 192 24 | }, 25 | "id" : 49443, 26 | "txt" : "bla die bla 2", 27 | "tags": [{ 28 | "id" : 5, 29 | "label" : "React" 30 | }, 31 | { 32 | "id" : 6, 33 | "label" : "Normalizr" 34 | } 35 | ] 36 | } 37 | 38 | ], 39 | "resultCount" : 114 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/mocks/articles_update.json: -------------------------------------------------------------------------------- 1 | { 2 | "articles" : { 3 | "items" : [ 4 | { 5 | "user" : { 6 | "nickName" : "Diogenes", 7 | "id" : 193 8 | }, 9 | "id" : 49444, 10 | "txt" : "More bla" 11 | }, 12 | { 13 | "user" : { 14 | "nickName" : "Diogenes", 15 | "id" : 193 16 | }, 17 | "id" : 49441, 18 | "txt" : "bla" 19 | }, 20 | { 21 | "user" : { 22 | "nickName" : "Marc", 23 | "id" : 192 24 | }, 25 | "id" : 49442, 26 | "txt" : "bla die bla" 27 | }, 28 | { 29 | "user" : { 30 | "nickName" : "Marc", 31 | "id" : 192 32 | }, 33 | "id" : 49443, 34 | "txt" : "bla die bla", 35 | "tags": [{ 36 | "id" : 5, 37 | "label" : "React" 38 | }, 39 | { 40 | "id" : 6, 41 | "label" : "Normalizr" 42 | } 43 | ] 44 | } 45 | 46 | ], 47 | "resultCount" : 114 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/mocks/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "users":[ 3 | { 4 | "nickName" : "Diogenes", 5 | "id" : 193 6 | }, 7 | { 8 | "nickName" : "Marc", 9 | "id" : 192 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /test/normalizerTest.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import { is, fromJS } from 'immutable'; 3 | import { assert as assert0, expect as expect0, should as should0 } from 'chai'; 4 | 5 | import iChaiImmutable from 'chai'; 6 | import chaiImmutable from 'chai-immutable'; 7 | 8 | iChaiImmutable.use(chaiImmutable) 9 | 10 | const { assert, expect, should } = iChaiImmutable; 11 | 12 | import loggerMiddleware from 'redux-logger'; 13 | 14 | import { createStore, combineReducers, applyMiddleware } from 'redux'; 15 | 16 | import json from './mocks/articles.json'; 17 | import jsonUpdate from './mocks/articles_update.json'; 18 | import jsonObject from './mocks/article_comments.json'; 19 | import jsonUsers from './mocks/users.json'; 20 | import { normalize, Schema, arrayOf, NormalizedRecord } from '../src/index'; 21 | 22 | import { Record, List, Map } from 'immutable'; 23 | 24 | const reducerKey = 'myReducer'; 25 | 26 | const Tag = new Record({ 27 | id:null, 28 | label: null 29 | }); 30 | 31 | const User = new Record({ 32 | id:null, 33 | nickName: null, 34 | }); 35 | 36 | const Article = new Record({ 37 | //base comment 38 | id:null, 39 | txt:null, 40 | user:new User(), 41 | tags:new List(), 42 | comments:new List() 43 | 44 | }); 45 | 46 | const schemas = { 47 | article : new Schema('articles', Article, { idAttribute: 'id', reducerKey: reducerKey }), 48 | user : new Schema('users', User, { idAttribute: 'id', reducerKey: reducerKey }), 49 | tag : new Schema('tags', Tag, { idAttribute: 'id', reducerKey: reducerKey }) 50 | }; 51 | 52 | schemas.article.define({ 53 | user: schemas.user, 54 | tags: arrayOf(schemas.tag), 55 | comments:arrayOf(schemas.article) 56 | }); 57 | 58 | const initialState = new NormalizedRecord(); 59 | 60 | function myReducer(state = initialState, action) { 61 | if(action.type === 'articles') 62 | return state.merge(action.payload); 63 | return state; 64 | }; 65 | 66 | const store = createStore(combineReducers({ 67 | myReducer 68 | }),{},applyMiddleware( 69 | // loggerMiddleware() 70 | )); 71 | 72 | describe("test normalizr", () => { 73 | 74 | it("should work against the immutable normalizr", () => { 75 | 76 | const normalized = normalize(json.articles.items, arrayOf(schemas.article),{}); 77 | 78 | expect(normalized).to.have.property('entities'); 79 | expect(normalized).to.have.property('result'); 80 | expect(normalized.result).to.have.size(3); 81 | expect(normalized.entities).to.have.property('users'); 82 | expect(normalized.entities.users).to.have.property(193); 83 | expect(normalized.entities).to.have.property('articles'); 84 | expect(normalized.entities.articles).to.have.property(49441); 85 | expect(normalized.entities.articles[49441]).to.have.property('user'); 86 | expect(normalized.entities.articles[49441].user).to.equal(193); 87 | }); 88 | 89 | it("should allow a proxy function to lazy load the reference", () => { 90 | 91 | const normalized = normalize(json.articles.items, arrayOf(schemas.article),{ 92 | getState:store.getState 93 | }); 94 | 95 | store.dispatch({ 96 | type:'articles', 97 | payload:normalized 98 | }) 99 | 100 | expect(normalized.entities.articles[49443].user.id).to.equal(192); 101 | expect(normalized.entities.articles.get(49443).user.get('id')).to.equals(192); 102 | expect(normalized.entities.articles.get(49443).user.nickName).to.equal('Marc'); 103 | 104 | }); 105 | 106 | it("show dynamic state changes after the reference has passed and not just a passed static state", () => { 107 | 108 | let normalized = normalize(json.articles.items, arrayOf(schemas.article),{ 109 | getState:store.getState 110 | }); 111 | 112 | store.dispatch({ 113 | type:'articles', 114 | payload:normalized 115 | }); 116 | 117 | normalized = normalize(jsonUpdate.articles.items, arrayOf(schemas.article),{ 118 | getState:store.getState 119 | }); 120 | 121 | expect(normalized.entities.articles[49444].user.id).to.equal(193); 122 | }); 123 | 124 | it("should process a single object", () => { 125 | 126 | const normalized = normalize(jsonObject, schemas.article,{ 127 | getState:store.getState 128 | }); 129 | 130 | store.dispatch({ 131 | type:'articles', 132 | payload:normalized 133 | }) 134 | 135 | expect(normalized.entities.articles[49443].user.id).to.equal(192); 136 | expect(normalized.entities.articles.get(50001).user.get('id')).to.equals(193); 137 | expect(normalized.entities.articles.get(50002).user.nickName).to.equal('Marc'); 138 | 139 | }); 140 | 141 | it("should process iterables", () => { 142 | 143 | const normalized = normalize(json.articles.items, arrayOf(schemas.article),{ 144 | getState:store.getState 145 | }); 146 | 147 | store.dispatch({ 148 | type:'articles', 149 | payload:normalized 150 | }) 151 | 152 | expect(normalized.entities.articles[49443].tags).to.have.size(2); 153 | expect(normalized.entities.articles.get(49443).tags.get(0).label).to.equal("React"); 154 | }); 155 | 156 | it("accesses objects across different reducers", () => { 157 | 158 | const mySchemas = { 159 | article : new Schema('articles', Article, { idAttribute: 'id', reducerKey: reducerKey }), 160 | user : new Schema('users', User, { idAttribute: 'id', reducerKey: 'userReducer' }), 161 | tag : new Schema('tags', Tag, { idAttribute: 'id', reducerKey: reducerKey }) 162 | }; 163 | 164 | mySchemas.article.define({ 165 | user: mySchemas.user, 166 | tags: arrayOf(mySchemas.tag), 167 | comments:arrayOf(mySchemas.article) 168 | }); 169 | 170 | function userReducer(state = initialState, action) { 171 | if(action.type === 'users') 172 | return state.merge(action.payload); 173 | return state; 174 | }; 175 | 176 | const myStore = createStore(combineReducers({ 177 | myReducer, 178 | userReducer 179 | }),{},applyMiddleware( 180 | // loggerMiddleware() 181 | )); 182 | 183 | const normalized = normalize(json.articles.items, arrayOf(mySchemas.article),{ 184 | getState:myStore.getState 185 | }); 186 | 187 | const normalizedUsers = normalize(jsonUsers.users, arrayOf(mySchemas.user),{ 188 | getState:myStore.getState 189 | }); 190 | 191 | myStore.dispatch({ 192 | type:'articles', 193 | payload:normalized 194 | }); 195 | 196 | myStore.dispatch({ 197 | type:'users', 198 | payload:normalizedUsers 199 | }); 200 | 201 | expect(normalized.entities.articles[49443].user.id).to.equal(192); 202 | expect(normalized.entities.articles.get(49443).user.get('id')).to.equals(192); 203 | expect(normalized.entities.articles.get(49443).user.nickName).to.equal('Marc'); 204 | 205 | }); 206 | 207 | it("equals Objects as different Proxies pass is(r1,r2)", () => { 208 | const normalized = normalize(json.articles.items, arrayOf(schemas.article),{ 209 | getState:store.getState 210 | }); 211 | 212 | store.dispatch({ 213 | type:'articles', 214 | payload:normalized 215 | }) 216 | 217 | expect(normalized.entities.articles[49442].user).to.equal(normalized.entities.articles[49442].user); 218 | expect(normalized.entities.articles[49442].user).to.equal(normalized.entities.articles[49443].user); 219 | expect(is(normalized.entities.articles[49442].user,normalized.entities.articles[49442].user)).to.be.true; 220 | expect(is(normalized.entities.articles[49442].user,normalized.entities.articles[49443].user)).to.be.true; 221 | 222 | }); 223 | 224 | it("allows useMapsForEntities to use Maps instead of Records for entity objects", () => { 225 | const normalized = normalize(json.articles.items, arrayOf(schemas.article),{ 226 | getState:store.getState, 227 | useMapsForEntityObjects:true 228 | }); 229 | 230 | store.dispatch({ 231 | type:'articles', 232 | payload:normalized 233 | }); 234 | 235 | expect(normalized.entities.articles.get('49443').user.id).to.equal(192); 236 | expect(normalized.entities.articles.get('49443').user.nickName).to.equal('Marc'); 237 | 238 | }); 239 | 240 | it("allows merging of new data", () => { 241 | const normalized = normalize(json.articles.items, arrayOf(schemas.article),{ 242 | getState:store.getState, 243 | useMapsForEntityObjects:true 244 | }); 245 | 246 | const normalizedUpdate = normalize(jsonUpdate.articles.items, arrayOf(schemas.article),{ 247 | getState:store.getState, 248 | useMapsForEntityObjects:true 249 | }); 250 | 251 | const normalizedMerged = normalized.entities.articles.merge(normalizedUpdate.entities.articles); 252 | 253 | expect(normalizedMerged).to.contain.key('49444'); 254 | 255 | const normalizedRecord = normalize(json.articles.items, arrayOf(schemas.article),{ 256 | getState:store.getState, 257 | useMapsForEntityObjects:false 258 | }); 259 | 260 | const normalizedUpdateRecord = normalize(jsonUpdate.articles.items, arrayOf(schemas.article),{ 261 | getState:store.getState, 262 | useMapsForEntityObjects:false 263 | }); 264 | 265 | try{ 266 | normalizedRecord.entities.articles.merge(normalizedUpdateRecord.entities.articles) 267 | should().fail('We cannot merge Records when keys are added.'); 268 | }catch(err){} 269 | 270 | }); 271 | 272 | it("allows accessing results through a proxy", () => { 273 | const normalized = normalize(json.articles.items, arrayOf(schemas.article),{ 274 | getState:store.getState, 275 | useProxyForResults:true 276 | }); 277 | 278 | store.dispatch({ 279 | type:'articles', 280 | payload:normalized 281 | }); 282 | 283 | expect(normalized.result.get(0).user.nickName).to.equal('Diogenes'); 284 | }); 285 | 286 | it("show processing of unions", () => { 287 | 288 | }); 289 | }); 290 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = { 4 | entry: './src/index', 5 | module: { 6 | loaders: [ 7 | { 8 | test: /\.js$/, 9 | loader: 'babel', 10 | exclude: /node_modules/, 11 | query: { 12 | presets: ['es2015'] 13 | } 14 | } 15 | ] 16 | }, 17 | output: { 18 | filename: 'dist/normalizr-immutable.min.js', 19 | libraryTarget: 'umd', 20 | library: 'normalizr-immutable' 21 | }, 22 | plugins: [ 23 | new webpack.optimize.OccurenceOrderPlugin(), 24 | new webpack.DefinePlugin({ 25 | 'process.env': { 26 | 'NODE_ENV': JSON.stringify('production') 27 | } 28 | }), 29 | new webpack.optimize.UglifyJsPlugin({ 30 | compressor: { 31 | warnings: false 32 | } 33 | }) 34 | ] 35 | }; 36 | --------------------------------------------------------------------------------