1.0.0-alpha.3 • Published 5 years ago

@ducharmemp/mobstr v1.0.0-alpha.3

Weekly downloads
1
License
SEE LICENSE IN LI...
Repository
github
Last release
5 years ago

MobStr

CircleCI CodeCov npm version

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.

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>;
  };

class Foo { @primaryKey id: string = uuid();

@relationship(type => Bar)
friends: Bar[] = [];

}

const f = new Foo(); f.friends0.id // This properly gives us type hints because we've typed it as a Bar[]. We could have also typed it as an IObservableArray

</details>

<details>
  <summary><b>Does this have anything to do with network calls?</b></summary>
At this time, no. There are plenty of ORMs for REST interfaces and GrahQL interfaces that are more feature complete than a hobby project, and I wanted to focus on an area that I felt was lacking in the front-end.
</details>

<details>
  <summary><b>Can I store arbitrary objects without a prototype in the store without defining a model?</b></summary>
Not exactly, at least not yet. I hope to make that a 1.0 feature. However, the likelihood of allowing similar definitions of `relationship` and `primaryKey` is uncertain at this time, due to the need for type names for storage purposes. It's entirely possible that this library could also offer a `collection` wrapper that would allow similar semantics for plain old objects.
 
At this time, the recommended way to use POJOs in this library is similar to this example code:

```js
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;
}
@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); leaves0.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.
</details>
<details>
<summary><b>Can I have complex recursive relationships?</b></summary>

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:

```js
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.

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

License

This project is licensed under the MIT License - see the LICENSE.md file for details