## @e22m4u/js-repository


[Русский](./README.md) | English
An implementation of the Repository pattern for working with databases in
Node.js.
- [Installation](#installation)
- [Importing](#importing)
- [Description](#description)
- [Example](#example)
- [Schema](#schema)
- [Datasource](#datasource)
- [Model](#model)
- [Properties](#properties)
- [Validators](#validators)
- [Global Validators](#global-validators)
- [Registering Global Validators](#registering-global-validators)
- [Local Validators](#local-validators)
- [Transformers](#transformers)
- [Global Transformers](#global-transformers)
- [Registering Global Transformers](#registering-global-transformers)
- [Local Transformers](#local-transformers)
- [Empty Values](#empty-values)
- [Overriding Empty Values](#overriding-empty-values)
- [Repository](#repository)
- [create](#repositorycreate)
- [replaceById](#repositoryreplacebyid)
- [replaceOrCreate](#repositoryreplaceorcreate)
- [patchById](#repositorypatchbyid)
- [patch](#repositorypatch)
- [find](#repositoryfind)
- [findOne](#repositoryfindone)
- [findById](#repositoryfindbyid)
- [delete](#repositorydelete)
- [deleteById](#repositorydeletebyid)
- [exists](#repositoryexists)
- [count](#repositorycount)
- [Filtering](#filtering)
- [Relations](#relations)
- [Belongs To](#belongs-to)
- [Has One](#has-one)
- [Has Many](#has-many)
- [References Many](#references-many)
- [Belongs To (polymorphic)](#belongs-to-polymorphic-version)
- [Has One (polymorphic)](#has-one-polymorphic-version)
- [Has Many (polymorphic)](#has-many-polymorphic-version)
- [Extending](#extending)
- [TypeScript](#typescript)
- [Tests](#tests)
- [License](#license)
## Installation
```bash
npm install @e22m4u/js-repository
```
Optionally, install the required adapter.
| adapter | description |
|-----------|---------------------------------------------------------------------------------------------------------------------------------|
| `memory` | An in-process memory database (no installation required) |
| `mongodb` | MongoDB - a NoSQL database management system (*[install](https://www.npmjs.com/package/@e22m4u/js-repository-mongodb-adapter)*) |
## Importing
The module supports both ESM and CommonJS standards.
*ESM*
```js
import {DatabaseSchema} from '@e22m4u/js-repository';
```
*CommonJS*
```js
const {DatabaseSchema} = require('@e22m4u/js-repository');
```
## Description
The module allows you to abstract away from various database interfaces
by presenting them as named *datasources* connected to *models*.
A *Model* describes a database table (or collection), whose columns are the
model's properties. Model properties can have a specific *type* of allowed
value, a set of *validators*, and *transformers* that data passes through
before being written to the database. Additionally, a *model* can define
classic "one-to-one," "one-to-many," and other types of relationships
between models.
Data is read and written directly through a *repository*, which is available
for every model with a declared *datasource*. The repository can filter
queried documents, validate properties according to the model definition,
and embed related data into the query results.
- *Datasource* - defines how to connect to the database.
- *Model* - describes the document structure and relations to other models.
- *Repository* - performs read and write operations for model documents.
```mermaid
flowchart TD
A[Schema]
subgraph Databases
B[Datasource 1]
C[Datasource 2]
end
A-->B
A-->C
subgraph Collections
D[Model A]
E[Model B]
F[Model C]
G[Model D]
end
B-->D
B-->E
C-->F
C-->G
H[Repository A]
I[Repository B]
J[Repository C]
K[Repository D]
D-->H
E-->I
F-->J
G-->K
```
## Example
This example demonstrates creating a schema instance, declaring a datasource,
and a `country` model. Then, using the model's repository, a new document
(a country) is added to the collection and logged to the console.
```
Country (country)
┌─────────────────────────┐
│ id: 1 │
│ name: "Russia" │
│ population: 143400000 │
└─────────────────────────┘
```
```js
import {DataType} from '@e22m4u/js-repository';
import {DatabaseSchema} from '@e22m4u/js-repository';
// create a DatabaseSchema instance
const dbs = new DatabaseSchema();
// declare the "myDb" datasource
dbs.defineDatasource({
name: 'myDb', // name of the new datasource
adapter: 'memory', // chosen adapter
});
// declare the "country" model
dbs.defineModel({
name: 'country', // name of the new model
datasource: 'myDb', // chosen datasource
properties: { // model properties
name: DataType.STRING, // type "string"
population: DataType.NUMBER, // type "number"
},
})
// get the model's repository
const countryRep = dbs.getRepository('country');
// add a new document to the collection
const country = await countryRep.create({
name: 'Russia',
population: 143400000,
});
// log the new document
console.log(country);
// {
// id: 1,
// name: 'Russia',
// population: 143400000,
// }
```
The next block defines a `city` model with a `belongsTo` relation to the
`country` model from the example above. Then, a new city document is created,
linked to the previously created country. After creating the new document, a
query is executed to retrieve this city with its related country included.
```
Country (country) City (city)
┌─────────────────────────┐ ┌─────────────────────────┐
│ id: 1 <───────────────│───┐ │ id: 1 │
│ name: "Russia" │ │ │ name: "Moscow" │
│ population: 143400000 │ └───│─ countryId: 1 │
└─────────────────────────┘ └─────────────────────────┘
```
```js
// declare the "city" model with a relation to "country"
dbs.defineModel({
name: 'city',
datasource: 'myDb',
properties: {
name: DataType.STRING,
countryId: DataType.NUMBER,
// defining the "countryId" foreign key is not strictly necessary,
// but it is recommended for type validation before writing to the
// database, as the "memory" adapter creates numeric IDs by default
},
relations: {
// defining the "country" relation allows for automatic inclusion
// of related documents using the "include" option in repository methods
country: {
type: RelationType.BELONGS_TO, // relation type: belongs to...
model: 'country', // target model name
foreignKey: 'countryId', // foreign key field (optional)
// if the foreign key follows the `relationName` + `Id` convention,
// the `foreignKey` option is not required.
},
},
});
// get the repository for the "city" model
const cityRep = dbs.getRepository('city');
// create a new city and link it to the country via country.id
const city = await cityRep.create({
name: 'Moscow',
countryId: country.id, // use the id from the previously created country
});
console.log(city);
// {
// id: 1,
// name: 'Moscow',
// countryId: 1,
// }
// retrieve the city by its ID, including the related country
const cityWithCountry = await cityRep.findById(city.id, {
include: 'country',
});
console.log(cityWithCountry);
// {
// id: 1,
// name: 'Moscow',
// countryId: 1,
// country: {
// id: 1,
// name: 'Russia',
// population: 143400000
// }
// }
```
## Schema
An instance of the `DatabaseSchema` class stores datasource and model
definitions.
**Methods**
- `defineDatasource(datasourceDef: object): this` - add a datasource
- `defineModel(modelDef: object): this` - add a model
- `getRepository(modelName: string): Repository` - get a repository
**Examples**
Import the class and create a schema instance.
```js
import {DatabaseSchema} from '@e22m4u/js-repository';
const dbs = new DatabaseSchema();
```
Define a new datasource.
```js
dbs.defineDatasource({
name: 'myDb', // name of the new datasource
adapter: 'memory', // chosen adapter
});
```
Define a new model.
```js
dbs.defineModel({
name: 'product', // name of the new model
datasource: 'myDb',// chosen datasource
properties: { // model properties
name: DataType.STRING,
weight: DataType.NUMBER,
},
});
```
Get a repository by the model name.
```js
const productRep = dbs.getRepository('product');
```
## Datasource
A datasource stores the name of the selected adapter and its settings.
A new datasource is defined using the `defineDatasource` method of a
`DatabaseSchema` instance.
**Parameters**
- `name: string` a unique name
- `adapter: string` the chosen adapter
- adapter-specific parameters (if any)
**Examples**
Define a new datasource.
```js
dbs.defineDatasource({
name: 'myDb', // name of the new datasource
adapter: 'memory', // chosen adapter
});
```
Passing additional parameters, using the MongoDB adapter as an example
(*[install](https://www.npmjs.com/package/@e22m4u/js-repository-mongodb-adapter)*).
```js
dbs.defineDatasource({
name: 'myDb',
adapter: 'mongodb',
// parameters for the "mongodb" adapter
host: '127.0.0.1',
port: 27017,
database: 'myDatabase',
});
```
## Model
Describes the structure of a collection's documents and its relations to
other models. A new model is defined using the `defineModel` method of a
`DatabaseSchema` instance.
**Parameters**
- `name: string` model name (required)
- `base: string` name of the base model to inherit from
- `tableName: string` name of the collection in the database
- `datasource: string` the chosen datasource
- `properties: object` property definitions (see [Properties](#properties))
- `relations: object` relation definitions (see [Relations](#relations))
**Examples**
Define a model with properties of specified types.
```js
dbs.defineModel({
name: 'user', // name of the new model
properties: { // model properties
name: DataType.STRING,
age: DataType.NUMBER,
},
});
```
## Properties
The `properties` parameter in a model definition accepts an object where keys
are property names and values are either the property type or an object with
additional parameters.
**Data Types**
- `DataType.ANY` any value is allowed
- `DataType.STRING` only `string` values
- `DataType.NUMBER` only `number` values
- `DataType.BOOLEAN` only `boolean` values
- `DataType.ARRAY` only `array` values
- `DataType.OBJECT` only `object` values
**Parameters**
- `type: string` the allowed value type (required)
- `itemType: string` array item type (for `type: 'array'`)
- `model: string` object model (for `type: 'object'`)
- `primaryKey: boolean` declares the property as a primary key
- `columnName: string` overrides the column name
- `columnType: string` column type (defined by the adapter)
- `required: boolean` declares the property as required
- `default: any` default value
- `validate: string | Function | array | object` see [Validators](#validators)
- `unique: boolean | string` check value for uniqueness
**The `unique` Parameter**
If the `unique` parameter is `true` or `'strict'`, a strict uniqueness
check is performed. In this mode, [empty values](#empty-values) are also
checked, where `null` and `undefined` are considered values that must be
unique.
The `'sparse'` mode only checks values with a payload, excluding
[empty values](#empty-values), the list of which varies depending on the
property type. For example, for a `string` type, empty values are
`undefined`, `null`, and `''` (an empty string).
- `unique: true | 'strict'` strict uniqueness check
- `unique: 'sparse'` exclude [empty values](#empty-values) from the check
- `unique: false | 'nonUnique'` do not check for uniqueness (default)
You can use predefined constants as equivalents for the string values
`strict`, `sparse`, and `nonUnique`.
- `PropertyUniqueness.STRICT`
- `PropertyUniqueness.SPARSE`
- `PropertyUniqueness.NON_UNIQUE`
**Examples**
Short-form property definition.
```js
dbs.defineModel({
name: 'city',
properties: { // model properties
name: DataType.STRING, // property type "string"
population: DataType.NUMBER, // property type "number"
},
});
```
Full-form property definition.
```js
dbs.defineModel({
name: 'city',
properties: { // model properties
name: {
type: DataType.STRING, // property type "string" (required)
required: true, // disallows undefined and null values
},
population: {
type: DataType.NUMBER, // property type "number" (required)
default: 0, // default value
},
code: {
type: DataType.NUMBER, // property type "number" (required)
unique: PropertyUniqueness.STRICT, // check for uniqueness
},
},
});
```
Factory default value. The function's return value will be determined at the
time the document is written.
```js
dbs.defineModel({
name: 'article',
properties: { // model properties
tags: {
type: DataType.ARRAY, // property type "array" (required)
itemType: DataType.STRING,// item type "string"
default: () => [], // factory value
},
createdAt: {
type: DataType.STRING, // property type "string" (required)
default: () => new Date().toISOString(), // factory value
},
},
});
```
## Validators
Validators are used to check a property's value before writing it to the
database. Validation occurs immediately after the type check specified in the
model's property definition. [Empty values](#empty-values) bypass validators
as they have no payload.
### Global Validators
The module comes with a set of global validators:
- `regexp` validates against a regular expression,
*parameter: `string | RegExp` - the regular expression;*
- `maxLength` maximum length for a string or array,
*parameter: `number` - the maximum length;*
- `minLength` minimum length for a string or array,
*parameter: `number` - the minimum length;*
The validators below are under development:
- `isLowerCase` checks for lowercase letters only;
- `isUpperCase` checks for uppercase letters only;
- `isEmail` checks for a valid email format;
**Examples**
Using a global validator.
```js
dbs.defineModel({
name: 'user',
properties: {
email: {
type: DataType.STRING,
validate: 'isEmail',
},
},
});
```
Using global validators as an array.
```js
dbs.defineModel({
name: 'user',
properties: {
email: {
type: DataType.STRING,
validate: [
'isEmail',
'isLowerCase',
],
},
},
});
```
Using global validators with arguments.
```js
dbs.defineModel({
name: 'user',
properties: {
name: {
type: DataType.STRING,
validate: {
minLength: 2,
maxLength: 24,
regexp: /^[a-zA-Z-']+$/,
},
},
},
});
```
Global validators without parameters can accept any arguments.
```js
dbs.defineModel({
name: 'user',
properties: {
email: {
type: DataType.STRING,
validate: {
maxLength: 100,
// since the "isEmail" validator has no parameters,
// its definition allows any value to be passed
// as an argument
isEmail: true,
},
},
},
});
```
### Registering Global Validators
A validator is a function that receives the value of the corresponding field
before it is written to the database. If the function returns `false` during
validation, a standard error is thrown. You can replace the standard error
by throwing a custom error directly inside the validation function.
A global validator is registered using the `addValidator` method of the
`PropertyValidatorRegistry` service, which takes the validator name and the
validation function.
**Examples**
Registering a global validator to check UUID format.
```js
import {createError} from 'http-errors';
import {format} from '@e22m4u/js-format';
import {Errorf} from '@e22m4u/js-format';
import {PropertyValidatorRegistry} from '@e22m4u/js-repository';
// get the service instance
const pvr = dbs.getService(PropertyValidatorRegistry);
// regular expressions for different UUID versions
const uuidRegex = {
any: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
v4: /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
};
// register the global validator "isUuid",
// which accepts a settings object with a "version" property.
pvr.addValidator('isUuid', (value, options, context) => {
// value - the value being validated;
// options - validator parameters;
// context - information about the property being validated;
console.log(options);
// {
// version: 'v4'
// }
console.log(context);
// {
// validatorName: 'isUuid',
// modelName: 'device',
// propName: 'deviceId'
// }
// empty values are not passed to validators
// (the condition below will never trigger)
if (typeof value !== 'string') return false;
// find the regex for the specified UUID version
// (from the validator's options)
const version = options?.version || 'any';
const regex = uuidRegex[version];
// if the regex is not found,
// an internal error is thrown
if (!regex)
throw new Errorf(
'Invalid UUID version %v specified for validator.',
version,
);
// if validation fails, a 400 BadRequest
// error is thrown
if (!regex.test(value)) {
const versionString = version !== 'any' ? ` (version ${version})` : '';
throw createError(400, format(
'The property %v of the model %v must be a valid UUID%s.',
context.propName,
context.modelName,
versionString,
));
}
// on successful validation, return true;
// otherwise, a standard validation error
// will be thrown
return true;
});
```
Using the global validator in a property definition.
```js
// define the "device" model
dbs.defineModel({
name: 'device',
properties: {
deviceId: {
type: DataType.STRING,
required: true,
validate: {
// the value {version: 'v4'} will be passed as the second
// argument to the validator function
isUuid: {version: 'v4'},
},
},
},
});
```
### Local Validators
A validator function can be passed directly in the property definition without
prior registration. To do this, simply pass the function to the `validate`
parameter as a value or as an array element alongside other validators.
**Examples**
Using a local validator to check password strength.
```js
// the `passwordStrength` validator checks password complexity
function passwordStrength(value, options, context) {
// value - the value being validated;
// options - not used;
// context - information about the property;
console.log(context);
// {
// validatorName: 'passwordStrength',
// modelName: 'user',
// propName: 'password'
// }
const errors = [];
if (value.length < 8)
errors.push('must be at least 8 characters long');
if (!/\d/.test(value))
errors.push('must contain at least one number');
if (!/[a-zA-Z]/.test(value))
errors.push('must contain at least one letter');
// if any condition is met,
// an error is thrown
if (errors.length > 0)
throw createError(400, format(
'Value of the property %v of the model %v %s.',
context.propName,
context.modelName,
errors.join(', '),
));
// on successful validation, return true;
// otherwise, a standard validation error
// will be thrown
return true;
}
// define the "user" model
dbs.defineModel({
name: 'user',
properties: {
password: {
type: DataType.STRING,
required: true,
validate: passwordStrength, // <=
// or
// validate: [passwordStrength, ...]
},
},
});
```
Using an anonymous validator function to check a slug.
```js
// define the "article" model
dbs.defineModel({
name: 'article',
properties: {
slug: {
type: DataType.STRING,
validate: (value) => {
const re = /^[a-z0-9]+(-[a-z0-9]+)*$/;
return re.test(value);
},
},
},
});
```
## Transformers
Transformers are used to modify a property's value before type checking and
sending the data to the database. Transformers allow for automatic cleaning
or formatting of data. [Empty values](#empty-values) are not passed to
transformers as they have no payload.
### Global Transformers
The module comes with a set of global transformers:
- `trim` removes whitespace from the beginning and end of a string;
- `toUpperCase` converts a string to uppercase;
- `toLowerCase` converts a string to lowercase;
The transformers below are under development:
- `cut` truncates a string or array to the specified length,
*parameter: `number` - the maximum length;*
- `truncate` truncates a string and appends an ellipsis,
*parameter: `number` - the maximum length;*
- `capitalize` converts the first letter of each word to uppercase,
*parameter: `{firstWordOnly?: boolean}`;*
**Examples**
Using a global transformer.
```js
dbs.defineModel({
name: 'user',
properties: {
username: {
type: DataType.STRING,
transform: 'toLowerCase',
},
},
});
```
Using global transformers as an array.
```js
dbs.defineModel({
name: 'user',
properties: {
firstName: {
type: DataType.STRING,
transform: [
'trim',
'capitalize',
],
},
},
});
```
Using global transformers with arguments.
```js
dbs.defineModel({
name: 'article',
properties: {
annotation: {
type: DataType.STRING,
transform: {
truncate: 200,
capitalize: {firstWordOnly: true},
},
},
},
});
```
Global transformers without parameters can accept any arguments.
```js
dbs.defineModel({
name: 'user',
properties: {
firstName: {
type: DataType.STRING,
transform: {
cut: 60,
// since the "trim" transformer has no parameters,
// its definition allows any value to be passed
// as an argument
trim: true,
},
},
},
});
```
### Registering Global Transformers
A transformer is a function that takes a property's value and returns a new
value. The function can be either synchronous or asynchronous (return a
`Promise`).
A global transformer is registered using the `addTransformer` method of the
`PropertyTransformerRegistry` service, which takes the transformer name and
the function itself.
**Examples**
Registering a global transformer to remove HTML tags.
```js
import {PropertyTransformerRegistry} from '@e22m4u/js-repository';
// get the service instance
const ptr = dbs.getService(PropertyTransformerRegistry);
// register the global transformer "stripTags"
ptr.addTransformer('stripTags', (value, options, context) => {
// value - the value being transformed;
// options - transformer settings (if provided);
// context - information about the property;
console.log(context);
// {
// transformerName: 'stripTags',
// modelName: 'comment',
// propName: 'text'
// }
if (typeof value !== 'string')
return value; // return as is if not a string
return value.replace(/<[^>]*>?/gm, '');
});
```
Using the global transformer in a model definition.
```js
dbs.defineModel({
name: 'comment',
properties: {
text: {
type: DataType.STRING,
transform: 'stripTags',
},
},
});
```
### Local Transformers
A transformer function can be passed directly in the property definition
without prior registration. To do this, simply pass the function to the
`transform` parameter as a value or as an array element.
**Examples**
Using a local transformer to normalize names.
```js
// function to normalize a name
function normalizeName(value, options, context) {
// value - the value being transformed
// options - not used
// context - information about the property
if (!value || typeof value !== 'string') return value;
return value
.trim() // remove leading/trailing whitespace
.toLowerCase() // convert to lowercase
.split(' ') // split into words
// capitalize the first letter of each word
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' '); // join the array back into a string
}
// define the "user" model
dbs.defineModel({
name: 'user',
properties: {
firstName: {
type: DataType.STRING,
transform: normalizeName, // <=
},
lastName: {
type: DataType.STRING,
transform: normalizeName, // <=
},
},
});
```
Using a local asynchronous transformer to hash a password.
```js
import * as bcrypt from 'bcrypt';
// asynchronous function to hash a value
async function hash(value, options, context) {
// value - the value being transformed
// options - not used
// context - information about the property
console.log(context);
// {
// transformerName: 'hash',
// modelName: 'user',
// propName: 'password'
// }
const saltRounds = 10;
return bcrypt.hash(value, saltRounds);
}
// define the "user" model
dbs.defineModel({
name: 'user',
properties: {
password: {
type: DataType.STRING,
transform: hash, // <=
// or
// transform: [hash, ...]
},
},
});
```
Using an anonymous transformer function to correct a slug.
```js
dbs.defineModel({
name: 'article',
properties: {
slug: {
type: DataType.STRING,
transform: (value) => {
if (typeof value !== 'string') return value;
return value.toLowerCase().replace(/\s+/g, '-');
},
},
},
});
```
## Empty Values
Different property types have their own sets of empty values. These sets are
used to determine if a property's value has a payload. For example, the
`default` parameter in a property definition sets a default value only if the
incoming value is empty. The `required` parameter disallows empty values by
throwing an error. And the `unique` parameter in `sparse` mode allows
duplicate empty values for a unique property, as they are not included in the
uniqueness check.
| type | empty values |
|-------------|---------------------------|
| `'any'` | `undefined`, `null` |
| `'string'` | `undefined`, `null`, `''` |
| `'number'` | `undefined`, `null`, `0` |
| `'boolean'` | `undefined`, `null` |
| `'array'` | `undefined`, `null`, `[]` |
| `'object'` | `undefined`, `null`, `{}` |
### Overriding Empty Values
The set of empty values for any data type can be overridden. These sets are
managed through a special service provided by the
[@e22m4u/js-empty-values](https://www.npmjs.com/package/@e22m4u/js-empty-values)
module (no installation required).
**EmptyValuesService**
To override empty values, you need to get an instance of the
`EmptyValuesService` class from the schema's container and call the method
that accepts the data type and an array of new values.
Interface:
```ts
class EmptyValuesService {
/**
* Set empty values
* for a specific data type.
*
* @param dataType The data type.
* @param emptyValues An array of new empty values.
*/
setEmptyValuesOf(
dataType: DataType,
emptyValues: unknown[],
): this;
}
```
**Example**
By default, the value `0` is considered empty for numeric properties. The
following example shows how to change this behavior, leaving only `undefined`
and `null` as empty values.
```js
import {DataType} from '@e22m4u/js-repository';
import {DatabaseSchema} from '@e22m4u/js-repository';
import {EmptyValuesService} from '@e22m4u/js-empty-values';
const dbs = new DatabaseSchema();
// get the service for working with empty values
const emptyValuesService = dbs.getService(EmptyValuesService);
// override empty values for the DataType.NUMBER type
emptyValuesService.setEmptyValuesOf(DataType.NUMBER, [undefined, null]);
```
After this, the value `0` for properties of type `DataType.NUMBER` will no
longer be considered empty, will pass validator checks, and will not be
replaced by a default value.
## Repository
The repository performs read and write operations for a specific model's data.
It acts as an intermediary between the application's business logic and the
database.
**Methods**
- [`create(data, filter = undefined)`](#repositorycreate) creates a new document;
- [`replaceById(id, data, filter = undefined)`](#repositoryreplacebyid) completely replaces a document;
- [`replaceOrCreate(data, filter = undefined)`](#repositoryreplaceorcreate) replaces or creates a new document;
- [`patchById(id, data, filter = undefined)`](#repositorypatchbyid) partially updates a document;
- [`patch(data, where = undefined)`](#repositorypatch) updates all or matching documents;
- [`find(filter = undefined)`](#repositoryfind) finds all or matching documents;
- [`findOne(filter = undefined)`](#repositoryfindone) finds the first matching document;
- [`findById(id, filter = undefined)`](#repositoryfindbyid) finds a document by its ID;
- [`delete(where = undefined)`](#repositorydelete) deletes all or matching documents;
- [`deleteById(id)`](#repositorydeletebyid) deletes a document by its ID;
- [`exists(id)`](#repositoryexists) checks for existence by ID;
- [`count(where = undefined)`](#repositorycount) counts all or matching documents;
**Arguments**
- `id: number|string` identifier (primary key)
- `data: object` document data (used for writing)
- `where: object` filter conditions (see [Filtering](#filtering))
- `filter: object` query parameters (see [Filtering](#filtering))
**Getting a Repository**
You can get a repository using the `getRepository()` method of a
`DatabaseSchema` instance. The method takes the model name as an argument.
The model must have a defined [datasource](#datasource), as the repository
interacts directly with the database through the adapter specified in the
datasource.
```js
// declare a datasource
dbs.defineDatasource({
name: 'myDatasource',
adapter: 'memory', // adapter
});
// declare a model
dbs.defineModel({
name: 'myModel',
datasource: 'myDatasource',
// properties: { ... },
// relations: { ... }
});
// get the model's repository
const modelRep = dbs.getRepository('myModel');
```
The first time `getRepository('myModel')` is called, a new repository instance
is created and cached. All subsequent calls with the same model name will
return the existing instance.
### repository.create
Creates a new document in the collection based on the provided data. Returns
the created document with an assigned identifier.
Signature:
```ts
create(
data: WithOptionalId,
filter?: ItemFilterClause,
): Promise;
```
**Examples**
Create a new document.
```js
const newProduct = await productRep.create({
name: 'Laptop',
price: 1200,
});
console.log(newProduct);
// {
// id: 1,
// name: 'Laptop',
// price: 1200,
// }
```
Create a document, returning only specific fields.
```js
const product = await productRep.create(
{name: 'Mouse', price: 25},
{fields: ['id', 'name']},
);
console.log(product);
// {
// id: 2,
// name: 'Mouse',
// }
```
Create a document, including related data in the result.
```js
// assumes the Product model has a "category" relation
// (the "include" option only affects the returned result)
const product = await productRep.create(
{name: 'Keyboard', price: 75, categoryId: 10},
{include: 'category'},
);
console.log(product);
// {
// id: 3,
// name: 'Keyboard',
// price: 75,
// categoryId: 10,
// category: {id: 10, name: 'Electronics'}
// }
```
### repository.replaceById
Completely replaces an existing document by its identifier. All previous data
in the document, except the identifier, is removed. Fields not provided in
`data` will be absent from the final document (unless they have a default
value).
Signature:
```ts
replaceById(
id: IdType,
data: WithoutId,
filter?: ItemFilterClause,
): Promise;
```
**Examples**
Replace a document by its identifier.
```js
// original document
// {
// id: 1,
// name: 'Laptop',
// price: 1200,
// inStock: true
// }
const updatedProduct = await productRep.replaceById(1, {
name: 'Laptop Pro',
price: 1500,
});
console.log(updatedProduct);
// {
// id: 1,
// name: 'Laptop Pro',
// price: 1500
// }
// the "inStock" property is removed
```
### repository.replaceOrCreate
Replaces an existing document if the provided data includes an identifier that
already exists in the collection. Otherwise, if the identifier is not
provided or not found, it creates a new document.
Signature:
```ts
replaceOrCreate(
data: WithOptionalId,
filter?: ItemFilterClause,
): Promise;
```
**Examples**
Create a new document if `id: 3` does not exist.
```js
const product = await productRep.replaceOrCreate({
id: 3,
name: 'Keyboard',
price: 75,
});
console.log(product);
// {
// id: 3,
// name: 'Keyboard',
// price: 75,
// }
```
Replace an existing document with `id: 1`.
```js
const updatedProduct = await productRep.replaceOrCreate({
id: 1,
name: 'Laptop Pro',
price: 1500,
});
console.log(updatedProduct);
// {
// id: 1,
// name: 'Laptop Pro',
// price: 1500,
// }
```
### repository.patchById
Partially updates an existing document by its identifier, modifying only the
provided fields. The other fields of the document remain unchanged.
Signature:
```ts
patchById(
id: IdType,
data: PartialWithoutId,
filter?: ItemFilterClause,
): Promise;
```
**Examples**
Partially update a document by its identifier.
```js
// original document with id: 1
// {
// id: 1,
// name: 'Laptop Pro',
// price: 1500
// }
const updatedProduct = await productRep.patchById(1, {
price: 1450,
});
console.log(updatedProduct);
// {
// id: 1,
// name: 'Laptop Pro',
// price: 1450
// }
```
### repository.patch
Partially updates one or more documents that match the `where` conditions.
Only the provided fields are changed; the rest remain unchanged. Returns the
number of updated documents. If `where` is not specified, it updates all
documents in the collection.
Signature:
```ts
patch(
data: PartialWithoutId,
where?: WhereClause,
): Promise;
```
**Examples**
Update documents based on a condition.
```js
// updates all products with a price less than 30
const updatedCount = await productRep.patch(
{inStock: false},
{price: {lt: 30}},
);
```
Update all documents.
```js
// adds or updates the updatedAt field for all documents
const totalCount = await productRep.patch({
updatedAt: new Date(),
});
```
### repository.find
Finds all documents that match the filter conditions and returns them as an
array. If no filter is specified, it returns all documents in the
collection.
Signature:
```ts
find(filter?: FilterClause): Promise;
```
**Examples**
Find all documents.
```js
const allProducts = await productRep.find();
```
Find documents by a `where` condition.
```js
const cheapProducts = await productRep.find({
where: {price: {lt: 100}},
});
```
Find with sorting and limiting the result set.
```js
const latestProducts = await productRep.find({
order: 'createdAt DESC',
limit: 10,
});
```
### repository.findOne
Finds the first document that matches the filter conditions. Returns
`undefined` if no documents are found.
Signature:
```ts
findOne(
filter?: FilterClause,
): Promise;
```
**Examples**
Find a single document by a condition.
```js
const expensiveProduct = await productRep.findOne({
where: {price: {gt: 1000}},
order: 'price DESC',
});
```
Handling the case where a document is not found.
```js
const product = await productRep.findOne({
where: {name: 'Non-existent Product'},
});
if (!product) {
console.log('Product not found.');
}
```
### repository.findById
Finds a single document by its unique identifier. Throws an error if the
document is not found.
Signature:
```ts
findById(
id: IdType,
filter?: ItemFilterClause,
): Promise;
```
**Examples**
Find a document by `id`.
```js
try {
const product = await productRep.findById(1);
console.log(product);
} catch (error) {
console.error('Product with id 1 is not found.');
}
```
Find a document including related data.
```js
const product = await productRep.findById(1, {
include: 'category',
});
```
### repository.delete
Deletes one or more documents that match the `where` conditions. Returns the
number of deleted documents. If `where` is not specified, it deletes all
documents in the collection.
Signature:
```ts
delete(where?: WhereClause): Promise;
```
**Examples**
Delete documents based on a condition.
```js
const deletedCount = await productRep.delete({
inStock: false,
});
```
Delete all documents.
```js
const totalCount = await productRep.delete();
```
### repository.deleteById
Deletes a single document by its unique identifier. Returns `true` if the
document was found and deleted, otherwise `false`.
Signature:
```ts
deleteById(id: IdType): Promise;
```
**Examples**
Delete a document by `id`.
```js
const wasDeleted = await productRep.deleteById(1);
if (wasDeleted) {
console.log('The document was deleted.');
} else {
console.log('No document found to delete.');
}
```
### repository.exists
Checks for the existence of a document with the specified identifier.
Returns `true` if the document exists, otherwise `false`.
Signature:
```ts
exists(id: IdType): Promise;
```
**Examples**
Check if a document exists by `id`.
```js
const productExists = await productRep.exists(1);
if (productExists) {
console.log('A document with id 1 exists.');
}
```
### repository.count
Counts the number of documents that match the `where` conditions. If `where`
is not specified, it returns the total number of documents in the collection.
Signature:
```ts
count(where?: WhereClause): Promise;
```
**Examples**
Count documents based on a condition.
```js
const cheapCount = await productRep.count({
price: {lt: 100},
});
```
Count all documents.
```js
const totalCount = await productRep.count();
```
## Filtering
Some repository methods accept a settings object that affects the returned
result. The `find` method's first parameter has the widest set of these
settings, expecting an object with the options listed below.
- `where: object` conditions to filter documents by their properties;
- `order: string|string[]` sort by specified properties;
- `limit: number` limit the number of documents;
- `skip: number` skip documents (for pagination);
- `fields: string|string[]` select necessary model properties;
- `include: object` include related data in the result;
**Example**
```js
// the "find" repository method is used for the query,
// with a filter object passed as the first argument
const news = await newsRepository.find({
where: {
title: {like: '%Moscow%'},
publishedAt: {gte: '2025-10-15T00:00:00.000Z'},
tags: {inq: ['world', 'politic']},
hidden: false,
},
order: 'publishedAt DESC',
limit: 12,
skip: 24,
fields: ['title', 'annotation', 'body'],
include: ['author', 'category'],
})
```
### where
This parameter accepts an object with query conditions and supports the
following set of comparison operators.
- [Search by value (shorthand)](#search-by-value-shorthand)
- [`eq`](#eq-strict-equality) (strict equality)
- [`neq`](#neq-inequality) (inequality)
- [`gt`](#gt-greater-than) (greater than)
- [`lt`](#lt-less-than) (less than)
- [`gte`](#gte-greater-than-or-equal-to) (greater than or equal to)
- [`lte`](#lte-less-than-or-equal-to) (less than or equal to)
- [`inq`](#inq-in-a-list) (in a list)
- [`nin`](#nin-not-in-a-list) (not in a list)
- [`between`](#between-range) (range)
- [`exists`](#exists-property-existence) (property existence)
- [`like`](#like-pattern-matching) (pattern matching)
- [`nlike`](#nlike-excluding-pattern) (excluding pattern)
- [`ilike`](#ilike-case-insensitive-pattern) (case-insensitive pattern)
- [`nilike`](#nilike-case-insensitive-excluding-pattern) (case-insensitive excluding pattern)
- [`regexp`](#regexp-regular-expression) (regular expression)
Conditions can be combined with logical operators:
- [`and`](#and-logical-and) (logical AND)
- [`or`](#or-logical-or) (logical OR)
#### Search by value (shorthand)
Finds documents where the value of the specified property is exactly equal
to the provided value. This is a shorthand for the `{eq: ...}` operator.
```js
// finds all documents where age is 21
const res = await rep.find({
where: {
age: 21,
},
});
```
#### `eq` (strict equality)
Finds documents where the property value is equal to the specified value.
```js
// finds all documents where age is 21
const res = await rep.find({
where: {
age: {eq: 21},
},
});
```
#### `neq` (inequality)
Finds documents where the property value is not equal to the specified value.
```js
// finds all documents where age is not 21
const res = await rep.find({
where: {
age: {neq: 21},
},
});
```
#### `gt` (greater than)
Finds documents where the property value is strictly greater than the
specified value.
```js
// finds documents where age is greater than 30
const res = await rep.find({
where: {
age: {gt: 30},
},
});
```
#### `lt` (less than)
Finds documents where the property value is strictly less than the
specified value.
```js
// finds documents where age is less than 30
const res = await rep.find({
where: {
age: {lt: 30},
},
});
```
#### `gte` (greater than or equal to)
Finds documents where the property value is greater than or equal to the
specified value.
```js
// finds documents where age is greater than or equal to 30
const res = await rep.find({
where: {
age: {gte: 30},
},
});
```
#### `lte` (less than or equal to)
Finds documents where the property value is less than or equal to the
specified value.
```js
// finds documents where age is less than or equal to 30
const res = await rep.find({
where: {
age: {lte: 30},
},
});
```
#### `inq` (in a list)
Finds documents where the property value matches one of the values in the
provided array.
```js
// finds documents where name is 'John' or 'Mary'
const res = await rep.find({
where: {
name: {inq: ['John', 'Mary']},
},
});
```
#### `nin` (not in a list)
Finds documents where the property value is not in the provided array.
```js
// finds all documents except those where name is 'John' or 'Mary'
const res = await rep.find({
where: {
name: {nin: ['John', 'Mary']},
},
});
```
#### `between` (range)
Finds documents where the property value is within the specified range
(inclusive).
```js
// finds documents where age is between 20 and 30, inclusive
const res = await rep.find({
where: {
age: {between: [20, 30]},
},
});
```
#### `exists` (property existence)
Checks for the presence or absence of a property in a document. Does not
check the property's value.
- `true` the property must exist (even if its value is `null`);
- `false` the property must be absent;
```js
// finds documents that have the 'nickname' property
const res1 = await rep.find({
where: {
nickname: {exists: true},
},
});
// finds documents that do not have the 'nickname' property
const res2 = await rep.find({
where: {
nickname: {exists: false},
},
});
```
#### `like` (pattern matching)
Performs a case-sensitive pattern match (see [more details](#pattern-matching-operators)).
```js
// finds {name: 'John Doe'}, but not {name: 'john doe'}
const res = await rep.find({
where: {
name: {like: 'John%'},
},
});
```
#### `nlike` (excluding pattern)
Finds documents that do not match the case-sensitive pattern
(see [more details](#pattern-matching-operators)).
```js
// finds everything except names starting with 'John'
const res = await rep.find({
where: {
name: {nlike: 'John%'},
},
});
```
#### `ilike` (case-insensitive pattern)
Performs a case-insensitive pattern match (see [more details](#pattern-matching-operators)).
```js
// finds both {name: 'John Doe'} and {name: 'john doe'}
const res = await rep.find({
where: {
name: {ilike: 'john%'},
},
});
```
#### `nilike` (case-insensitive excluding pattern)
Finds strings that do not match the pattern, case-insensitively
(see [more details](#pattern-matching-operators)).
```js
// finds everything except names starting with 'John' or 'john'
const res = await rep.find({
where: {
name: {nilike: 'john%'},
},
});
```
#### `regexp` (regular expression)
Finds documents where the string property's value matches the specified
regular expression. Can be passed as a string or a `RegExp` object.
```js
// finds documents where name starts with 'J'
const res1 = await rep.find({
where: {
name: {regexp: '^J'},
},
});
// finds documents where name starts with 'J' or 'j' (case-insensitive)
const res2 = await rep.find({
where: {
name: {regexp: '^j', flags: 'i'},
},
});
```
#### `and` (logical AND)
Combines multiple conditions into an array, requiring every condition to be
met.
```javascript
// finds documents where surname is 'Smith' AND age is 21
const res = await rep.find({
where: {
and: [
{surname: 'Smith'},
{age: 21}
],
},
});
```
#### `or` (logical OR)
Combines multiple conditions into an array, requiring at least one of them
to be met.
```javascript
// finds documents where name is 'James' OR age is greater than 30
const res = await rep.find({
where: {
or: [
{name: 'James'},
{age: {gt: 30}}
],
},
});
```
#### Pattern Matching Operators
The `like`, `nlike`, `ilike`, and `nilike` operators are designed for filtering
string properties based on pattern matching, similar to the `LIKE` operator
in SQL. They allow finding values that match a certain structure using
special characters.
**`%`** matches any sequence of zero or more characters:
- `'A%'` finds all strings starting with "A";
- `'%a'` finds all strings ending with "a";
- `'%word%'` finds all strings containing "word" anywhere;
**`_`** matches exactly one of any character:
- `'c_t'` finds "cat", "cot", but not "cart" or "ct";
- `'cat_'` finds "cats", "cat1", but not "cat" or "catch";
To find the literal characters `%` or `_`, they must be escaped with a
backslash `\`:
- `'100\%'` finds the string "100%";
- `'file\_name'` finds the string "file_name";
- `'path\\to'` finds the string "path\to";
### order
This parameter sorts the result set by the specified model properties. You can
specify descending order with the `DESC` postfix.
**Examples**
Sort by the `createdAt` field.
```js
const res = await rep.find({
order: 'createdAt',
});
```
Sort by the `createdAt` field in descending order.
```js
const res = await rep.find({
order: 'createdAt DESC',
});
```
Sort by multiple properties in different directions.
```js
const res = await rep.find({
order: [
'title',
'price ASC',
'featured DESC',
],
});
```
*i. The `ASC` sort direction is optional.*
### include
This parameter includes related documents in the method's result. The names
of the included relations must be defined in the current model
(see [Relations](#relations)).
**Examples**
Include a relation by its name.
```js
const res = await rep.find({
include: 'city',
});
```
Include nested relations.
```js
const res = await rep.find({
include: {
city: 'country',
},
});
```
Include multiple relations using an array.
```js
const res = await rep.find({
include: [
'city',
'address',
'employees'
],
});
```
Using filtering for included documents.
```js
const res = await rep.find({
include: {
relation: 'employees', // relation name
scope: { // filter for "employees" documents
where: {hidden: false}, // query conditions
order: 'id', // document order
limit: 10, // limit the number of documents
skip: 5, // skip documents
fields: ['name', 'surname'], // only these fields
include: 'city', // include relations for "employees"
},
},
});
```
## Relations
Relations describe relationships between models, allowing for automatic
embedding of related data using the `include` option in repository methods.
The example below shows automatic relation resolution when using the
`findById` method.
```
Role (role)
┌────────────────────┐
│ id: 3 <──────────│────┐
│ name: 'Manager' │ │
└────────────────────┘ │
│
User (user) │
┌────────────────────────┐ │
│ id: 1 │ │
│ name: 'John Doe' │ │
│ roleId: 3 ──────────│────┘
│ cityId: 24 ──────────│────┐
└────────────────────────┘ │
│
City (city) │
┌────────────────────┐ │
│ id: 24 <─────────│────┘
│ name: 'Moscow' │
└────────────────────┘
```
```js
// query a document from the "users" collection,
// including related data (role and city)
const user = await userRep.findById(1, {
include: ['role', 'city'],
});
console.log(user);
// {
// id: 1,
// name: 'John Doe',
// roleId: 3,
// role: {
// id: 3,
// name: 'Manager'
// },
// cityId: 24,
// city: {
// id: 24,
// name: 'Moscow'
// }
// }
```
### Defining a Relation
The `relations` property in a model definition accepts an object where keys
are relation names and values are their parameters. The relation name can
then be used in the `include` option of repository methods.
```js
import {
DataType,
RelationType,
DatabaseSchema,
} from '@e22m4u/js-repository';
dbs.defineModel({
name: 'user',
datasource: 'memory',
properties: {
name: DataType.STRING,
},
relations: {
// relation role -> parameters
role: {
type: RelationType.BELONGS_TO,
model: 'role',
},
// relation city -> parameters
city: {
type: RelationType.BELONGS_TO,
model: 'city',
},
},
});
```
**Core Parameters**
- `type: string` the relation type (required);
- `model: string` the target model name (required for some types);
- `foreignKey: string` the property in the current model for the target ID;
*i. For Belongs To and References Many types, the `foreignKey` parameter
can be omitted as it's automatically generated based on the relation name.*
**Polymorphic Mode**
- `polymorphic: boolean|string` declares a polymorphic relation;
- `discriminator: string` the property in the current model for the target's name;
*i. Polymorphic mode allows dynamically determining the target model by its
name, which is stored in the document's discriminator property.*
### Relation Types
- [Belongs To](#belongs-to)
The current model references the target model by its ID.
`type: "belongsTo"` or `type: RelationType.BELONGS_TO`
- [Has One](#has-one)
The inverse of `belongsTo` for a "one-to-one" relationship.
`type: "hasOne"` or `type: RelationType.HAS_ONE`
- [Has Many](#has-many)
The inverse of `belongsTo` for a "one-to-many" relationship.
`type: "hasMany"` or `type: RelationType.HAS_MANY`
- [References Many](#references-many)
The current model references the target model via an array of IDs.
`type: "referencesMany"` or `type: RelationType.REFERENCES_MANY`
Polymorphic versions:
- [Belongs To (polymorphic)](#belongs-to-polymorphic-version)
- [Has One (polymorphic)](#has-one-polymorphic-version)
- [Has Many (polymorphic)](#has-many-polymorphic-version)
The `type` parameter in a relation definition accepts a string with the type
name. To avoid typos, it's recommended to use the constants from the
`RelationType` object listed below.
- `RelationType.BELONGS_TO`
- `RelationType.HAS_ONE`
- `RelationType.HAS_MANY`
- `RelationType.REFERENCES_MANY`
#### Belongs To
The current model references the target model by its identifier.
```
Current (user) Target (role)
┌─────────────────────────┐ ┌─────────────────────────┐
│ id: 1 │ ┌───│─> id: 5 │
│ roleId: 5 ───────────│───┤ │ ... │
│ ... │ │ └─────────────────────────┘
└─────────────────────────┘ │
┌─────────────────────────┐ │
│ id: 2 │ │
│ roleId: 5 ───────────│───┘
│ ... │
└─────────────────────────┘
```
Relation definition:
```js
dbs.defineModel({
name: 'user',
relations: {
role: { // relation name
type: RelationType.BELONGS_TO, // current model references target
model: 'role', // target model name
foreignKey: 'roleId', // foreign key (optional)
// if "foreignKey" is not specified, the foreign key property
// is formed based on the relation name with an "Id" postfix
},
},
});
```
Example:
```js
import {
DataType,
RelationType,
DatabaseSchema,
} from '@e22m4u/js-repository';
const dbs = new DatabaseSchema();
// datasource
dbs.defineDatasource({
name: 'myDb',
adapter: 'memory',
});
// role model
dbs.defineModel({
name: 'role',
datasource: 'myDb',
properties: {
name: DataType.STRING,
},
});
// user model
dbs.defineModel({
name: 'user',
datasource: 'myDb',
properties: {
name: DataType.STRING,
roleId: DataType.NUMBER, // optional
},
relations: {
role: {
type: RelationType.BELONGS_TO,
model: 'role',
foreignKey: 'roleId', // optional
},
},
});
// create a role
const roleRep = dbs.getRepository('role');
const role = await roleRep.create({
id: 5,
name: 'Manager',
});
console.log(role);
// {
// id: 5,
// name: 'Manager'
// }
// create a user
const userRep = dbs.getRepository('user');
const user = await userRep.create({
id: 1,
name: 'John Doe',
roleId: role.id,
});
console.log(user);
// {
// id: 1,
// name: 'John Doe',
// roleId: 5
// }
// fetch user with related role ("include" option)
const userWithRole = await userRep.findById(user.id, {include: 'role'});
console.log(userWithRole);
// {
// id: 1,
// name: 'John Doe',
// roleId: 5,
// role: {
// id: 5,
// name: 'Manager'
// }
// }
```
#### Has One
The inverse of `belongsTo` for a "one-to-one" relationship.
```
Current (profile) Target (user)
┌─────────────────────────┐ ┌─────────────────────────┐
│ id: 5 <──────────────│───┐ │ id: 1 │
│ ... │ └───│── profileId: 5 │
└─────────────────────────┘ │ ... │
└─────────────────────────┘
```
Relation definition:
```js
// dbs.defineModel({
// name: 'user',
// relations: {
// profile: {
// type: RelationType.BELONGS_TO,
// model: 'profile',
// },
// },
// });
dbs.defineModel({
name: 'profile',
relations: {
user: { // relation name
type: RelationType.HAS_ONE, // target model references current
model: 'user', // target model name
foreignKey: 'profileId', // foreign key from target to current
},
},
});
```
#### Has Many
The inverse of `belongsTo` for a "one-to-many" relationship.
```
Current (role) Target (user)
┌─────────────────────────┐ ┌─────────────────────────┐
│ id: 5 <──────────────│───┐ │ id: 1 │
│ ... │ ├───│── roleId: 5 │
└─────────────────────────┘ │ │ ... │
│ └─────────────────────────┘
│ ┌─────────────────────────┐
│ │ id: 2 │
└───│── roleId: 5 │
│ ... │
└─────────────────────────┘
```
Relation definition:
```js
// dbs.defineModel({
// name: 'user',
// relations: {
// role: {
// type: RelationType.BELONGS_TO,
// model: 'role',
// },
// },
// });
dbs.defineModel({
name: 'role',
relations: {
users: { // relation name
type: RelationType.HAS_MANY, // target model references current
model: 'user', // target model name
foreignKey: 'roleId', // foreign key in the target model
},
},
});
```
#### References Many
The current model references the target model via an array of identifiers.
```
Current (article) Target (category)
┌─────────────────────────┐ ┌─────────────────────────┐
│ id: 1 │ ┌───│─> id: 5 │
│ categoryIds: [5, 6] ──│───┤ │ ... │
│ ... │ │ └─────────────────────────┘
└─────────────────────────┘ │ ┌─────────────────────────┐
└───│─> id: 6 │
│ ... │
└─────────────────────────┘
```
Relation definition:
```js
// dbs.defineModel({name: 'category', ...
dbs.defineModel({
name: 'article',
relations: {
categories: { // relation name
type: RelationType.REFERENCES_MANY, // relation via array of IDs
model: 'category', // target model name
foreignKey: 'categoryIds', // foreign key (optional)
// if "foreignKey" is not specified, the foreign key property
// is formed based on the relation name with an "Ids" postfix
},
},
});
```
#### Belongs To (polymorphic version)
The current model references a target model by its identifier. The target
model's name is determined by a discriminator property.
```
Current (file) ┌──────> Target 1 (letter)
┌─────────────────────────────┐ │ ┌─────────────────────────┐
│ id: 1 │ │ ┌──│─> id: 10 │
│ referenceType: 'letter' ─│──┘ │ │ ... │
│ referenceId: 10 ─────────│────┘ └─────────────────────────┘
└─────────────────────────────┘
┌──────> Target 2 (user)
┌─────────────────────────────┐ │ ┌─────────────────────────┐
│ id: 2 │ │ ┌──│─> id: 5 │
│ referenceType: 'user' ───│──┘ │ │ ... │
│ referenceId: 5 ──────────│────┘ └─────────────────────────┘
└─────────────────────────────┘
```
Relation definition:
```js
dbs.defineModel({
name: 'file',
relations: {
reference: { // relation name
type: RelationType.BELONGS_TO, // current model references target
// polymorphic mode allows storing the target model's name
// in a discriminator property, which is formed based on
// the relation name with a "Type" postfix, in this case,
// the target model's name is stored in "referenceType",
// and the document's ID is in "referenceId"
polymorphic: true,
},
},
});
```
Relation definition with explicit properties:
```js
dbs.defineModel({
name: 'file',
relations: {
reference: { // relation name
type: RelationType.BELONGS_TO, // current model references target
polymorphic: true, // target model name is in a discriminator
foreignKey: 'referenceId', // property for the target's ID
discriminator: 'referenceType', // property for the target's name
},
},
});
```
#### Has One (polymorphic version)
The inverse of a polymorphic `belongsTo` for a "one-to-one" relationship.
```
Current (company) <───────┐ Target (license)
┌─────────────────────────┐ │ ┌─────────────────────────┐
│ id: 10 <─────────────│──┐ │ │ id: 1 │
│ ... │ │ └──│── ownerType: 'company' │
└─────────────────────────┘ └────│── ownerId: 10 │
└─────────────────────────┘
```
Relation definition specifying the target's relation name:
```js
// dbs.defineModel({
// name: 'license',
// relations: {
// owner: {
// type: RelationType.BELONGS_TO,
// polymorphic: true,
// },
// },
// });
dbs.defineModel({
name: 'company',
relations: {
license: { // relation name
type: RelationType.HAS_ONE, // target model references current
model: 'license', // target model name
polymorphic: 'owner', // polymorphic relation name in target model
},
},
});
```
Relation definition specifying the target model's properties:
```js
// dbs.defineModel({
// name: 'license',
// relations: {
// owner: {
// type: RelationType.BELONGS_TO,
// polymorphic: true,
// foreignKey: 'ownerId',
// discriminator: 'ownerType',
// },
// },
// });
dbs.defineModel({
name: 'company',
relations: {
license: { // relation name
type: RelationType.HAS_ONE, // target model references current
model: 'license', // target model name
polymorphic: true, // current model name is in discriminator
foreignKey: 'ownerId', // property in target for current's ID
discriminator: 'ownerType', // property in target for current's name
},
},
});
```
#### Has Many (polymorphic version)
The inverse of a polymorphic `belongsTo` for a "one-to-many" relationship.
```
Current (letter) <─────────┐ Target (file)
┌──────────────────────────┐ │ ┌────────────────────────────┐
│ id: 10 <──────────────│──┐ │ │ id: 1 │
│ ... │ │ ├──│── referenceType: 'letter' │
└──────────────────────────┘ ├─│──│── referenceId: 10 │
│ │ └────────────────────────────┘
│ │ ┌────────────────────────────┐
│ │ │ id: 2 │
│ └──│── referenceType: 'letter' │
└────│── referenceId: 10 │
└────────────────────────────┘
```
Relation definition specifying the target's relation name:
```js
// dbs.defineModel({
// name: 'file',
// relations: {
// reference: {
// type: RelationType.BELONGS_TO,
// polymorphic: true,
// },
// },
// });
dbs.defineModel({
name: 'letter',
relations: {
attachments: { // relation name
type: RelationType.HAS_MANY, // target model references current
model: 'file', // target model name
polymorphic: 'reference', // polymorphic relation name in target
},
},
});
```
Relation definition specifying the target model's properties:
```js
// dbs.defineModel({
// name: 'file',
// relations: {
// reference: {
// type: RelationType.BELONGS_TO,
// polymorphic: true,
// foreignKey: 'referenceId',
// discriminator: 'referenceType',
// },
// },
// });
dbs.defineModel({
name: 'letter',
relations: {
attachments: { // relation name
type: RelationType.HAS_MANY, // target model references current
model: 'file', // target model name
polymorphic: true, // current model name is in discriminator
foreignKey: 'referenceId', // property in target for current's ID
discriminator: 'referenceType', // property in target for current's name
},
},
});
```
## Extending
The `getRepository` method of a `DatabaseSchema` instance checks for an
existing repository for the specified model and returns it. Otherwise, a new
instance is created and cached for subsequent calls.
```js
import {Repository} from '@e22m4u/js-repository';
import {DatabaseSchema} from '@e22m4u/js-repository';
// const dbs = new DatabaseSchema();
// dbs.defineDatasource ...
// dbs.defineModel ...
const rep1 = dbs.getRepository('model');
const rep2 = dbs.getRepository('model');
console.log(rep1 === rep2); // true
```
You can replace the default repository constructor using the `setRepositoryCtor`
method of the `RepositoryRegistry` service, which is available in the
`DatabaseSchema` instance's service container. After that, all new
repositories will be created using the specified constructor instead of the
default one.
```js
import {Repository} from '@e22m4u/js-repository';
import {DatabaseSchema} from '@e22m4u/js-repository';
import {RepositoryRegistry} from '@e22m4u/js-repository';
class MyRepository extends Repository {
/*...*/
}
// const dbs = new DatabaseSchema();
// dbs.defineDatasource ...
// dbs.defineModel ...
dbs.getService(RepositoryRegistry).setRepositoryCtor(MyRepository);
const rep = dbs.getRepository('model');
console.log(rep instanceof MyRepository); // true
```
*i. Since repository instances are cached, you should replace the
constructor before calling the `getRepository` method.*
## TypeScript
Getting a typed repository with a specified model interface.
```ts
import {DataType} from '@e22m4u/js-repository';
import {RelationType} from '@e22m4u/js-repository';
import {DatabaseSchema} from '@e22m4u/js-repository';
// const dbs = new DatabaseSchema();
// dbs.defineDatasource ...
// define the "city" model
dbs.defineModel({
name: 'city',
datasource: 'myDatasource',
properties: {
name: DataType.STRING,
timeZone: DataType.STRING,
},
});
// define the "city" interface
interface City {
id: number;
name?: string;
timeZone?: string;
}
// when getting a repository for a model,
// you can specify the document type
const cityRep = dbs.getRepository('city');
// now, repository methods return
// the City type instead of Record
const city: City = await cityRep.create({
name: 'Moscow',
timeZone: 'Europe/Moscow',
});
```
For defining models using TypeScript classes, it is recommended to use the
specialized version of this module,
[@e22m4u/ts-repository](https://www.npmjs.com/package/@e22m4u/ts-repository),
which comes with a set of TypeScript decorators and additional tools for
working in a TypeScript environment.
## Tests
```bash
npm run test
```
## License
MIT