├── .babelrc ├── .npmignore ├── .gitignore ├── docs ├── model_static_methods.md ├── api_usage.md ├── SUMMARY.md ├── defining_attributes.md ├── individual_model_config.md ├── api_config.md ├── base_model_config.md ├── model_instance_methods.md └── defining_relations.md ├── test ├── index.js └── base_model │ ├── index.js │ ├── restful_actions.js │ ├── to_json.js │ ├── model_name_prop.js │ └── relations.js ├── src ├── init_attributes.js ├── index.js ├── utils.js ├── remove_related_model.js ├── set_attributes.js ├── set_relations.js ├── init_relations.js ├── restful_actions_mixin.js ├── set_relation.js ├── api.js ├── set_related_model.js ├── set_relations_defaults.js └── base_model.js ├── webpack.config.js ├── book.json ├── LICENSE ├── README.md ├── package.json └── CHANGELOG.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | test/ 3 | webpack.*.js 4 | .babelrc 5 | README.md 6 | TODO 7 | USAGE.js -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | TODO 2 | USAGE.js 3 | .DS_Store 4 | lib/ 5 | node_modules/ 6 | npm-debug.log 7 | _book 8 | .idea -------------------------------------------------------------------------------- /docs/model_static_methods.md: -------------------------------------------------------------------------------- 1 | # Model static methods 2 | 3 | get 4 | 5 | set 6 | 7 | remove 8 | 9 | all -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 2 | describe("Library `mobx-model`", function() { 3 | require('./base_model'); 4 | }); 5 | -------------------------------------------------------------------------------- /src/init_attributes.js: -------------------------------------------------------------------------------- 1 | import { extendObservable } from 'mobx'; 2 | 3 | export default function initAttributes(options = {}) { 4 | let { model } = options; 5 | extendObservable(model, model.constructor.attributes); 6 | } -------------------------------------------------------------------------------- /docs/api_usage.md: -------------------------------------------------------------------------------- 1 | # API usage 2 | 3 | request options 4 | 5 | method 6 | 7 | data 8 | 9 | endpoint 10 | 11 | onSuccess 12 | 13 | 14 | response options 15 | 16 | ok 17 | 18 | errors 19 | 20 | requestId 21 | 22 | body -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import API from './api'; 2 | import BaseModel from './base_model'; 3 | 4 | // Add default RESTful actions to BaseModel 5 | import './restful_actions_mixin'; 6 | 7 | 8 | export { 9 | API, 10 | BaseModel 11 | } -------------------------------------------------------------------------------- /test/base_model/index.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | 4 | describe('BaseModel', () => { 5 | require('./relations'); 6 | require('./to_json'); 7 | require('./model_name_prop'); 8 | require('./restful_actions'); 9 | }); -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | let lowercaseFirstLetter = function(string) { 2 | return string.charAt(0).toLowerCase() + string.slice(1); 3 | } 4 | 5 | let upperCaseFirstLetter = function(string) { 6 | return string.charAt(0).toUpperCase() + string.slice(1); 7 | } 8 | 9 | export { 10 | lowercaseFirstLetter, 11 | upperCaseFirstLetter 12 | } -------------------------------------------------------------------------------- /src/remove_related_model.js: -------------------------------------------------------------------------------- 1 | export default function removeRelatedModel(options = {}) { 2 | let { model, relation, relatedModel } = options; 3 | 4 | if (relation.isHasMany) { 5 | let collection = model[relation.propertyName]; 6 | collection.splice(collection.indexOf(relatedModel), 1); 7 | } else if (relation.isHasOne) { 8 | model[relation.propertyName] = relation.initialValue; 9 | } 10 | } -------------------------------------------------------------------------------- /src/set_attributes.js: -------------------------------------------------------------------------------- 1 | import keys from 'lodash/keys'; 2 | import { underscore } from 'inflection'; 3 | 4 | export default function setAttributes(options = {}) { 5 | let { model, modelJson } = options; 6 | 7 | keys(model.constructor.attributes).forEach(attributeName => { 8 | model[attributeName] = modelJson[attributeName] ? modelJson[attributeName] : modelJson[underscore(attributeName)]; 9 | }); 10 | } -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | * [Defining attributes](docs/defining_attributes.md) 4 | * [Defining relations](docs/defining_relations.md) 5 | * [Base model config](docs/base_model_config.md) 6 | * [API config](docs/api_config.md) 7 | * [Individual model config](docs/individual_model_config.md) 8 | * [Model static methods](docs/model_static_methods.md) 9 | * [Model instance methods](docs/model_instance_methods.md) 10 | * [API usage](docs/api_usage.md) 11 | 12 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = { 4 | entry: './src/index.js', 5 | devtool: 'source-map', 6 | module: { 7 | loaders: [ 8 | { 9 | test: /\.js$/, 10 | exclude: /node_modules\/(?!qs)/, 11 | loader: 'babel', 12 | query: { 13 | presets: ['es2015', 'stage-0'] 14 | } 15 | } 16 | ] 17 | }, 18 | output: { 19 | libraryTarget: 'umd', 20 | library: 'mobservable-model', 21 | filename: 'lib/index.js' 22 | } 23 | } -------------------------------------------------------------------------------- /book.json: -------------------------------------------------------------------------------- 1 | { 2 | "gitbook": "2.4.3", 3 | "structure": { 4 | "summary": "docs/SUMMARY.md" 5 | }, 6 | "plugins": [ 7 | "edit-link", 8 | "prism", 9 | "-highlight", 10 | "github", 11 | "anker-enable", 12 | "disqus", 13 | "ga" 14 | ], 15 | "pluginsConfig": { 16 | "edit-link": { 17 | "base": "https://github.com/ikido/mobx-model/tree/master", 18 | "label": "Edit This Page" 19 | }, 20 | "github": { 21 | "url": "https://github.com/ikido/mobx-model/" 22 | }, 23 | "disqus": { 24 | "shortName": "mobx-model" 25 | }, 26 | "ga": { 27 | "token": "UA-63820458-2" 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /docs/defining_attributes.md: -------------------------------------------------------------------------------- 1 | # Defining attributes 2 | 3 | To use attributes you must extend `BaseModel` class and add static `attributes` property object. 4 | 5 | ```js 6 | import { BaseModel } from 'mobx-model'; 7 | 8 | class Post extends BaseModel 9 | static attributes = { 10 | name: null, 11 | someAttribute: 'default value' 12 | } 13 | end 14 | ``` 15 | 16 | Attributes are defined as an object. Each property name is observable property name that will be set on model instance. Value is default attribute value (used when new instance of model is created). When setting/updating attribtues from model JSON attribute names are underscored, e.g. to update `someAttribute` property you must provide `some_attribute` key in model JSON. -------------------------------------------------------------------------------- /test/base_model/restful_actions.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { BaseModel } from '../../lib/index'; 3 | import isFunction from 'lodash/isFunction'; 4 | 5 | 6 | describe('RESTful actions in BaseModel', () => { 7 | 8 | it("should have class static method `load`", function() { 9 | expect(isFunction(BaseModel.load)).to.equal(true); 10 | }); 11 | 12 | it("should have class static method `create`", function() { 13 | expect(isFunction(BaseModel.create)).to.equal(true); 14 | }); 15 | 16 | it("should have method `update` in prototype", function() { 17 | expect(isFunction(BaseModel.prototype.update)).to.equal(true); 18 | }); 19 | 20 | it("should have method `destroy` in prototype", function() { 21 | expect(isFunction(BaseModel.prototype.destroy)).to.equal(true); 22 | }); 23 | 24 | }); -------------------------------------------------------------------------------- /src/set_relations.js: -------------------------------------------------------------------------------- 1 | import setRelation from './set_relation'; 2 | 3 | export default function setRelations(options = {}) { 4 | 5 | let { model, modelJson, requestId, topLevelJson } = options; 6 | 7 | model.constructor.relations.forEach(relation => { 8 | 9 | let embeddedJson = modelJson[relation.jsonKey]; 10 | let foreignKeys = modelJson[relation.foreignKey]; 11 | 12 | options = { 13 | model, 14 | relation, 15 | requestId, 16 | topLevelJson 17 | } 18 | 19 | if (embeddedJson) { 20 | Object.assign(options, { modelJson: embeddedJson }); 21 | } else if (foreignKeys !== undefined) { 22 | Object.assign(options, { ids: foreignKeys }); 23 | } 24 | 25 | // console.log(relation.propertyName, attributes); 26 | setRelation(options); 27 | 28 | }); 29 | 30 | } -------------------------------------------------------------------------------- /src/init_relations.js: -------------------------------------------------------------------------------- 1 | import { extendObservable } from 'mobx'; 2 | 3 | import setRelationsDefaults from './set_relations_defaults'; 4 | import removeRelatedModel from './remove_related_model'; 5 | import setRelatedModel from './set_related_model'; 6 | 7 | export default function initRelations(options = {}) { 8 | let { model } = options; 9 | 10 | // set defaults for relations 11 | setRelationsDefaults(model); 12 | 13 | model.constructor.relations.forEach(relation => { 14 | 15 | extendObservable(model, { [relation.propertyName]: relation.initialValue }); 16 | 17 | // add alias method to set relation to model's instance 18 | model[relation.setMethodName] = function(options = {}) { 19 | Object.assign(options, { relation, model }); 20 | return setRelatedModel(options); 21 | }.bind(model); 22 | 23 | // add alias method to remove relation to model's instance 24 | model[relation.removeMethodName] = function(relatedModel) { 25 | return removeRelatedModel({ 26 | model, 27 | relation, 28 | relatedModel 29 | }); 30 | }.bind(model); 31 | 32 | }); 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Aleksander Ponomarev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /docs/individual_model_config.md: -------------------------------------------------------------------------------- 1 | # Individual model config 2 | 3 | ```js 4 | export default class Post extends BaseModel { 5 | 6 | static urlRoot = '/posts'; 7 | 8 | static jsonKey = 'post'; 9 | 10 | static relations = [ 11 | { 12 | type: 'hasMany', 13 | relatedModel: 'Comment' 14 | } 15 | ]; 16 | 17 | static attributes = { 18 | title: null, 19 | createdAt: null 20 | } 21 | 22 | } 23 | ``` 24 | 25 | ## Model config options 26 | 27 | Most of the options are not needed to be defined manually as defaults can be used for RESTful API. Note that these config options must be defined as es6 static variables on a model class. 28 | 29 | | option | type | default | description | 30 | | -- | -- | -- | -- | 31 | | `urlRoot` | `string` | tableized model class name with a prepending slash, e.g. for `Post` model it will be `/posts` | Can be used in model actions as a prefix for API endpoints | 32 | | `jsonKey` | `string` | underscored model class name, e.g. for `Post` model it will be `post` | Can be used in model actions to find object containing model json | 33 | | `attributes` | `object` | | An object describing model attributes, read more on [defining attributes](defining_attributes.md) | 34 | | `relations` | `array` | | An array of objects describing model's relations with other models, read more on [defining relations](defining_relations.md) | -------------------------------------------------------------------------------- /docs/api_config.md: -------------------------------------------------------------------------------- 1 | # API config 2 | 3 | 4 | You can setup API requests by passing config options to `API.config` method. After that you can use `API.request` in your model actions to make requests to your backend. Basically it's just a wrapper around [superagent](https://github.com/visionmedia/superagent) that returns a promise and reduces the boilerplate code. 5 | 6 | ```js 7 | API.config({ 8 | urlRoot: '/api/v1', 9 | requestData: { CSRFParam: 'CSRFToken' }, 10 | requestHeaders() { 11 | return { Authorization: `Bearer ${Auth.token}` }; 12 | }, 13 | onRequestError(response) { 14 | console.log(`API Error ${response.status}`); 15 | }, 16 | onRequestCompleted(response) { 17 | console.log('API request completed', response.body); 18 | } 19 | }); 20 | ``` 21 | 22 | ### Available config options 23 | 24 | | Option | Type | Description | 25 | | -- | -- | -- | 26 | | `urlRoot` | `string` | Prefix that will be added to all your api requests | 27 | | `requestData` | `object` or `function` | An object or a function that returns an object that will be merged with data sent with a request. Note that for now if you will use `fileData` option to upload a file then no data will be sent to server, including `requestData` | 28 | | `requestHeaders` | `object` or `function` | An object or a function that returns an object that will be merged with headers sent with a request | 29 | | `onRequestError` | `function` | Callback that will be called with a superagent response object when request is considered as failed by superagent | 30 | | `onRequestCompleted` | `function` | Callback that will be called with a superagent response object on every request (even failed one) | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mobx-model [![npm version](https://badge.fury.io/js/mobx-model.svg)](https://badge.fury.io/js/mobx-model) 2 | 3 | This is a library to simplify [mobx](https://github.com/mobxjs/mobx) data stores that mimic backend models. It includes simple class that makes API requests and executes callbacks on success/failure. Model attributes and relations are then updated from server-side json. Note that you will need es6 support with static properties to use mobx-model. 4 | 5 | The idea is to have single source of truth — graph of model objects that reference each other. Each model class holds collection of cached model instances available through `Model.all()` and `Model.get(:id)` methods. 6 | 7 | Mobx-model is not a replacement for Backbone.Model since it supports single source of truth principle. Model instance methods that are created by defining attributes and relations are intended to be used as a way to access state of the models. To hydrate the state you have to use `set` method on an instance or a class, that will set or update your model attributes and trigger re-renders on appropriate components thanks to `mobx-react`. 8 | 9 | You can also define actions on model classes or instances or on the `BaseModel` itself that will communicate with your data store — API, localstorage or whatever you need, just make sure to call `set` when you need to update state of the models. 10 | 11 | This library is not perfect, but it works for us together with Rails + ActiveModel Serializers 0.8.3 on the backend, making work with normalized database structure much easier. If you want to make it work with your backend setup then raise an issue — I'll be glad to help you out to make library universal. 12 | 13 | Note that currently polymorphic associations are not supported, though there are some workarounds. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mobx-model", 3 | "version": "0.0.39", 4 | "description": "Simple model with attributes and relations updated from json", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "mocha --reporter spec --compilers js:babel-core/register test/index.js", 8 | "test-watch": "mocha --reporter spec --compilers js:babel-core/register test/index.js --recursive --watch", 9 | "prepublish": "npm run clean && npm run build && npm run test", 10 | "clean": "rm -rf lib/", 11 | "build": "babel src --out-dir lib --source-maps", 12 | "docs:prepare": "gitbook install", 13 | "docs:watch": "npm run docs:prepare && gitbook serve", 14 | "docs:build": "npm run docs:prepare && rm -rf _book && gitbook build -g wearevolt/mobx-model", 15 | "docs:publish": "npm run docs:build && cd _book && git init && git commit --allow-empty -m 'Update docs' && git checkout -b gh-pages && git add . && git commit -am 'Update docs' && git push git@github.com:wearevolt/mobx-model gh-pages --force" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/ikido/mobx-model.git" 20 | }, 21 | "author": "Alexander Ponomarev", 22 | "license": "MIT", 23 | "keywords": [ 24 | "mobx", 25 | "observable", 26 | "react-component", 27 | "react", 28 | "reactjs", 29 | "reactive", 30 | "model" 31 | ], 32 | "dependencies": { 33 | "bluebird": "^3.4.1", 34 | "inflection": "^1.10.0", 35 | "lodash": "^4.13.1", 36 | "qs": "^6.2.1", 37 | "superagent": "^2.1.0" 38 | }, 39 | "devDependencies": { 40 | "babel-cli": "^6.18.0", 41 | "babel-core": "^6.11.4", 42 | "babel-preset-es2015": "^6.3.13", 43 | "babel-preset-stage-0": "^6.3.13", 44 | "chai": "^3.4.1", 45 | "gitbook-cli": "^2.1.3", 46 | "mocha": "^2.3.4", 47 | "mobx": "^3.2.2" 48 | }, 49 | "peerDependencies": { 50 | "mobx": "^3.2.2" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /docs/base_model_config.md: -------------------------------------------------------------------------------- 1 | # Base model config 2 | 3 | You need to config `BaseModel` somewhere, at least to set up a way to resolve related model classes. `BaseModel` is a class all your other models should inherit from, You can also add actions to `BaseModel` that will be shared between all your models, such as common REST actions. 4 | 5 | ### getModel(modelName) 6 | 7 | This method is used to derive model class from its name. We need this method to solve circular dependency problem when models reference each other. 8 | 9 | ```js 10 | import models from 'models'; 11 | 12 | BaseModel.getModel = (modelName) => { 13 | return models[modelName] 14 | } 15 | ``` 16 | 17 | ### addClassAction(actionName, fn) 18 | 19 | This method is a shortcut to add static method and bind its `this` to a class. 20 | 21 | ```js 22 | BaseModel.addClassAction('load', function(id) { 23 | return API.request({ 24 | endpoint: `${this.urlRoot}/${id}`, 25 | onSuccess: (options = {}) => { 26 | let { json, requestId } = options; 27 | this.set({ 28 | modelJson: json[this.jsonKey], 29 | topLevelJson: json, 30 | requestId 31 | }); 32 | } 33 | }) 34 | }); 35 | ``` 36 | 37 | 38 | ### addAction(actionName, fn) 39 | 40 | This method is a shortcut to add instance method and bind its `this` to model instance 41 | 42 | ```js 43 | BaseModel.addAction('destroy', function() { 44 | return API.request({ 45 | method: 'del', 46 | endpoint: `${this.urlRoot}/${this.id}`, 47 | onSuccess: (options = {}) => { 48 | let { json, requestId } = options; 49 | this.onDestroy(); 50 | } 51 | }); 52 | }); 53 | ``` 54 | 55 | ### Additinal config 56 | 57 | You can also add other common behaviour, for example you can add static `init` method to your models that will load cache when your app is loaded, and call it during config time 58 | 59 | ```js 60 | Object.keys(models).forEach(modelName => { 61 | if (models[modelName].init) models[modelName].init(); 62 | }) 63 | ``` -------------------------------------------------------------------------------- /src/restful_actions_mixin.js: -------------------------------------------------------------------------------- 1 | import API from './api'; 2 | import BaseModel from './base_model'; 3 | 4 | import isString from 'lodash/isString'; 5 | import isFunction from 'lodash/isFunction'; 6 | 7 | 8 | BaseModel.addClassAction('create', function(attributes) { 9 | 10 | if (isString(attributes)) { 11 | attributes = { name: attributes } 12 | } 13 | 14 | return API.request({ 15 | method: 'post', 16 | data: attributes, 17 | endpoint: this.urlRoot, 18 | onSuccess: (options = {}) => { 19 | const json = options.body; 20 | 21 | let model = this.set({ 22 | modelJson: json[this.jsonKey], 23 | topLevelJson: json 24 | }); 25 | 26 | if (isFunction(model.afterCreate)) { 27 | model.afterCreate(options); 28 | } 29 | 30 | } 31 | }); 32 | }); 33 | 34 | 35 | BaseModel.addClassAction('load', function(id, isIncludeDeleted) { 36 | return API.request({ 37 | endpoint: `${this.urlRoot}/${id}`, 38 | data: { 39 | include_deleted: isIncludeDeleted 40 | }, 41 | onSuccess: (options = {}) => { 42 | const json = options.body; 43 | 44 | this.set({ 45 | modelJson: json[this.jsonKey], 46 | topLevelJson: json 47 | }); 48 | 49 | } 50 | }) 51 | }); 52 | 53 | 54 | BaseModel.addAction('update', function(attributes = {}) { 55 | return API.request({ 56 | method: 'put', 57 | data: attributes, 58 | endpoint: `${this.urlRoot}/${this.id}`, 59 | onSuccess: (options = {}) => { 60 | const json = options.body; 61 | 62 | this.set({ 63 | modelJson: json[this.jsonKey], 64 | topLevelJson: json 65 | }); 66 | 67 | if (isFunction(this.afterUpdate)) { 68 | this.afterUpdate(options); 69 | } 70 | 71 | } 72 | }); 73 | }); 74 | 75 | 76 | BaseModel.addAction('destroy', function() { 77 | return API.request({ 78 | method: 'del', 79 | endpoint: `${this.urlRoot}/${this.id}`, 80 | onSuccess: (options = {}) => { 81 | this.onDestroy(); 82 | 83 | if (isFunction(this.afterDestroy)) { 84 | this.afterDestroy(options); 85 | } 86 | 87 | } 88 | }); 89 | }); 90 | 91 | -------------------------------------------------------------------------------- /test/base_model/to_json.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { isFunction } from 'lodash'; 3 | import { BaseModel } from '../../lib/index'; 4 | 5 | 6 | class AlphaModel extends BaseModel { 7 | static attributes = { 8 | value: null 9 | }; 10 | 11 | static relations = [ 12 | { 13 | type: 'hasOne', 14 | relatedModel: 'BettaModel', 15 | reverseRelation: true 16 | }, 17 | { 18 | type: 'hasMany', 19 | relatedModel: 'OmegaModel', 20 | reverseRelation: true 21 | } 22 | ]; 23 | } 24 | 25 | class BettaModel extends BaseModel { 26 | static attributes = { 27 | name: null 28 | }; 29 | 30 | static relations = [ 31 | { 32 | type: 'hasOne', 33 | relatedModel: 'AlphaModel', 34 | reverseRelation: true 35 | } 36 | ]; 37 | } 38 | 39 | class OmegaModel extends BaseModel { 40 | static attributes = { 41 | name: null 42 | }; 43 | 44 | static relations = [ 45 | { 46 | type: 'hasOne', 47 | relatedModel: 'AlphaModel', 48 | reverseRelation: true 49 | } 50 | ]; 51 | } 52 | 53 | const models = { AlphaModel, BettaModel, OmegaModel }; 54 | 55 | BaseModel.getModel = function(modelName) { 56 | return models[modelName]; 57 | }; 58 | 59 | 60 | describe('toJSON()', () => { 61 | 62 | it("method should exist", function() { 63 | expect(isFunction(BaseModel.prototype.toJSON)).to.equal(true); 64 | }); 65 | 66 | it("should serialize attributes and related models ID`s", function() { 67 | 68 | const topLevelJson = { 69 | 70 | model: { 71 | id: 1, 72 | omega_model_ids: [ 73 | 11, 74 | 12, 75 | 13 // not existed 76 | ], 77 | value: 'foo', 78 | betta_model: { id: 2, name: 'bar' }, 79 | }, 80 | 81 | omega_models: [ 82 | { id: 11, name: 'Omega bar 11' }, 83 | { id: 12, name: 'Omega bar 12' } 84 | ] 85 | 86 | }; 87 | 88 | const modelJson = topLevelJson.model; 89 | const model = AlphaModel.set({ modelJson, topLevelJson }); 90 | 91 | expect(model.toJSON()).to.deep.equal({ 92 | id: 1, 93 | value: 'foo', 94 | bettaModelId: 2, 95 | omegaModelIds: [ 11, 12 ] 96 | }); 97 | 98 | }); 99 | 100 | }); 101 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | ### 0.0.39 4 | 5 | Updated mobx to 3.2.2 6 | 7 | ### 0.0.33 8 | 9 | Made this.observables.collection regular array, not an observable 10 | 11 | ### 0.0.32 12 | 13 | Add common RESTful actions to BaseModel 14 | 15 | ### 0.0.31 16 | 17 | Library no more build as single JS bundle. 18 | Now it builds as several CommonJS files in `/lib/` directory. 19 | All external dependencies located in `node_modules`. 20 | It solve problem with huge dist size. 21 | 22 | ### 0.0.30 23 | 24 | Add usage `modelName` property first instead `name` 25 | 26 | ### 0.0.29 27 | 28 | * Add toJSON() method to BaseModel 29 | * Fix broken `hasOne` and `hasMany` relations tests 30 | 31 | ### 0.0.28 32 | 33 | * fix issue default superagent is set incorrectly 34 | * cleanup `extraneous` modules 35 | * add .idea directory in to git ignore file 36 | 37 | ### 0.0.27 38 | 39 | Passing superagent as config option. 40 | 41 | ### 0.0.26 42 | 43 | Added onInitialize hook for model instances 44 | 45 | ### 0.0.25 46 | 47 | Fixed a bug in setRealation method 48 | 49 | ### 0.0.24 50 | 51 | Updated dependencies to latest versions 52 | 53 | ### 0.0.23 54 | 55 | * file uploads now working, you can supply fileData option like this: `fileData: { attibuteName: 'file', file }`, where attributeName is name of form field that server expects and file is the file object. Note that to data from `data` option won't be sent, as well as no `requestData` from `API.config` 56 | 57 | ### 0.0.22 58 | 59 | * fixed issue with non-updating attributes on subsequent set instace method calls 60 | 61 | ### 0.0.21 62 | 63 | * requestData and requestHeaders in api config can be an object or a function returning object 64 | 65 | ### 0.0.18 66 | 67 | * Allow ids to be non-integer 68 | 69 | ### 0.0.15 70 | 71 | * API onSuccess and onError callbacks are executed with full response object, not just json (which is now response.body) 72 | 73 | ### 0.0.14 74 | 75 | * fixed BaseModel.get bug with mobx 2.0.5 76 | 77 | ### 0.0.13 78 | 79 | * added requestHeaders to api config 80 | * added onError callback to API 81 | * onSuccess and onError now return only json, no requestId elsewhere 82 | * we resolve API promise with whole response object from superagent now 83 | * BaseModel.set static method now works if only { modelJson } was passed 84 | * attributes in JSON can be either camelcased or underscored 85 | * fixed static urlRoot and jsonKey properties for -------------------------------------------------------------------------------- /src/set_relation.js: -------------------------------------------------------------------------------- 1 | import isArray from 'lodash/isArray'; 2 | import includes from 'lodash/includes'; 3 | 4 | export default function setRelation(options = {}) { 5 | 6 | let { 7 | ids, 8 | modelJson, 9 | relation, 10 | model, 11 | requestId, 12 | topLevelJson 13 | } = options; 14 | 15 | // console.log('setRelation', relation, ids, modelJson) 16 | 17 | // if no ids and json was passed, do nothing 18 | if (!modelJson && ids===undefined) return 19 | 20 | if (relation.isHasMany) { 21 | 22 | if ((ids && !isArray(ids)) || (modelJson && !isArray(modelJson))) { 23 | throw new Error(`Expected json or ids for ${relation.propertyName} to be an array`); 24 | } 25 | 26 | let relatedModelIds = []; 27 | let collection = model[relation.propertyName]; 28 | 29 | let attributes = modelJson ? modelJson : ids; 30 | 31 | // add new relations to this model 32 | attributes.forEach(relatedModelAttributes => { 33 | 34 | // console.log('relatedModelAttributes', relatedModelAttributes) 35 | 36 | let options = { 37 | requestId, 38 | topLevelJson 39 | } 40 | 41 | if (modelJson) { 42 | Object.assign(options, { modelJson: relatedModelAttributes }) 43 | } else { 44 | Object.assign(options, { id: relatedModelAttributes }) 45 | } 46 | 47 | let relatedModel = model[relation.setMethodName](options); 48 | 49 | // can be undefined for example if we haven't found 50 | // id in a separate store 51 | if (relatedModel) { 52 | relatedModelIds.push(relatedModel.id); 53 | } 54 | 55 | }); 56 | 57 | // remove relations not in json 58 | collection.slice().forEach(relatedModel => { 59 | if (!includes(relatedModelIds, relatedModel.id)) { 60 | model[relation.removeMethodName](relatedModel); 61 | } 62 | }); 63 | 64 | } else if (relation.isHasOne) { 65 | 66 | let options = { 67 | requestId, 68 | topLevelJson 69 | } 70 | 71 | if (modelJson) { 72 | Object.assign(options, { modelJson }) 73 | } else { 74 | Object.assign(options, { id: ids }) 75 | } 76 | 77 | // try to set relation 78 | let relatedModel = model[relation.setMethodName](options); 79 | 80 | // if no related model was returned then reset property 81 | if (!relatedModel) { 82 | model[relation.propertyName] = relation.initialValue; 83 | } 84 | 85 | } 86 | } -------------------------------------------------------------------------------- /docs/model_instance_methods.md: -------------------------------------------------------------------------------- 1 | # Model instance methods 2 | 3 | ```js 4 | 5 | import { BaseModel } from 'mobx-model'; 6 | 7 | class Post extends BaseModel { 8 | 9 | static attributes = { 10 | title: '' 11 | } 12 | 13 | static relations = [ 14 | { 15 | type: 'hasMany', 16 | relatedModel: 'Comment' 17 | } 18 | ] 19 | 20 | } 21 | 22 | ``` 23 | 24 | ## model.set 25 | 26 | Set method is used to update model instance's attributes, its relations and attributes of relations recursively. If relation had a `reverseRelation` property defined or set to `true` then this model instance will be made available at `reverseRelation.propertyName` 27 | 28 | ```js 29 | let json = { 30 | comments: [ 31 | { 32 | id: 1, 33 | content: 'nice post!' 34 | } 35 | ], 36 | post: { 37 | id: 1, 38 | title: 'new title', 39 | comment_ids: [1] 40 | } 41 | } 42 | 43 | let post = Post.get(1); 44 | post.set({ modelJson: json.post, topLevelJson: json }); 45 | ``` 46 | 47 | #### Options 48 | 49 | | option | type | description | 50 | | -- | -- | -- | 51 | | `modelJson` | `object` | Json with model's attributes. It can contain either ids of relations deisgnated by the relation `foreignKey` property, or embedded json for relations designated by `jsonKey` property in relation config | 52 | | `topLevelJson` | `object` | When looking up relations referenced by ids `topLevelJsonKey` relation config property is used to find object in `topLevelJson` that contains related objects | 53 | 54 | 55 | ## model.urlRoot, model.jsonKey 56 | 57 | Just couple of shorthand methods that returns prefix for model's RESTful methods and model's key to find its attributes in JSON. Both methods are usually used in server actions 58 | 59 | ```js 60 | class Post extends BaseModel { 61 | 62 | update(attributes = {}) { 63 | return API.request({ 64 | method: 'put', 65 | data: attributes, 66 | endpoint: `${this.urlRoot}/${this.id}`, 67 | onSuccess: (response) => { 68 | let json = response.body; 69 | this.set({ modelJson: json[this.jsonKey] }); 70 | } 71 | }); 72 | } 73 | 74 | } 75 | 76 | ``` 77 | 78 | ### model.onInitialize 79 | 80 | This is a hook called when model instance is initialized. 81 | Redefine it in your models to add functionality to each model instance, such as custom observable attribute 82 | 83 | ``` 84 | class Model extends BaseModel { 85 | onInitialize() { 86 | this.observableMeta = observable({ 87 | action: null, 88 | someStuff: null 89 | }) 90 | } 91 | } 92 | ``` 93 | 94 | ### model.onDestroy 95 | 96 | This is a shorthand method that calls all methods listed below — `removeSelfFromCollection`, `destroyDependentRelations`, `removeSelfFromRelations`. It does all cleanup that is neccessary when model is being destroyed. 97 | 98 | ### model.removeSelfFromCollection 99 | 100 | Removes this model instance from collection of cached models of this class 101 | 102 | ### model.destroyDependentRelations 103 | 104 | Finds relations that have `onDestroy: 'destroyRelation'` config option and calls `onDestroy` method on those relations, removing them from model cache. 105 | 106 | ### model.removeSelfFromRelations 107 | 108 | Finds relations that have `onDestroy: 'removeSelf'` config option set (which is default) and removes this model instance from those relation model instances 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import superagentDefault from 'superagent'; 4 | import qs from 'qs'; 5 | import BPromise from 'bluebird'; 6 | 7 | import pick from 'lodash/pick'; 8 | import isFunction from 'lodash/isFunction'; 9 | import isEmpty from 'lodash/isEmpty'; 10 | 11 | BPromise.config({ 12 | warnings: true, 13 | longStackTraces: true, 14 | cancellation: true 15 | }); 16 | 17 | 18 | const API = { 19 | 20 | config(options = {}) { 21 | Object.assign(this, pick(options, [ 22 | 'onRequestError', 23 | 'onRequestCompleted', 24 | 'requestData', 25 | 'requestHeaders', 26 | 'urlRoot', 27 | 'superagent' 28 | ])) 29 | }, 30 | 31 | request(options = {}) { 32 | 33 | let { method, data, endpoint, onSuccess, onError, fileData, superagent } = options; 34 | let requestData, requestHeaders, doRequest; 35 | const request = superagent || this.superagent || superagentDefault; 36 | 37 | if (!method) { method = 'get' } 38 | if (!data) { data = {} } 39 | if (!endpoint) { 40 | throw new Error('Please provide an endpoint for an API call'); 41 | } 42 | 43 | if (!onSuccess) { 44 | onSuccess = (options) => { } 45 | } 46 | 47 | if (!onError) { 48 | onError = (options) => { } 49 | } 50 | 51 | // set headers 52 | doRequest = request[method](this.urlRoot+endpoint); 53 | 54 | 55 | if (isEmpty(fileData)) { 56 | doRequest.accept('json'); 57 | } 58 | 59 | if (isFunction(this.requestHeaders)) { 60 | requestHeaders = this.requestHeaders(); 61 | } else { 62 | requestHeaders = this.requestHeaders; 63 | } 64 | 65 | Object.keys(requestHeaders).forEach(header => { 66 | doRequest = doRequest.set(header, requestHeaders[header]); 67 | }); 68 | 69 | // for now do not send any data except files if they are passed 70 | if (!isEmpty(fileData)) { 71 | // merge default requestData with object passed with this request 72 | if (isFunction(this.requestData)) { 73 | requestData = this.requestData(); 74 | } else { 75 | requestData = this.requestData; 76 | } 77 | 78 | Object.assign(data, requestData); 79 | } 80 | 81 | // just send as POST or prepare data for GET request 82 | if (method === 'post' || method === 'put') { 83 | if (!isEmpty(fileData)) { 84 | let formData = new FormData(); 85 | formData.append(fileData.attibuteName, fileData.file); 86 | doRequest.send(formData); 87 | } else { 88 | doRequest.send(data); 89 | } 90 | } else if (method === 'get' || method == 'del') { 91 | doRequest.query( 92 | qs.stringify( 93 | data, 94 | { arrayFormat: 'brackets' } 95 | ) 96 | ); 97 | } 98 | 99 | return new BPromise( (resolve) => { 100 | 101 | // send request and act upon result 102 | doRequest.end( (err, response) => { 103 | if (this.onRequestCompleted) this.onRequestCompleted(response); 104 | 105 | if (!response.ok) { 106 | //let errors = response.body ? response.body.errors : 'Something bad happened'; 107 | //let statusCode = response.status; 108 | 109 | if (this.onRequestError) this.onRequestError(response); 110 | 111 | onError(response); 112 | } else { 113 | onSuccess(response); 114 | } 115 | 116 | /* 117 | we resolve promise even if request 118 | was not successfull to reduce boilerplat 119 | + because we typically don't want ui do 120 | have some specific behaviour in this case 121 | */ 122 | 123 | resolve(response); 124 | 125 | }); 126 | 127 | }); 128 | 129 | } 130 | }; 131 | 132 | export default API; 133 | -------------------------------------------------------------------------------- /src/set_related_model.js: -------------------------------------------------------------------------------- 1 | import isNumber from 'lodash/isNumber'; 2 | import isPlainObject from 'lodash/isPlainObject'; 3 | import find from 'lodash/find'; 4 | import includes from 'lodash/includes'; 5 | 6 | import BaseModel from './base_model'; 7 | 8 | export default function setRelatedModel(options = {}) { 9 | 10 | let { 11 | id, 12 | modelJson, 13 | relatedModel, // related model instance, basically the same as existingRelatedModel 14 | model, 15 | relation, 16 | requestId, 17 | topLevelJson, 18 | } = options; 19 | 20 | let existingRelatedModel; 21 | // id, json, relatedModel, 22 | 23 | if (!id && !modelJson && !relatedModel) return; 24 | 25 | // if only id was passed, try to get json from top level 26 | if (id && !modelJson) { 27 | let topLevelModelJson = topLevelJson[relation.topLevelJsonKey]; 28 | if (topLevelModelJson) { 29 | modelJson = find(topLevelModelJson, { id }); 30 | } 31 | } 32 | 33 | if (!id && modelJson) id = modelJson.id; 34 | if (!id && relatedModel) id = relatedModel.id; 35 | 36 | 37 | 38 | // try to find it in array by id if hasMany relation 39 | if (relation.isHasMany) { 40 | existingRelatedModel = model[relation.propertyName].find(m => m.id === id); 41 | // or just check if property is assigned 42 | } else if (relation.isHasOne) { 43 | existingRelatedModel = model[relation.propertyName]; 44 | if (existingRelatedModel && existingRelatedModel.id !== id) existingRelatedModel = undefined; 45 | } 46 | 47 | 48 | 49 | 50 | // if no existing related model was not found 51 | if (!existingRelatedModel) { 52 | 53 | // if no related model was passed 54 | if (!relatedModel) { 55 | 56 | // if no json passed, then just try to fetch model 57 | // with given id from the store, if any 58 | if (!modelJson) { 59 | 60 | /* 61 | * !!!!!!!!!!!!!!!!!!!!!!!!!!! 62 | * TODO 63 | */ 64 | 65 | relatedModel = relation.relatedModel.get(id) 66 | 67 | // if not only id was passed in json then do regular 68 | // processing 69 | } else { 70 | 71 | // add relation to its store 72 | relatedModel = relation.relatedModel.set({ 73 | modelJson, 74 | requestId, 75 | topLevelJson 76 | }) 77 | 78 | } 79 | } 80 | 81 | // if we finally got related model, or it was passed 82 | // add it to relation property 83 | if (relatedModel) { 84 | 85 | // push new model to array 86 | if (relation.isHasMany && !includes(model[relation.propertyName], relatedModel)) { 87 | model[relation.propertyName].push(relatedModel); 88 | 89 | // or just assign it to the property 90 | } else if (relation.isHasOne) { 91 | model[relation.propertyName] = relatedModel; 92 | } 93 | 94 | // if there is reverse relation, add current model 95 | // to the related model's reverse relation. 96 | let reverseRelation = relation.reverseRelation; 97 | if (reverseRelation) { 98 | let setReverseRelation = relatedModel[reverseRelation.setMethodName] 99 | // console.log('reverseRelation', relation, relatedModel, reverseRelation.setMethodName, model) 100 | if (setReverseRelation) setReverseRelation({ relatedModel: model }); 101 | } 102 | 103 | } 104 | 105 | return relatedModel; 106 | 107 | // if there is existing related model 108 | } else { 109 | 110 | // update it with json if it was passed 111 | if (modelJson) { 112 | existingRelatedModel.set({ 113 | requestId, 114 | modelJson, 115 | topLevelJson 116 | }); 117 | } 118 | 119 | return existingRelatedModel; 120 | 121 | } 122 | 123 | } -------------------------------------------------------------------------------- /test/base_model/model_name_prop.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { isFunction } from 'lodash'; 3 | import { BaseModel } from '../../lib/index'; 4 | 5 | 6 | class AModel extends BaseModel { 7 | 8 | static modelName = 'AlphaModel'; 9 | 10 | static attributes = { 11 | value: null 12 | }; 13 | 14 | static relations = [ 15 | { 16 | type: 'hasOne', 17 | relatedModel: 'BettaModel', 18 | reverseRelation: true 19 | }, 20 | { 21 | type: 'hasMany', 22 | relatedModel: 'OmegaModel', 23 | reverseRelation: true 24 | } 25 | ]; 26 | } 27 | 28 | class BModel extends BaseModel { 29 | 30 | static modelName = 'BettaModel'; 31 | 32 | static attributes = { 33 | name: null 34 | }; 35 | 36 | static relations = [ 37 | { 38 | type: 'hasOne', 39 | relatedModel: 'AlphaModel', 40 | reverseRelation: true 41 | } 42 | ]; 43 | } 44 | 45 | class OModel extends BaseModel { 46 | 47 | static modelName = 'OmegaModel'; 48 | 49 | static attributes = { 50 | name: null 51 | }; 52 | 53 | static relations = [ 54 | { 55 | type: 'hasOne', 56 | relatedModel: 'AlphaModel', 57 | reverseRelation: true 58 | } 59 | ]; 60 | } 61 | 62 | const models = { AlphaModel: AModel, BettaModel: BModel, OmegaModel: OModel }; 63 | 64 | BaseModel.getModel = function(modelName) { 65 | return models[modelName]; 66 | }; 67 | 68 | const topLevelJson = { 69 | 70 | model: { 71 | id: 1, 72 | omega_model_ids: [ 73 | 11, 74 | 12, 75 | 13 // not existed 76 | ], 77 | value: 'foo', 78 | betta_model: { id: 2, name: 'bar' }, 79 | }, 80 | 81 | omega_models: [ 82 | { id: 11, name: 'Omega bar 11' }, 83 | { id: 12, name: 'Omega bar 12' } 84 | ] 85 | 86 | }; 87 | 88 | const modelJson = topLevelJson.model; 89 | const model = AModel.set({ modelJson, topLevelJson }); 90 | 91 | 92 | describe('Use property modelName first insead constructor.name', () => { 93 | 94 | it("should have different constructor.name and model name", function() { 95 | expect(model.constructor.name !== model.constructor.modelName).to.equal(true); 96 | }); 97 | 98 | describe('urlRoot should calculated by `modelName`', () => { 99 | 100 | it("for model class", function() { 101 | expect(AModel.urlRoot).to.equal('/alpha_models'); 102 | expect(model.constructor.urlRoot).to.equal('/alpha_models'); 103 | }); 104 | 105 | it("for model item", function() { 106 | expect(model.urlRoot).to.equal('/alpha_models'); 107 | }); 108 | 109 | }); 110 | 111 | describe('hasOne', () => { 112 | 113 | it("should set `hasOne`-type related model data", function() { 114 | expect(model.bettaModel.id).to.equal(2); 115 | expect(model.bettaModel.name).to.equal('bar'); 116 | }); 117 | 118 | it("should have reverse related model attribute", function() { 119 | expect(model.bettaModel.alphaModel.id).to.equal(1); 120 | expect(model.bettaModel.alphaModel.value).to.equal('foo'); 121 | }); 122 | 123 | }); 124 | 125 | describe('hasMany', () => { 126 | 127 | it("should set `hasMany`-type related models data", function() { 128 | expect(!!model.omegaModels).to.equal(true); 129 | expect(model.omegaModels[0].id).to.equal(11); 130 | expect(model.omegaModels[0].name).to.equal('Omega bar 11'); 131 | expect(model.omegaModels[1].id).to.equal(12); 132 | expect(model.omegaModels[1].name).to.equal('Omega bar 12'); 133 | }); 134 | 135 | it("should have reverse related model attribute", function() { 136 | expect(model.omegaModels[0].alphaModel.id).to.equal(1); 137 | expect(model.omegaModels[0].alphaModel.value).to.equal('foo'); 138 | expect(model.omegaModels[1].alphaModel.id).to.equal(1); 139 | expect(model.omegaModels[1].alphaModel.value).to.equal('foo'); 140 | }); 141 | 142 | }); 143 | 144 | 145 | }); 146 | -------------------------------------------------------------------------------- /test/base_model/relations.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { BaseModel } from '../../lib/index'; 3 | 4 | class AlphaModel extends BaseModel { 5 | static attributes = { 6 | value: null, 7 | }; 8 | 9 | static relations = [ 10 | { 11 | type: 'hasOne', 12 | relatedModel: 'BettaModel', 13 | reverseRelation: true, 14 | }, 15 | { 16 | type: 'hasMany', 17 | relatedModel: 'OmegaModel', 18 | reverseRelation: true, 19 | }, 20 | ]; 21 | } 22 | 23 | class BettaModel extends BaseModel { 24 | static attributes = { 25 | name: null, 26 | }; 27 | 28 | static relations = [ 29 | { 30 | type: 'hasOne', 31 | relatedModel: 'AlphaModel', 32 | reverseRelation: true, 33 | }, 34 | ]; 35 | } 36 | 37 | class OmegaModel extends BaseModel { 38 | static attributes = { 39 | name: null, 40 | }; 41 | 42 | static relations = [ 43 | { 44 | type: 'hasOne', 45 | relatedModel: 'AlphaModel', 46 | reverseRelation: true, 47 | }, 48 | ]; 49 | } 50 | 51 | const models = { AlphaModel, BettaModel, OmegaModel }; 52 | 53 | BaseModel.getModel = function(modelName) { 54 | return models[modelName]; 55 | }; 56 | 57 | function getAlphaModel() { 58 | const topLevelJson = { 59 | alpha_model: { 60 | id: 1, 61 | omega_model_ids: [11, 12], 62 | value: 'foo', 63 | betta_model: { id: 2, name: 'bar' }, 64 | }, 65 | 66 | omega_models: [ 67 | { id: 11, name: 'Omega bar 11' }, 68 | { id: 12, name: 'Omega bar 12' }, 69 | ], 70 | }; 71 | const modelJson = topLevelJson.alpha_model; 72 | return AlphaModel.set({ modelJson, topLevelJson }); 73 | } 74 | 75 | function getOmegaModel() { 76 | const topLevelJson = { 77 | omega_model: { 78 | id: 13, 79 | name: 'Omega bar 13', 80 | alpha_model_id: 1, 81 | }, 82 | }; 83 | const modelJson = topLevelJson.omega_model; 84 | return OmegaModel.set({ modelJson, topLevelJson }); 85 | } 86 | 87 | const model = getAlphaModel(); 88 | const modelOmega = getOmegaModel(); 89 | 90 | describe('Relations', () => { 91 | it('should set model value', function() { 92 | expect(model.value).to.equal('foo'); 93 | }); 94 | 95 | describe('hasOne', () => { 96 | it('should set `hasOne`-type related model data', function() { 97 | expect(model.bettaModel.id).to.equal(2); 98 | expect(model.bettaModel.name).to.equal('bar'); 99 | }); 100 | 101 | it('should have reverse related model attribute', function() { 102 | expect(model.bettaModel.alphaModel.id).to.equal(1); 103 | expect(model.bettaModel.alphaModel.value).to.equal('foo'); 104 | }); 105 | }); 106 | 107 | describe('hasMany', () => { 108 | it('should set `hasMany`-type related models data', function() { 109 | expect(!!model.omegaModels).to.equal(true); 110 | expect(model.omegaModels[0].id).to.equal(11); 111 | expect(model.omegaModels[0].name).to.equal('Omega bar 11'); 112 | expect(model.omegaModels[1].id).to.equal(12); 113 | expect(model.omegaModels[1].name).to.equal('Omega bar 12'); 114 | }); 115 | 116 | it('should have reverse related model attribute', function() { 117 | expect(model.omegaModels[0].alphaModel.id).to.equal(1); 118 | expect(model.omegaModels[0].alphaModel.value).to.equal('foo'); 119 | expect(model.omegaModels[1].alphaModel.id).to.equal(1); 120 | expect(model.omegaModels[1].alphaModel.value).to.equal('foo'); 121 | }); 122 | }); 123 | 124 | describe('Reverse relation', () => { 125 | it('should have parent AlphaModel related to OmegaModel', function() { 126 | expect(!!modelOmega).to.equal(true); 127 | 128 | expect(modelOmega.alphaModel.id).to.equal(1); 129 | expect(modelOmega.alphaModel.value).to.equal('foo'); 130 | expect(modelOmega.alphaModel.omegaModels[2].id).to.equal(13); 131 | expect(modelOmega.alphaModel.omegaModels[2].name).to.equal( 132 | 'Omega bar 13', 133 | ); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /src/set_relations_defaults.js: -------------------------------------------------------------------------------- 1 | import { upperCaseFirstLetter, lowercaseFirstLetter } from './utils'; 2 | import { 3 | pluralize, underscore, tableize, foreign_key, 4 | singularize 5 | } from 'inflection'; 6 | import isBoolean from 'lodash/isBoolean'; 7 | import isString from 'lodash/isString'; 8 | 9 | // mutate static relations and add defaults 10 | // to each relation 11 | export default function setRelationsDefaults(model) { 12 | 13 | if (!model.constructor.getModel) { 14 | throw new Error("getModel static method must be defined for a \ 15 | base model class, that returns model class given its name") 16 | } 17 | 18 | model.constructor.relations.forEach(relation => { 19 | 20 | if (relation._isPrepared) return; 21 | 22 | // console.log('setRelationsDefaults', model, relation) 23 | 24 | // shorthand method to quickly check if relation is of hasMany type 25 | Object.defineProperty(relation, "isHasMany", { 26 | get: function() { 27 | return this.type === 'hasMany' 28 | } 29 | }); 30 | 31 | // shorthand method to quickly check if relation is of hasOne type 32 | Object.defineProperty(relation, "isHasOne", { 33 | get: function() { 34 | return this.type === 'hasOne' 35 | } 36 | }); 37 | 38 | // set initialValue for relation property 39 | if (relation.isHasMany) { 40 | relation.initialValue = []; 41 | } else if (relation.isHasOne) { 42 | relation.initialValue = null; 43 | } 44 | 45 | if (isString(relation.relatedModel)) { 46 | relation.relatedModel = model.constructor.getModel(relation.relatedModel); 47 | } 48 | 49 | // property name on model instance to relation(s) 50 | if (!relation.propertyName) { 51 | relation.propertyName = lowercaseFirstLetter(relation.relatedModel.modelName || relation.relatedModel.name); 52 | 53 | if (relation.isHasMany) { 54 | relation.propertyName = pluralize(relation.propertyName) 55 | } 56 | } 57 | 58 | // json key for embedded json 59 | if (!relation.jsonKey) { 60 | relation.jsonKey = underscore(relation.propertyName); 61 | } 62 | 63 | // key in top level json 64 | if (!relation.topLevelJsonKey) { 65 | relation.topLevelJsonKey = tableize(relation.propertyName); 66 | } 67 | 68 | // foreign key with ids of relations 69 | if (!relation.foreignKey) { 70 | if (relation.isHasMany) { 71 | relation.foreignKey = foreign_key(singularize(relation.propertyName)) + 's'; 72 | } else if (relation.isHasOne) { 73 | relation.foreignKey = foreign_key(relation.propertyName); 74 | } 75 | } 76 | 77 | let name = upperCaseFirstLetter(relation.propertyName); 78 | if (relation.isHasMany) name = singularize(name); 79 | 80 | // method name to add single relation, will be used as alias 81 | if (!relation.setMethodName) { 82 | relation.setMethodName = `set${name}`; 83 | } 84 | 85 | // method name to remove single relation, will be used as alias 86 | if (!relation.removeMethodName) { 87 | relation.removeMethodName = `remove${name}`; 88 | } 89 | 90 | let reverseRelation = relation.reverseRelation; 91 | 92 | if (reverseRelation) { 93 | 94 | if (isBoolean(reverseRelation)) { 95 | reverseRelation = relation.reverseRelation = {}; 96 | } 97 | 98 | if (!reverseRelation.onDestroy && reverseRelation.onDestroy !== false) { 99 | reverseRelation.onDestroy = 'removeSelf' 100 | } 101 | 102 | if (!reverseRelation.propertyName) { 103 | reverseRelation.propertyName = lowercaseFirstLetter(model.constructor.modelName || model.constructor.name); 104 | } 105 | 106 | let name = upperCaseFirstLetter(reverseRelation.propertyName); 107 | 108 | if (!reverseRelation.setMethodName) { 109 | reverseRelation.setMethodName = `set${name}`; 110 | } 111 | 112 | if (!reverseRelation.removeMethodName) { 113 | reverseRelation.removeMethodName = `remove${name}`; 114 | } 115 | 116 | //console.log('setRelationsDefaults reverseRelation is true', relation.reverseRelation, relation) 117 | 118 | } 119 | 120 | relation._isPrepared = true; 121 | 122 | }); 123 | 124 | } -------------------------------------------------------------------------------- /docs/defining_relations.md: -------------------------------------------------------------------------------- 1 | # Defining relations 2 | 3 | To use relations you must extend `BaseModel` class and add static `relations` property 4 | 5 | ```js 6 | import { BaseModel } from 'mobx-model'; 7 | 8 | class Post extends BaseModel 9 | static relations = [ 10 | { 11 | type: 'hasMany', 12 | relatedModel: 'Comments' 13 | } 14 | ] 15 | end 16 | ``` 17 | 18 | ## Relation config options 19 | 20 | Note that if config option has a default it can be ommitted when declaring relations. Note that defaults are dynamic, so that you can incrementally override defautls as you need. 21 | 22 | | option | type | default | description | 23 | | -- | -- | -- | -- | 24 | | `type` | `string` | | Can be `hasOne` or `hasMany`. An observable property defined by `propertyName` will be added to instance of model. If relation is `hasMany` an observable array will be added | 25 | | `relatedModel` | `string` | | Name of the model that will be supplied to `getModel` method to get related model class. We need this due to circular dependency problem when models refer to each other | 26 | | `propertyName` | `string` | `relatedModel` with lowercased first letter for `hasOne` and is further pluralized for `hasMany` | Name of the property that will be set on model instance. For example if model is `Post` and `relatedModel` is `BestComment` then when relation is `hasOne` propertyName defaults to `bestComment`. When relation is `hasMany` it defaults to `bestComments` and can be later accessed with `post.bestComments` | 27 | | `jsonKey` | `string` | uderscored `propertyName` | Is used to get relation json from provided model json. When `propertyName` is `bestComments` relation json by default is expected to be under `best_comments` key, and when `propertyName` is `bestComment` then it defaults to `best_comment` | 28 | | `topLevelJsonKey` | `string` | tableized `propertyName` | Is used to get relation json from normalized array of objects in top level. if `propertyName` is `bestComments` than by default top level json must have `best_comments` key that is an array of objects | 29 | | `foreignKey` | `string` | foreign key derived from `propertyName` | Used to look up relation ids in model json, that later will be used to find relation json in top level defined by `topLevelJsonKey`. If `propertyName` is `bestComment` and relation type is `hasOne` then `foreignKey` defaults to `best_comment_id`. If `propertyName` is `bestComments` and relation type is `hasMany` then `foreignKey` defaults to `best_comment_ids` | 30 | | `setMethodName` | `string` | `set` + singularized `propertyName` with uppercased first letter | Name of the instance method to set related model, for `Post` that `hasMany` `Comment` it will default to `setComment` | 31 | | `removeMethodName` | `string` | `remove` + singularized `propertyName` with uppercased first letter | Name of the instance method to remove related model, for `Post` that `hasMany` `Comment` it will default to `removeComment` | 32 | | `reverseRelation` | `boolean` or `object` | `false` | If set to true or to an object then related model will have a property defined by `reverseRelation.propertyName` be set to this model instance. E.g. `Post` that `hasMany` `Comment` and `reverseRelation: true` will make `comment.post` available if `Post` relation is also set for `Comment` as `hasOne` relation | 33 | 34 | 35 | ## Reverse relation config options 36 | 37 | | option | type | default | description | 38 | | -- | -- | -- | -- | 39 | | `onDestroy` | `string` | `removeSelf` | Can be `removeSelf` or `destroyRelation`. Defines what needs to be done when relation is destroyed. When option is `removeSelf` this model will be removed from reverse relation, e.g. a comment will be removed from list of comments for a post. When it is set to `destroyRelation` then related model will be destroyed when this model is destroyed, e.g. when deleting a post we can delete all related comments. | 40 | | `propertyName` | `string` | this model's name with lowercased first letter | {roperty name to set or remove on reverse relation. If `Post` has `Comment` then defaulP `reverseRelation.propertyName` will be `post`. Note that it should be singular even for `hasMany` relations | 41 | | `setMethodName` | `string` | `set` + `reverseRelation.propertyName` with uppercased first letter | Instance method name on related model to set reverse relation. For `Post` that `hasMany` `Comment` it will default to `setPost` | 42 | | `removeMethodName` | `string` | `remove` + `reverseRelation.propertyName` with uppercased first letter | Instance method name on related model to remove reverse relation. For `Post` that `hasMany` `Comment` it will default to `removePost` | -------------------------------------------------------------------------------- /src/base_model.js: -------------------------------------------------------------------------------- 1 | import { 2 | runInAction, isObservableArray 3 | } from 'mobx'; 4 | import { tableize, underscore, camelize } from 'inflection'; 5 | import filter from 'lodash/filter'; 6 | import uniqueId from 'lodash/uniqueId'; 7 | import result from 'lodash/result'; 8 | 9 | import initAttributes from './init_attributes'; 10 | import setAttributes from './set_attributes'; 11 | import initRelations from './init_relations'; 12 | import setRelations from './set_relations'; 13 | 14 | /* 15 | * This is a hack to allow each model that extends 16 | * BaseModel to have its own collection. Model is 17 | * assigned a collection when first instance of model is 18 | * created or when Model.all() method is called 19 | */ 20 | const initObservables = function(target) { 21 | if (!target.observables) { 22 | target.observables = { collection: [] }; 23 | } 24 | } 25 | 26 | 27 | class BaseModel { 28 | 29 | static attributes = {}; 30 | static relations = []; 31 | 32 | id = null; 33 | lastSetRequestId = null; 34 | 35 | static get = function(id) { 36 | let items = result(this, 'observables.collection') 37 | if (items) { 38 | let l = items.length; 39 | for(var i = 0; i < l; i++) { 40 | if (items[i].id.toString() === id.toString()) return items[i]; 41 | } 42 | } 43 | 44 | return null; 45 | }; 46 | 47 | static set = function(options = {}) { 48 | 49 | let { modelJson, topLevelJson, requestId } = options; 50 | 51 | /* 52 | requestId is used to allow models to 53 | prevent loops when setting same attributes 54 | multiple times, we set one if none is set 55 | */ 56 | if (!requestId) requestId = uniqueId('request_'); 57 | 58 | /* 59 | * topLevelJson is used to get json for models referenced by ids 60 | */ 61 | if (!topLevelJson) topLevelJson = modelJson; 62 | 63 | let model = this.get(modelJson.id); 64 | 65 | runInAction(() => { 66 | if (!model) { 67 | model = new this({ 68 | modelJson, 69 | topLevelJson, 70 | requestId 71 | }); 72 | 73 | this.observables.collection.push(model); 74 | } 75 | 76 | model.set({ modelJson, topLevelJson, requestId }); 77 | }); 78 | 79 | // console.log('set', model) 80 | 81 | return model; 82 | }; 83 | 84 | static remove = function (model) { 85 | if (this.observables && this.observables.collection) { 86 | this.observables.collection.splice(this.observables.collection.indexOf(model), 1); 87 | } 88 | }; 89 | 90 | static all = function() { 91 | initObservables(this); 92 | return this.observables.collection.slice(); 93 | }; 94 | 95 | static addClassAction(actionName, method) { 96 | Object.defineProperty(this, actionName, { 97 | get: function() { 98 | return method.bind(this); 99 | } 100 | }); 101 | }; 102 | 103 | static addAction(actionName, method) { 104 | Object.defineProperty(this.prototype, actionName, { 105 | get: function() { 106 | return method.bind(this); 107 | } 108 | }); 109 | }; 110 | 111 | constructor(options = {}) { 112 | let { 113 | modelJson, 114 | topLevelJson, 115 | requestId 116 | } = options; 117 | 118 | 119 | initObservables(this.constructor) 120 | 121 | 122 | 123 | if (modelJson && modelJson.id) { 124 | this.id = modelJson.id; 125 | } 126 | 127 | initAttributes({ model: this }); 128 | initRelations({ model: this }); 129 | 130 | this.onInitialize(); 131 | } 132 | 133 | 134 | set(options = {}) { 135 | let { requestId, modelJson, topLevelJson } = options; 136 | let model = this; 137 | 138 | if (!requestId) requestId = uniqueId('request_'); 139 | 140 | if (this.lastSetRequestId === requestId) { 141 | return; 142 | } else { 143 | this.lastSetRequestId = requestId; 144 | } 145 | 146 | runInAction(() => { 147 | setAttributes({ model, modelJson }); 148 | 149 | setRelations({ 150 | model, 151 | requestId, 152 | modelJson, 153 | topLevelJson 154 | }); 155 | }); 156 | } 157 | 158 | 159 | get urlRoot() { 160 | return this.constructor.urlRoot; 161 | } 162 | 163 | get jsonKey() { 164 | return this.constructor.jsonKey; 165 | } 166 | 167 | onInitialize() { 168 | } 169 | 170 | onDestroy() { 171 | runInAction(() => { 172 | this.removeSelfFromCollection(); 173 | this.destroyDependentRelations(); 174 | this.removeSelfFromRelations(); 175 | }); 176 | } 177 | 178 | removeSelfFromCollection() { 179 | this.constructor.remove(this); 180 | } 181 | 182 | destroyDependentRelations() { 183 | let relationsToDestroy = filter(this.constructor.relations, relation => { 184 | let reverseRelation = relation.reverseRelation; 185 | return reverseRelation && reverseRelation.onDestroy === 'destroyRelation'; 186 | }); 187 | 188 | relationsToDestroy.forEach(relation => { 189 | if (relation.isHasMany) { 190 | this[relation.propertyName].slice().forEach(relatedModel => { 191 | relatedModel.onDestroy(); 192 | }) 193 | } else if (relation.isHasOne) { 194 | this[relation.propertyName].onDestroy(); 195 | } 196 | }) 197 | } 198 | 199 | removeSelfFromRelations() { 200 | 201 | let relationsToRemoveFrom = filter(this.constructor.relations, relation => { 202 | let reverseRelation = relation.reverseRelation; 203 | return reverseRelation && reverseRelation.onDestroy === 'removeSelf'; 204 | }); 205 | 206 | relationsToRemoveFrom.forEach(relation => { 207 | 208 | let removeMethodName = relation.reverseRelation.removeMethodName; 209 | 210 | if (relation.isHasMany) { 211 | this[relation.propertyName].slice().forEach(relatedModel => { 212 | if (relatedModel[removeMethodName]) { 213 | relatedModel[removeMethodName](this); 214 | } 215 | }) 216 | } else if (relation.isHasOne) { 217 | // console.log(relation.propertyName, removeMethodName, this[relation.propertyName]) 218 | if (this[relation.propertyName] && this[relation.propertyName][removeMethodName]) { 219 | this[relation.propertyName][removeMethodName](this); 220 | } 221 | } 222 | }) 223 | } 224 | 225 | toJSON () { 226 | const { id, constructor } = this; 227 | const { attributes, relations } = constructor; 228 | 229 | // collect attributes 230 | const attributeValues = Object.keys(attributes || {}).reduce((values, attributeName) => { 231 | values[attributeName] = this[attributeName]; 232 | return values; 233 | }, {}); 234 | 235 | // collect relation models id 236 | const relationValues = (relations || []).reduce((values, {type, propertyName, foreignKey}) => { 237 | const camelizedForeignKey = camelize(foreignKey, true); 238 | 239 | if (type === 'hasMany') { 240 | values[camelizedForeignKey] = (this[propertyName] || []).slice().map(model => model.id); 241 | } 242 | 243 | if (type === 'hasOne') { 244 | values[camelizedForeignKey] = (this[propertyName] || {}).id; 245 | } 246 | 247 | return values; 248 | }, {}); 249 | 250 | return { 251 | id, 252 | ...attributeValues, 253 | ...relationValues, 254 | }; 255 | } 256 | 257 | } 258 | 259 | Object.defineProperty(BaseModel, 'urlRoot', { 260 | get: function() { 261 | return this._urlRoot ? this._urlRoot : '/'+tableize(this.modelName || this.name); 262 | }, 263 | set: function(value) { 264 | this._urlRoot = value; 265 | } 266 | }); 267 | 268 | Object.defineProperty(BaseModel, 'jsonKey', { 269 | get: function() { 270 | return this._jsonKey ? this._jsonKey : underscore(this.modelName || this.name); 271 | }, 272 | set: function(value) { 273 | this._jsonKey = value; 274 | } 275 | }); 276 | 277 | 278 | export default BaseModel; --------------------------------------------------------------------------------