immutable-core-model
v6.0.0
Published
Immutable model built on immutable-core
Downloads
90
Readme
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|number|string) | 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 |