immutable-core-model

Immutable model built on immutable-core


License
MIT
Install
npm install immutable-core-model@6.0.0

Documentation

immutable-core-model

Immutable Core Model integrates with the Immutable App ecosystem and provides persistence of immutable data objects using MySQL, Redis and Opensearch.

Immutable Core Model requires native async/await support.

Creating a new database connection

const mysql = ImmutableCoreModel.createMysqlConnection({
    database: 'database-name',
    host: 'localhost',
    password: 'db-password'
    user: 'db-user'
})

The connection parameters will have required defaults added and will then be passed to mysql2 to create a connection.

If the connectionLimit param is set then a connection pool will be created instead of a single connection. This is recommended for production use.

The default connection params are:

bigNumberStrings: true,
dateStrings: true,
namedPlaceholders: true,
rowsAsArray: false,
supportBigNumbers: true,

These parameters are needed for Immutable Core Model process results correctly and so they cannot be changed.

Creating a new model

const ImmutableCoreModel = require('immutable-core-model')

var fooModel = new ImmutableCoreModel({name: 'foo'})

Syncing model specification to database

await fooModel.sync()

In a production environment clients should not have database credentials that allow anything besides INSERT and SELECT on the database.

All models should be sync'd prior to deploying new code with specification changes that require creating or modifying tables.

Altering model tables, columns, and indexes

Immutable Core Model only supports limited alterations to existing model tables:

  • Adding new columns
  • Adding new indexes to columns without indexes

These operations are currently done as ALTER TABLE statements which may cause significant performance impacts with large tables in production envrionments.

Compression

By default Immutable Core Model compresses data records using Google's snappy compression algorithm.

Disabling compression for an individual model

new ImmutableCoreModel({compression: false})

Setting the default compression setting for all models

ImmutableCoreModel.defaultCompression(false)

Setting compression from the env

DEFAULT_COMPRESSION=true node app
DEFAULT_COMPRESSION=false node app

DEFAULT_COMPRESSION=1 node app
DEFAULT_COMPRESSION=0 node app

If the default compression setting is set in the environment this will override any values set either globally or at the model level in code.

Id column type

By default Immutable Core Model uses binary columns for ids. This saves space but makes it more difficult to work with the database using other tools.

Disabling binaryIds for an individual model

new ImmutableCoreModel({binaryIds: false})

Setting the default binaryIds setting for all models

ImmutableCoreModel.defaultBinaryIds(false)

Setting id column type from the env

DEFAULT_BINARY_IDS=true node app
DEFAULT_BINARY_IDS=false node app

DEFAULT_BINARY_IDS=1 node app
DEFAULT_BINARY_IDS=0 node app

If the default binary ids setting is set in the environment this will override any values set either globally or at the model level in code.

Schema

For the simple example above the following schema will be created:

CREATE TABLE foo (
    n bigint(20) unsigned NOT NULL AUTO_INCREMENT,
    c smallint(5) unsigned NOT NULL DEFAULT '1',
    d tinyint(1) NOT NULL DEFAULT '0',
    fooAccountId binary(16) NOT NULL,
    fooCreateTime datetime(6) NOT NULL,
    fooData mediumblob NOT NULL,
    fooId binary(16) NOT NULL,
    fooOriginalId binary(16) NOT NULL,
    fooParentId binary(16) DEFAULT NULL,
    fooSessionId binary(16) NOT NULL,
    PRIMARY KEY (n),
    UNIQUE KEY (fooId),
    UNIQUE KEY (fooParentId),
    KEY (fooAccountId),
    KEY (fooCreateTime),
    KEY (fooOriginalId),
    KEY (fooSessionId)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

All single character column names are reserved for system use and single character system columns will always come first in the schema.

All model columns are created in alphabetical order after system columns.

As of version 2.0.0 all ids are lower case. Previously they were upper case.

n

The n column provides and auto increment id for each record.

NEVER USE THIS ID IN YOUR APPLICATIONS

c

The c column indicates whether or not record data is compressed. The c column will only be created if a data column is present.

0 indicates that data is not commpressed and 1 indicates that data is compressed using snappy.

d

The d column indicates whether or not the record is deleted.

0 indicates that the record is not deleted and 1 indicates that the record is deleted.

fooAccountId

The accountId of the account that owns the foo object.

fooCreateTime

fooCreateTime is a microsecond timestamp generated using the micro-timestamp module.

Timestamps are generated in the application and not in the database which is necessary because fooId includes the timestamp in the data that is hashed.

fooData

fooData is a JSON encoding of the foo object using the json-stable-stringify module. This data is then compressed using the snappy module which is a binding for Google's Snappy compression library.

fooId

fooId is a hash of the JSON encoding of fooOriginalId, fooParentId, fooData, fooCreateTime, sessionId, and accountId. The hash is calculated before any compression is performed.

Undefined values are not included in the JSON encoding and the database driver converts NULL values from the database to undefined so that they will not be included in JSON encodings.

The hash value is the first 128 bits on an SHA-2 hash calculated using the stable-id module.

fooOriginalId and fooParentId

The original and parent ids in Immutable Core Model are used to track object revisions.

When a foo record is initially created its originalId will be equal to its id and its parentId will be null.

When a revision to that record is created it will have the same originalId as the first record and its parentId will be equal to the id of the first record.

fooId, fooOriginalId, fooParentId Example

Revision fooId fooOriginalId fooParentId
1st 1111 1111 NULL
2nd 2222 1111 1111
3rd 3333 1111 2222
4th 4444 1111 3333

Distributed Concurrency Control

The UNIQUE INDEX on parentFooId insures that in a multi-reader, multi-writer, distributed system data does not become corrupted.

In order to create a new revision of foo it is necessary to provide the parentFooId from the last version of foo fetched from the database.

If this record is outdated then the INSERT with that parentFooId will fail because it has already been used by another writer.

In this case the client must abort or refetch the latest revision of foo and retry.

fooSessionId

The sessionId of the session that created the foo object.

Setting the database engine for models

The database engine can be set either globally or on an individual model.

If the engine is specified on a model this will override any global value.

The database engine will not be changed or checked after the initial sync.

Setting the database engine from the ENV

DEFAULT_ENGINE=TokuDB node app.js

Setting the database engine globally

ImmutableCoreModel.defaultEngine('TokuDB')

When setting the defaultEngine the ImmutableCoreModel class object is returned so that global configuration methods can be chained.

Getting the global default database engine

var defaultEngine = ImmutableCoreModel.defaultEngine()

Setting the database engine on a model

new ImmutableCoreModel({engine: 'TokuDB'})

Setting the charset for models

The charset can be set either globally or on an individual model.

If the charset is specified on a model this will override any global value.

The charset will not be changed or checked after the initial sync.

Setting the charset from the ENV

DEFAULT_CHARSET=latin1 node app.js

Setting the charset globally

ImmutableCoreModel.defaultCharset('latin1')

When setting the defaultCharset the ImmutableCoreModel class object is returned so that global configuration methods can be chained.

Getting the global default charset

var defaultCharset = ImmutableCoreModel.defaultCharset()

Setting the charset on a model

new ImmutableCoreModel({charset: 'latin1'})

Creating a model with a JSON schema

var fooModel = new ImmutableCoreModel({
    additionalProperties: false,
    errorMessage: {
        required: {
            foo: 'please enter foo'
        },
    },
    name: 'foo',
    properties: {
        foo: {
            type: 'string',
            default: 'foo',
        },
    },
    required: 'foo'
})

The properties object is a list of JSON Schema properties for the record data.

The schema specified here will be built into a schema for the complete object including meta data columns (id, createTime, etc).

Properties with defaults will be added to the data.

Data type coercion will be performed so that numbers are converted to strings, single element arrays are converted to scalar values, scalars are converted to single element arrays, etc.

When additionalProperties:false is set any properties not in the schema will be removed without throwing an error.

Immutable Core Model uses ajv with the following options to perform JSON schema validation:

allErrors: true
coerceTypes: 'array'
removeAdditional: true
useDefaults: true
v5: true

ajv-errors is used to provide custom error messages.

Disabling schema validation at the model level

var fooModel = new ImmutableCoreModel({
    name: 'foo',
    validate: false,
})

JSON schema validation is enabled by default. To disable JSON schema validation set the validate:false flag.

JSON schema validation can also be enabled and disabled for individual creat and update calls.

Even if validation is disabled the schema and metaSchema properties of the model will still be created.

Disabling schema validation on create

fooModel.createMeta({
    data: {foo: 'bar'},
    validate: false,
})

Disabling schema validation on update

foo.updateMeta({
    data: {foo: 'bar'},
    validate: false,
})

Creating a model with queryable columns

var fooModel = new ImmutableCoreModel({
    columns: {
        bam: 'boolean',
        bar: 'number',
        foo: 'string',
    },
    name: 'foo',
})

While all of the object data for foo is stored in fooData it may be necessary to query tables using column values.

Immutable Core Model allows for specifying additional columns that will be created on the table schema.

Data for these columns will always match the values in fooData.

Supported data types

Immutable Core Model supports the folowing data typesfor columns: boolean, data, id, int, number, smallint, string and time.

boolean values must be either true or false.

data values must be JSON encodable and be able to be stored compressed in a MEDIUMBLOB (~16MB).

id values must be specified as 32 char HEX string which will be stored as a 16 byte binary value.

strings have a maximum length of 255 characters. If strings exceed this length then only the first 255 characters of the string will be stored in the database column but the entire string will remain visible to the application.

numbers allow for a maximum of 27 digits before the decimal point and a maximum of 9 digits after the decimal point.

time must be string that can be interpreted by MySQL as DATETIME.

MySQL data type equivalents

Immutable Type MySQL Type
boolean TINYINT(1)
data MEDIUMBLOB
date DATE
id BINARY(16)
int BIGINT(20)
number DECIMAL(36,9)
smallint SMALLINT(5)
string VARCHAR(255)
time DATETIME(6)

Creating a queryable column with non-default options

var fooModel = new ImmutableCoreModel({
    columns: {
        foo: {
            default: 0,
            index: false,
            null: false,
            path: 'foo.bar',
            type: 'number',
        },
    },
    name: 'foo',
})

When a column is created with a string value that string is used as the column type and all other configuration options are set to defaults.

When a columen is created with an object value then non-default configuration options can be set.

Create a column with a unique index

var accountModel = new ImmutableCoreModel({
    columns: {
        email: {
            type: 'string',
            unique: true,
        },
    },
    name: 'account',
})

Because all the revisions of a record are stored in the same table a unique index on a column would not allow revisions of a record to be inserted if the indexed value did not change.

In order for this to work the value for the unique column is only inserted for the first revision of the record and this column is left NULL for future revisions unless the value changes.

To disable this behavior use the firstOnly:false option.

The firstOnly:true option is incompatible with the null:false option.

Column options

Option Name Default Description
default null default value for column
firstOnly true only apply unique index to original record
index true create index for column
immutable false value cannot be changed after being set
null true allow null values
path undefined path to get value from object (uses lodash _.get)
primary false column is primary key
type undefined data type (boolean
unique false create index as unique
unsigned false create integer columns as unsigned

Creating multi-column indexes

var fooModel = new ImmutableCoreModel({
    columns: {
        bam: 'boolean',
        bar: 'number',
    },
    indexes: [
        {
            columns: ['bam', 'bar'],
            unique: true
        },
    ],
    name: 'foo',
})

Multi-column indexes must be specified separately from column specifications.

The indexes option can only be used for multi-column indexes and attempts to create single column indexes will result in an error.

The unique flag controls whether or not the index is unique.

Creating a model with unique id based on data only

var fooModel = new ImmutableCoreModel({
    columns: {
        accountId: false,
        originalId: false,
        parentId: false,
    }
    idDataOnly: true,
    name: 'foo',
})

When the idDataOnly flag is set then only the data values for an object will be used to calculate the id for the object.

When creating models where the id is based only on the data it will usually not make sense to allow revisions or ownership of records.

Creating models with relations

var fooModel = new ImmutableCoreModel({
    name: 'foo',
    relations: {
        bar: {},
    },
})

var barModel = new ImmutableCoreModel({
    columns: {
        fooId: {
            index: true,
            type: 'id',
        },
    },
    name: 'bar',
})

Relations allow linked models to be created and queried from record objects.

In this example foo is related to bar.

The bar model must have either a fooId or fooOriginalId column.

For cases where the relation should apply to all revisions of a record the originalId should be used. For cases where the relation only applies to a specific revision the id should be used.

Relations are resolved at runtime which allows models to be defined in any order.

Creating models with relations linked via an intermediary table

var fooModel = new ImmutableCoreModel({
    name: 'foo',
    relations: {
        bar: {via: 'bam'},
    },
})

var bamModel = new ImmutableCoreModel({
    columns: {
        barId: {
            index: true,
            null: false,
            type: 'id',
        },
        fooId: {
            index: true,
            null: false,
            type: 'id',
        },
        data: false,
        originalId: false,
        parentId: false,
    },
    name: 'bam',
})

var barModel = new ImmutableCoreModel({
    name: 'bar',
})

The via option can be used to link two models via a third model.

The linking model should have either an id or originalId column from each of the two models being linked.

Creating a new related model

var foo = fooModel.create(...)

foo.create('bar', {foo: 'foo'})

To create a new related record the create method is call on an existing record with the name of the related model and the data for the related record.

If the related record is linked via a third model the linking record will be also be created.

Creating a new related model with meta options

foo.createMeta({
    data: {foo: 'foo'},
    relation: 'bar',
    session: session,
})

The createMeta method can be used to set meta options for the create method.

Selecting related records

foo.select('bar')

The select method takes the name of a relation and queries all records related to the record object.

The select method always returns a results object which must be used to fetch or iterate over the related records.

Querying related records

foo.query({
    order: ['createTime'],
    relation: 'bar',
})

The query method allows setting any of the options that are available with a normal model query.

Loading related records with query

foo.query({
    where: {id: fooId},
    with: {
        bar: {
            order: ['createTime'],
        },
    },
})

When doing a query for a single record by id related records can be loaded by specifying the with option.

All records will be loaded so caution must be taken to make sure that this does not become a performance and memory usage issue.

Creating a model with transform functions

var fooModel = new ImmutableCoreModel({
    name: 'foo',
    transform: {
        bam: (value, model, args) => {
            return 'bar'
        }
    }
})

fooModel.createMeta({
    data: {bam: 'foo'}
})

Transform functions are called when creating or updating a record to modify values set in the record data.

Transform functions will only be called if the value is defined in the data passed to the create or update call.

Transform functions are called before schema validation.

The property name for the transform function will be resolved using lodash get and can reference nested data.

Access control for models

Immutable Core Model integrates with Immutable Access Control to control access to records.

Access control rules should typically be configured independently from model specifications but access control rules can be specified directly on the model.

This is primarily useful for setting default rules to deny access to a model so that access must be specifically granted in order to use the model.

Setting the Immutable Access Control provider

var foo = new ImmutableCoreModel({
    accessControl: new ImmutableAccessControl(),
    name: 'foo',
})

The access control provider for a model can be set via the accessControl parameter when the model is created.

Immutable Access Control uses a global singleton instance so this will only be needed if a custom access control provider is used.

Deny access for all actions

var foo = new ImmutableCoreModel({
    accessControlRules: ['0'],
    name: 'foo',
})

Immutable Access Control is permissive by default. To deny access to all model actions set the accessControlRules to an array with a single string as 0.

This is equivalent to calling Immutable Access Control with:

accessControl.setRule(['all', 'model:foo:0'])

Allowing access to specific actions

var foo = new ImmutableCoreModel({
    accessControlRules: [
        '0',
        'create:1',
        'list:any:1',
        'read:any:1',
        'update:own:1',
    ],
    name: 'foo',
})

All access control rules must be in the same form allowed by Immutable Access Control except that they will have model:<modelName>: prepended to them.

These rules are equivalent to calling Immutable Access Control with:

accessControl.setRules([
    ['all', 'model:foo:0'],
    ['all', 'model:foo:create:1'],
    ['all', 'model:foo:list:any:1'],
    ['all', 'model:foo:read:any:1'],
    ['all', 'model:foo:update:own:1'],
])

Allowing access for specific roles

var foo = new ImmutableCoreModel({
    accessControlRules: [
        '0',
        ['authenticated', 'list:any:1']
        ['authenticated', 'read:any:1']
    ],
    name: 'foo',
})

These rules will allowed authenticated (logged in) sessions to list and read records.

To specify roles(s) the access control rule must be passed as an array instead of a string and one or more role must be specified prior to the rule.

These rules are equivalent to calling Immutable Access Control with:

accessControl.setRules([
    ['all', 'model:foo:0'],
    ['authenticated', 'model:foo:list:any:1']
    ['authenticated', 'model:foo:read:any:1']
])

Denying access

Access is checked before performing any action. If access is denied an error will be thrown using Immutable App Http Error which will generate a 403 Access Denied error when used with the Immutable App framework.

Setting a custom property for defining ownership

var fooModel = new ImmutableCoreModel({
    accessIdName: 'barId',
    columns: {
        barId: {
            index: true,
            null: false,
            type: 'id',
        },
    },
    name: 'foo',
})

By default accountId is used to determine ownership of records.

The accessIdName parameter can be used to specify a different property to use for determining ownership of records.

This column must have the id type and should usually be indexed.

When a custom accessId property is used that property must be set on the session in order for access to be granted based on ownership.

Allowing access for queries

var fooModel = new ImmutableCoreModel({
    name: foo,
})

var res = fooModel.query({
    allow: true,
})

Access controls can not and do not provide security in the local context. Local code can override access controls by passing the allow:true argument.

Working with models

These examples use async/await to show how records can be created and queried. It is assumed that this code will execute inside an async function.

Creating a local model instance with session context

// foo model import
const globalFooModel = require('foo')

// function where queries are performed
async function () {
    var fooModel = globalFooModel.session(session)
}

Calls to create, query, and select require a session object which will change from one request to the next.

To make model calls less verbose and eliminate the risk of forgetting to pass a session object with a call it is recommended to set the session context for an object before using it locally.

If create, query, or select are called on a local instance of a model that has a session context set they do not need to have a session object passed as an argument but if one is passed it will override the existing session context.

Creating a simple model with default options

const mysql = ImmutableCoreModel.createMysqlConnection({ ... })

var fooModel = new ImmutableCoreModel({
    mysql: mysql,
    name: 'foo',
})

await fooModel.sync()

Creating a new record with a global model

var foo = await globalFooModel.createMeta({
    data: {foo: 'foo'},
    session: session,
})

To create a record with the global fooModel instance you must use the createMeta method and include the session for the object being created.

The createMeta method can also be used for manually setting default columns like accountId, createTime, and parentId.

Creating a new record with a local model

var fooModel = globalFooModel.session(session)

fooModel.create({foo: 'foo'})

The local fooModel instance has a create method that takes only the record data as an argument.

This is the prefered way to create new records.

The local fooModel instance also has a createMeta method which can be used for any advanced create operations that require it.

Creating a new record while ignoring duplicate key errors

var foo = await globalFooModel.createMeta({
    data: {foo: 'foo'},
    duplicate: true,
    session: session,
})

To ignore duplicate key errors when creating an object use the duplicate:true flag.

If duplicate key errors are ignored the response data is not guaranteed to be correct.

Creating a new record while ignoring the response

var foo = await globalFooModel.createMeta({
    data: {foo: 'foo'},
    duplicate: true,
    response: false,
    session: session,
})

Set the response:false flag to not return a response.

This is typically used together with duplicate:true because the data returned may not be correct if duplicate key errors are ignored.

Creating a new record and responding with id only

var foo = await globalFooModel.createMeta({
    data: {foo: 'foo'},
    duplicate: true,
    responseIdOnly: true,
    session: session,
})

With the responseIdOnly:true option only the record id will be returned.

Creating a new record without waiting for insert to complete

var foo = await globalFooModel.createMeta({
    data: {foo: 'foo'},
    session: session,
    wait: false,
})

foo.promise.then( ... )

When createMeta is called with wait:false the response will be returned immediately without waiting for the insert query to complete.

When wait:false is used the insert promise will be added to the record object that is returned.

Errors will be caught by default when wait:false is used.

To prevent errors from being caught the catch:false option must be used.

Persisting data with a local foo model

var fooModel = globalFooModel.session(session)

var fooId = await fooModel.persist({foo: 'foo'})

The persist method is available on local fooModel instances and is equivalent to calling createMeta with duplicate:true and responseIdOnly:true set.

Calling persist will return a promise that resolves with the id of the persisted record as a string.

response:false overrides responseIdOnly so if response:false is set nothing will be returned.

Record methods and properties

foo = await fooModel.select.by.id(fooId)

/* access properties */

foo.data  // foo data object
foo.id    // foo id

/* call methods */

foo.update(...)
foo.empty()

Record objects follow a paradigm of accessing data via properties and performing actions by calling methods.

Record objects also include toJSON and inspect methods to customize the output provided for JSON.stringify and console.log.

Common record methods

Method Name Description
inspect custom formater for console.log
toJSON custom formater for JSON.stringify

Common record properties

Property Name Description
model model record was create for
raw raw database record with data column decoded
session session that instantiated record

Default record properties

Property Name Description
accountId id of account that record belongs to
createTime record creation timestamp
data record data as plain object
id hash id of record
originalId hash id of original record revision
parentId hash id of parent record revision
sessionId id of session that created record

Updating a record

foo = await foo.update({foo: 'bar'})

When a record is updated the data object passed as an argument will be merged over the existing data using the lodash _.merge method.

The update method returns a new record object. Multiple attempts to update the same record will fail so the new record returned by update must always be captured if further updates will be performed.

By default the updated record inherits the accountId of the previous revision of the record and the sessionId from the session that created or queried the record being updated.

The updated record will always have the same originalId as the parent and the parentId for the updated record will always be the id of the record that was updated.

Changing the accountId on a record

foo = await foo.updateMeta({
    accountId: '2222'
})

By default a record will have the same accountId as its parent. The accountId must be passed as an argument to change it.

Changing the sessionId on a record

foo = await foo.updateMeta({
    session: session
})

If a session is passed as an argument to the update method then that sessionId will be assigned to the new record revision. Otherwise the sessionId from the session that created or queried the record will be used.

Forcing an update on an old record

await foo.update({foo: 'bam'})

await foo.updateMeta({
    data: {foo: 'bar'},
    force: true,
})

By default calling update twice on the same record will result in a unique key constraint violation and the update will throw an exception.

When updateMeta is called with force:true the update will be retried up to 3 times.

Each time the update operation is retried the current revision of the record will be fetched and the data passed to the update statement will be re-merged against the current record data.

Using force:true can very easily lead to data corruption and so it should be used rarely if at all.

Emptying record data

foo = await foo.empty()

foo = await foo.updateMeta({data: null})

The empty method creates a new record revision with an empty data object.

The empty method is an alias for calling updateMeta with data:null and accepts the same arguments as updateMeta.

Working with revisions

Immutable Core Model stores every revision to a record as another row in the same table which exposes the revision history of a record to the client.

When doing a query on anything other than id only current record revisions will be returned.

When querying a record by id the revision matching the queried id will be returned.

Overwriting existing data

foo = await foo.updateMeta({
    data: {foo: 'bar'},
    merge: false,
})

With the merge:false flag set all existing data will be overwritten with the value of the data property.

Checking if a record is current

foo = await foo.select.isCurrent.by.id(objectId)

foo = await foo.query({
    isCurrent: true,
    where: { id: objectId }
})

if (foo.isCurrent) {
    ...
}

When doing a query by id the isCurrent flag can be set to check if the record(s) returned are the latest revisions.

isCurrent queries cannot be cached so this functionality should only be used when necessary.

Getting the current revision of a record

foo = await foo.select.by.id(objectId)

foo = await foo.current()

The current method queries the most recent revision of a record.

Querying data

Immutable Core Model provides two methods for performing queries: query and select.

The query method provides a raw low-level interface to all of the query functionality that Immutable Core Model provides while the select method provides shorthand helper methods that make simple queries easier to read and write.

This section demonstrates both query and select by providing side-by-side examples of the same query being performed with each.

Queries using the select method can only be performed on a local model instance with a session context set.

Select interface

foo = await fooModel.select.all.order.by.foo

foo = await fooModel.select.all.order.by.foo.query()

foo = await fooMdoel.select.all.order.by.foo.then(
    res => { ... },
    err => { ... }
)

The select interface works using JavaScript Proxies

Every property access modifies the internal state of the select query and when the query is executed the state is reset to perform the next query.

Every object retured by a property access returns a proxy object with query and then methods defined on it.

Calling either query or then will execute the query returning a promise.

When await is used the then method is called implicitly.

Query with plain object result

foo = await fooModel.select.all.plain

foo = await fooModel.query({
    all: true,
    plain: true,
})

When the plain option is set plain objects will be returned instead of ImmutableCoreModelRecord instances. The object returned is the same as the result of calling toJSON on a record instance.

Query a record by id

query

foo = await fooModel.query({
    limit: 1,
    where: { id: objectId },
})

select without session context set

foo = await fooModel.session(session).select.by.id(objectId)

In this example session is called first to return a local instance of fooModel with the session context set and then select is called on that instance.

The select method is not defined on the global model instance so attempts to call it will result in an exception.

All further examples of the select method assume that a local model instance with the session context set is being used.

select by id with session context set

foo = await fooModel.select.by.id(objectId)

When doing a select.by query where the argument is a string the limit:1 option will be set by default and the returned value will be either an object or undefined.

When doing a select.by query where the argument is an array the all:true option will be set by default and the returned value will be an array.

Querying multiple records by id

To do an exact match on a column with multiple values use an array instead of a string as the value to match against.

This is equivalent to SQL SELECT WHERE IN (...).

When selecting a list of records by id the records in the result will be in the same order as the ids in the query but there is no guaratee that all of the queried records will be returned.

query

results = await fooModel.query({
    where: { id: [objectId1, objectId2, objectId3] }
})

select

results = await fooModel.select.by.id([objectId1, objectId2, objectId3])

Rules for select.by queries

select.by.* queries can be performed on any column with select.by.id being the most common use case.

select.by with single value

foo = await fooModel.select.by.id('1111')

When a select.by query has a single value as an argument and the all option is not used then either a record object or undefined will be retured.

select.all.by with a single value

foos = await fooModel.select.all.by.id('1111')

When the all option is used the query will always return an array which will be empty if no records were found.

select.by with an array value

foos = await fooModel.select.by.id(['1111', '2222'])

When a select.by query is performed with an array value the all option is set by default so an array will always be returned.

When doing a query for an array of ids the results will be in the same order as the ids were queried. Ordering is not performed for queries other than id.

select.one.by with an array value

foo = await fooModel.select.one.by.id(['1111', '2222'])

When the one option is used with an array of values only a single record object or undefined will be returned.

There are no guarantees as to which record will be returned when multiple records match the query.

Result object properties

Property Name Description
model model used to perform queries
ids array of record ids in result set
session session used to perform queries
length number of records in result set
fetchNum number of records to fetch at a time
fetched number of records fetched
buffer array buffer of fetched records
done boolean flag indicating if all records fetched

Iterating over records with each

Records are accessed with the each method which iterates over the records, fetching and buffering them when needed, and calls the provided callback function for each.

context = await result.each(function callback (record, number, context) {

})

The callback function passed to each will be called for each record in order.

The arguments passed to the callback function are the record, the number of the record in the set starting at 0, and a context object which is passed to each callback.

If the callback function returns a promise this promise will be waited for before continuing to the next record.

Calling each with a context object

The context object that is passed to the callback function can be specified when calling each:

var context = {foo: 'foo'}

await results.each(callbackFunction, context)

Now context will be passed to callbackFunction with each iteration.

Querying all matching records

When the all:true option is used all records will be returned immediately.

In cases where the result set is small this is more efficient than using a response iterator but it is also dangerous because significant performance impacts and out-of-memory errors may occur if the result set is too large.

It is recommended to use response iterators in most cases and only use query all when the record size is known and an appropriate limit is set.

Query all must not be set to true if limit is set to 1.

query

foo = await fooModel.query({
    all: true,
    limit: 100,
    where: { foo: { like: '%bar%' } },
})

select

foo = await fooModel.select.all.where.foo.like('%bar%').limit(100)

Querying with order clauses

query

foo = await fooModel.query({
    order: ['foo', 'desc']
})

select

foo = await fooModel.select.order.by.foo.desc
foo = await fooModel.select.order.foo.desc

The by keyword in order selects is optional and the query will be the same with or without it.

Querying with multiple order clauses

query

foo = await fooModel.query({
    order: [
        ['bam', 'bar', 'asc'],
        ['foo', 'desc'],
    ]
})

select

foo = await fooModel.select.order.by.bam.bar.asc.foo.desc

The asc/desc keywords split up column groups for order selects.

Querying deleted records

query

foo = await fooModel.query({
    where: { isDeleted: true }
})

This query will return all record that have been deleted.

select

foo = await fooModel.select.where.isDeleted(true)

Querying records that have not been deleted

query

foo = await fooModel.query({
    where: { isDeleted: false }
})

This query returns all records that have not been deleted. the isDeleted:false flag is set by default for all queries.

select

foo = await fooModel.select.where.isDeleted(false)

Querying both deleted and not-deleted records

query

foo = await fooModel.query({
    where: { isDeleted: null }
})

The isDeleted:false flag is set for all queries by default. The isDeleted:null option is used to query both deleted and not-deleted records.

select

foo = await fooModel.select.where.isDeleted(false)

Querying the current revision of a record

query

foo = await fooModel.query({
    current: true,
    limit: 1,
    where: { id: fooId }
})

select

foo = fooModel.select.current.by.id(fooId)

Querying where column is null

query

foo = await fooModel.query({
    where: { foo: null }
})

select

foo = await fooModel.select.where.foo.is.null
foo = await fooModel.select.where.foo.null

Querying where column is not null

query

foo = await fooModel.query({
    where: { foo: { not: null } }
})

select

foo = await fooModel.select.where.foo.is.not.null
foo = await fooModel.select.where.foo.not.null

Querying where column greater than value

query

foo = await fooModel.query({
    where: { bar: { gt: 5 } }
})

select

foo = await fooModel.select.where.bar.is.gt(5)
foo = await fooModel.select.where.bar.gt(5)

Querying where column greater than or equal to value

query

foo = await fooModel.query({
    where: { bar: { gte: 5 } }
})

select

foo = await fooModel.select.where.bar.is.gte(5)
foo = await fooModel.select.where.bar.gte(5)

Querying where column is less than value

query

foo = await fooModel.query({
    where: { bar: { lt: 5 } }
})

select

foo = await fooModel.select.where.bar.is.lt(5)
foo = await fooModel.select.where.bar.lt(5)

Querying where column is less than or equal to value

query

foo = await fooModel.query({
    where: { bar: { lte: 5 } }
})

select

foo = await fooModel.select.where.bar.is.lte(5)
foo = await fooModel.select.where.bar.lte(5)

Querying where column equals a value

query

foo = await fooModel.query({
    where: { bar: { eq: 5 } }
})

select

foo = await fooModel.select.where.bar.is.eq(5)
foo = await fooModel.select.where.bar.eq(5)

Querying where column does not equal a value

query

foo = await fooModel.query({
    where: { bar: { not: { eq: 5 } } }
})

select

foo = await fooModel.select.where.bar.is.not.eq(5)
foo = await fooModel.select.where.bar.not.eq(5)

Querying records with results required

If the required:true option is used an error will be thrown if no results are found.

query

foo = await fooModel.query({required: true})

select

foo = await fooModel.select.required.by.id('foo')
foo = await fooModel.select.required.where.id.eq('foo')

When using the required keyword in a select statement it must come before the by or where keywords.

Model Views

Immutable Core Model uses Immutable Core Model View to provide reusable and compositable methods for formatting and summarizing record data.

Immutable Core Model Views can be included in the model definition, in which case they will be available for use with every query, or ad-hoc model views can be used with specific query instances.

Creating a model with a model view

var FooModelView = require('foo-model-view')

var fooModel = new ImmutableCoreModel({
    name: 'foo',
    views: {
        default: ['foo'],
        foo: FooModelView(),
    },
})

In this example the FooModelView is created as a named view foo for the model. Additionally foo is added as a default view.

With foo set as a default model view it will be applied to every foo model query.

Any number of named views can be added to a model and each naned view can be either a single model view object, the name of a model view object, an array of model view objects or an array of model view names.

Model view names are looked up in the local model views first and if not found there they will be looked up in the global model view register.

Creating a model with a globally registered model view

require('foo-model-view')

var fooModel = new ImmutableCoreModel({
    name: 'foo',
    views: {
        default: ['foo'],
    },
})

This example will yield the same result as the first one. The name foo will be used to find FooModelView in the global model view register and a new instance of FooModelView will be created and used as the default view.

Creating a model with multiple model views

require('bam-model-view')
require('bar-model-view')
require('foo-model-view')

var fooModel = new ImmutableCoreModel({
    name: 'foo',
    views: {
        default: ['viewA'],
        viewA: ['bam', 'bar'],
        viewB: ['bar', 'foo'],
    },
})

In this example two named views are defined and both of them apply two model views.

The default view references viewA and so the bam and bar model views will be applied to all fooModel queries by default.

Querying views

var foo = fooModel.query({
    view: 'foo'
})

var foo = fooModel.query({
    view: ['foo', 'bar']
})

One or more views can be specified with the view param on a query.

Selecting views

fooModel.select.view('foo')

fooModel.select.view('foo', 'bar')

fooModel.select.view(['foo', 'bar'])

The arguments to view can be either a single array or any number of strings.

Selecting by id with views

fooModel.select.one.where.id.eq(fooId).view(false)

It is not possible to specify a view when doing a select.by.id so the default view will always be applied to select.by.id.

If a non-default view is needed for select.by.id the long form select.one.where approach must be used.

Resolving related records

var bar = barModel.create({bar: true})

var foo = fooModel.create({
    bar: bar.id,
})

foo = fooModel.select.resolve.by.id(foo.id)

// foo.data: {bar: {bar: true}}

When the resolve:true option is set record data will be search for model names and model id names and the related records will be resolved.

The name of a model (e.g. bar) or the plural name (e.g. bars) will be resolved.

The id columns of model (e.g. barId, barOriginalId) and their plural version (e.g. barIds, barOriginalIds) will be resolved and the value will be stored under the model name or plural model name (e.g. bar, bars).

If a specific revision is referenced (e.g. barId) that revision will be resolved. If originalId is used the the current revision of the record will be resolved.

If the value is a string it will be replaced with the loaded record object or undefined. If the value is an object the keys will be used as object ids and the resolved records will be set as values. If the value is an array if will be replaced with an array of the resolved objects. Ids that are not found will not be returned.

Arrays of objects with an id column as a property will be resolved like arrays of ids except that the name of the id column (fooId, fooOriginalId) will be use to determine whether to query the specific id references or the current record.

For string and array values they must be 32 char hex strings or they will not be resolved and the original values will be left in place.

Deep properties are not resolved automatically.

Resolving related records explicitly

foo = fooModel.query({
    resolve: {
        'bar.bam': {
            isOriginalId: true,
            modelName: 'bam',
            setProperty: 'bamRecord',
        },
        baz: true,
    },
    where: {id: foo.id},
})

When explicit values are set for resolve only the specified properties will be resolved. While this is more verbose it is also more efficient and less error prone.

The property name (e.g. bam.bar) will be accessed using lodash _.get so deep properties can be resolved this way.

To resolve the property using default settings use the true value. Otherwise and object with explicit options can be used to override defaults.

The isOriginalId flag is used to indicate that the current revision of the referenced record should be resolved.

The setProperty option is used to specify a path that will be used with lodash _.set to store the resolved record.

Resolving related records with custom query args

foo = fooModel.query({
    resolve: {
        foo: {
            queryArgs: {
                ...
            },
        },
    },
    where: {id: foo.id},
})

The queryArgs parameter will be merged over the query args for the query that is used to get the record being resolved.

Any argument that can be passed to a query can be set in queryArgs.

Using Opensearch

Immutable Core Model allows using Opensearch in addition to MySQL.

When the opensearch parameter is set for a model then the current revision of each record will be stored in Opensearch as well as MySQL.

Immutable Core Model updates the document in Opensearch whenever updates to a record are made.

Opensearch storage is asynchronous and unreliable in the sense that if an insert or update to Opensearch fails this is not treated as a fatal error.

The Opensearch index for a model will be created when model sync is called.

For an intro to Opensearch terminology used here see About Opensearch

Adding Opensearch support to a model

const opensearch = require('@opensearch-project/opensearch')

const opensearchClient = new opensearch.Client({
    ...
})

// create model with opensearch client set as parameter
var fooModel = new ImmutableCoreModel({
    opensearch: opensearchClient,
    name: 'foo',
})

Passing an opensearch client at model creation will enable opensearch

// create a model with opensearch enabled to set client later
var barModel = new ImmutableCoreModel({
    opensearch: true,
    name: 'foo',
})

// set elastic search client
barModel.opensearch(opensearchClient)

If opensearch is set to true and a sync is attempted without a client set this will result in an exception.

All opensearch errors, including a missing client, are ignored for record create.

Setting the Opensearch client globally

var barModel = new ImmutableCoreModel({
    opensearch: true,
    name: 'foo',
})

ImmutableCoreModel.opensearch(opensearchClient)

When the the opensearch client is set globally it will be used for all models where the opensearch property is set to true. It will not be used for models where an opensearch client instance has been set.

If a model is created after the client is set globally then the global opensearch client will be set on the model when it is created.

If the global opensearch client is set after the model is created then it will be set on the model the first time the model needs to use the opensearch client.

Adding an optional Opensearch client to a model

var barModel = new ImmutableCoreModel({
    name: 'foo',
})

// set elastic search client
barModel.opensearch(opensearchClient)

An opensearch client can be added to any model even if the model does not require opensearch.

Setting the Opensearch index name

var fooModel = new ImmutableCoreModel({
    opensearch: client,
    osIndex: 'not-foo',
    name: 'foo',
})

By default the model path (name converted to foo-bar-bam style) will be used as the Opensearch index name. The osIndex property can be used to set a custom name.

Opensearch does not allow capital letters in index names.

Setting the Opensearch document type

var fooModel = new ImmutableCoreModel({
    opensearch: client,
    name: 'foo',
})

// 'baz' will become the opensearch document type
fooModel.createMeta({
    data: {
        bar: { bam: 'baz' }
    },
    session: session,
})

Performing a search

var barModel = new ImmutableCoreModel({
    opensearch: true,
    name: 'foo',
})

varModel.search({
    query: { ... },
    raw: true,
    session: session,
})

The search method args are passed directly to the opensearch search method so any params accepted by that method can be passed to the model search method.

The index for the search will be set to the index for the model by default.

When the raw property is set the raw results of the opensearch api query are returned. Only raw mode is currently supported.

Handling deleted records

If a model is deleted it will be deleted from Opensearch and if it is un-deleted it will be inserted back into Opensearch.

ImmutableCoreModel properties

Immutable Core Models have numerous properties that are used internally for building queries, determining access, and other purposes.

These properties should be treated as read only and may be read only.

To the greatest extent possible these properties will never be changed.

This list does not include all model properties. Undocumented properties are subject to change and should not be relied upon.

name type description
columns object column specs by name
defaultColumns object model column name to default column name
defaultColumnsInverse object default column name to model column name
extraColumns object non-default column specs by name
columnNames array all column names