@ducharmemp/mobstr
v1.0.0-alpha.3
Published
Object relational mapper for MobX stores
Downloads
4
Maintainers
Readme
MobStr
MobStr is a project designed to provide an ORM-like interface to a MobX store. The goal of this project is to achieve a low-overhead, normalized approach to modeling to allow developers to focus on domain modeling without having to deal with manual book-keeping of object relationships. It also provides an opt-in approach to constraint checking, which can lead to better performance since we simply don't check every field if it's not marked with a check constraint.
Breaking Change for v1.0
Microbundle/rollup doesn't play well with named and default exports mixing. Therefore, all exports will now be explicitly named. The following code:
import initialize from 'mobstr'
Will need to be rewritten to:
import { initializeStore } from 'mobstr'
No other API changes are planned.
Benefits
- Simple and direct query API to find/delete entries from the data store
- Declarative API for describing object models in a manner similar to other data stores
- Relationship cascades and auto-cleanup for removed objects
- Separation of concerns for managing object models and RESTful/GraphQL API queries (no sagas/thunks)
- Update objects directly without the need for complex copy operations
- Opt-in semantics for constraints and checks on columns for debugging purposes and sanity checks.
Technical Details
Additionally, it's actually fairly easy to make certain guarantees that MobX provides invalid by complete accident, especially when copying string keys from observable objects into another observable. The answer is to leverage computed
or autorun
or other reaction-based functions, but this library should abstract over those to the point where the user doesn't need to necessarily worry about committing error-prone code in these specialized cases.
There do exist other solutions in the MobX examples and they are perfectly valid, but they require passing around parent contexts and there isn't an out of the box solution for saying "I have all of these models that I know are related to parents, but I just want these without looping through all of the parents". Consider this example store code loosely lifted from the MobX documentation:
class ToDo {
constructor(store) {
this.store = store;
}
}
class Parent {
@observable todos = []
makeTodo() {
this.todos.push(new ToDo(this));
}
}
Full and complete sample here: https://mobx.js.org/best/store.html
This requires only a simple flatmap to achieve the desired output of a list of all ToDos, but more complicated relationships would easily become more cumbersome. For example, take the following code snippet:
class Step {}
class ToDo {
@observable steps = [];
makeStep() {
this.steps.push(new Step(this))
}
constructor(store) {
this.store = store;
}
}
class Parent {
@observable todos = []
makeTodo() {
this.todos.push(new ToDo(this));
}
}
The overall approach is still the same (flatMap with a greater depth to get all Steps from all ToDos), but it would be nice to simply query for all of the steps that currently exist in isolation, or all ofthe ToDos that currently exist without having to traverse the parent contexts.
With this project, I hope to separate the concerns of managing a centralized store with an accessible syntax for describing model relationships and model structure. Eventually I also hope to integrate nice-to-have features, such as index only lookups, complex primary key structures, and relationship cascade options.
All told, this project is about 200 lines of actual code (so far!), with most of the actual code lying in the decorators to set up meta attributes and maintain book-keeping, so it should achieve a very similar result to mobx-state-tree while cutting down on the complexity. LOC isn't a great metric for complexity or scope but it's what I have on hand.
As of now, the form that the __meta__
attribute takes is this:
__meta__: {
key: IObservableValue<string | symbol | number | null>;
collectionName: string | symbol | number;
relationships: Record<
string | symbol,
{
type: any;
keys: IObservableArray<string>;
options: Record<string, any>;
}
>;
indicies: IObservableArray<string | symbol | number>;
};
Example of "proper" typing of class attributes:
class Bar {
@primaryKey
id: string = uuid();
}
class Foo {
@primaryKey
id: string = uuid();
@relationship(type => Bar)
friends: Bar[] = [];
}
const f = new Foo();
f.friends[0].id // This properly gives us type hints because we've typed it as a Bar[]. We could have also typed it as an IObservableArray
At this time, the recommended way to use POJOs in this library is similar to this example code:
class Foo {
@primaryKey
id = uuid();
@observable
someProperty = []
}
// returnValue = { status: 200, data: {id: '1234', someProperty: [1, 2, 3, 4] }}
function apiCallResult(returnValue) {
// Validate
...
// Dump the result into a new instance of the model
const f = Object.assign(new Foo(), returnValue.data);
add(f);
return f;
}
class Foo {
@primaryKey
id: string = uuid();
@relationship(store, () => Foo, { cascade: true })
leaves: Foo[] = [];
}
const foo = new Foo();
const leaves = [new Foo(), new Foo()];
const otherLeaves = [new Foo(), new Foo()];
addOne(store, foo);
foo.leaves.push(...leaves);
leaves[0].leaves.push(...otherLeaves);
findAll(Foo).length === 5;
removeOne(foo);
findAll(Foo).length === 0;
However, this does still have the same limitations as POJOs currently do, so you can't directly shove a JSON structure into the store, there has to be a preprocessing step. However, a nice side effect of this is the ability to gather all Foo objects in a single query without walking the entirety of the tree.
At this time, no. It has a lot to do with when javascript class definitions are evaluated. For an example of what I'm talking about, please reference the below code:
class Bar {
@primaryKey;
id = uuid()
@relationship(() => Foo)
foos = [];
}
class Foo {
@primaryKey
id = uuid();
@relationship(() => Bar)
bars = []
}
At class definition time, "Foo" as a type is undefined, so the overall code will fail. I hope to eventually allow for these kinds of structures by using some form of lazy evalutaion on relationship definitions, similar to the method employed by SQLAlchemy.
No, since the decorator specification doens't allow for usage of decorators within anonymous classes, there's not much that MobStr can do at this time. I hope that in the future we could allow for something like this, since it could open up doors for dynamic model creation, although I'm not sure if that's a great idea or a terrible idea.
Examples
You can find some comprehensive toy examples in tests/integration.test.ts. Below is an example of real-world(ish) example using a fetch to get a company name and the a list of employees from that company.
import { observable, computed } from 'mobx';
import { initializeStore } from 'mobstr';
const {
relationship,
primaryKey,
addAll,
findOne,
removeOne,
truncateCollection,
notNull,
} = initializeStore();
class Employee {
@primaryKey
id = uuid()
}
class Company {
@primaryKey
id = uuid()
@notNull
name;
@observable
affiliates = []
@relationship(type => Employee)
employees = [];
@computed
get employeeIds() { return this.employees.map(employee => employee.id); }
}
async function getAllCompanies(companyIds) {
const companyData = await Promise.all(
companyIds.map(companyId => fetch(`/url/for/company/${companyId}`)
);
companyData.forEach(company => {
// Get all of the employee objects from the DB
const employees = await Promise.all(
company.employees.map(employee => fetch(`/url/for/company/employee/${employee}`))
);
// Note that this would overwrite any existing employees with the same ID in the data store, so make sure your IDs are unique!
company.employees = employees.map(employee => Object.assign(new Employee(), employee))
});
return companyData.map(company => Object.assign(new Company(), company));
}
// Top level await for illustrative purposes only
addAll(await getAllCompanies([1, 2, 3, 4))
findOne(Company, 1).employees.push(new Employee());
...
// Maybe we want to show a table of the company in one column with an employee in the other
join(Company, Employees).map((company, employee) => [company.id, employee.id])
...
function destroyCompany(companyId) {
findOne(Company, companyId).employees = [];
// If we had cascade: true in our relationship options, we could also delete the company from the store like so:
// removeOne(findOne(Company, companyId));
}
// Example of a react component to display all companies and with a button to delete all employees for a given company
function ShowAllCompanies(props) {
const companies = findAll(Company);
return (
<div>
{
companies.map(company => (
<div>
<span>{company.name}</span>
<button onClick={destroyCompany.bind(null, company.id)}>Destroy {company.name}?</button>
</div>
))
}
</div>
);
}
Getting Started
This does require decorator support for now, so follow the instructions for enabling babel decorator support here: https://babeljs.io/docs/en/babel-plugin-proposal-decorators
If using TypeScript, enable the "experimentalDecorators" flag in tsconfig.json, instructions located here: https://www.typescriptlang.org/docs/handbook/decorators.html
If using Create-React-App in conjunction with this project and don't wish to eject, please use react-app-rewired to override the babel settings, located here: https://github.com/timarney/react-app-rewired
Running the tests
This project uses mocha/chai for testing purposes. To invoke, use npm test
to run the test suite.
Built With
Versioning
We use SemVer for versioning. For the versions available, see the tags on this repository.
Authors
- Matthew DuCharme - Initial work - My Github
License
This project is licensed under the MIT License - see the LICENSE.md file for details