Реализация репозитория для работы с базами данных

e22m4u deb1bc11e4 chore: updates README.md 4 weeks ago
.husky 77f2fb02f4 chore: adds CommonJS support 1 year ago
assets 609a7dc7fe style: updates logo 1 month ago
dist 987d4246d6 fix: the stringToRegexp should not replace "%" and "_" symbols as SQL-like wildcard 1 month ago
src 987d4246d6 fix: the stringToRegexp should not replace "%" and "_" symbols as SQL-like wildcard 1 month ago
.c8rc dbff18d833 chore: initial commit 2 years ago
.commitlintrc dbff18d833 chore: initial commit 2 years ago
.editorconfig dbff18d833 chore: initial commit 2 years ago
.gitignore 829eb6f924 chore: updates .gitignore 1 year ago
.mocharc.json 237d1d1488 chore: replaces tsx by ts-node to improve types checking 4 months ago
.prettierrc dbff18d833 chore: initial commit 2 years ago
LICENSE 51b0f047e4 chore: updates license 4 months ago
README-en.md deb1bc11e4 chore: updates README.md 4 weeks ago
README.md deb1bc11e4 chore: updates README.md 4 weeks ago
build-cjs.js 818dd30ef0 chore: updates esbuild config 1 year ago
eslint.config.js 0b0a223e38 chore: updates dependencies 2 months ago
package.json ba3a480b5c chore: updates dependencies 1 month ago
tsconfig.json 2c35784fb3 chore: removes experimental decorators and improve types 1 year ago

README-en.md

@e22m4u/js-repository

npm version license

Русский | English


logo


An implementation of the Repository pattern for working with databases in Node.js.

Installation

npm install @e22m4u/js-repository

Optionally, install the required adapter.

adapter description installation
memory An in-process memory database built-in
mongodb MongoDB is a document-oriented database npm

Importing

The module supports both ESM and CommonJS standards.

ESM

import {DatabaseSchema} from '@e22m4u/js-repository';

CommonJS

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.
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  │
└─────────────────────────┘
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           │
└─────────────────────────┘       └─────────────────────────┘
// 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.

import {DatabaseSchema} from '@e22m4u/js-repository';

const dbs = new DatabaseSchema();

Define a new datasource.

dbs.defineDatasource({
  name: 'myDb',      // name of the new datasource
  adapter: 'memory', // chosen adapter
});

Define a new model.

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.

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.

dbs.defineDatasource({
  name: 'myDb',      // name of the new datasource
  adapter: 'memory', // chosen adapter
});

Passing additional parameters, using the MongoDB adapter as an example (*install*).

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)
  • relations: object relation definitions (see Relations)

Examples

Define a model with properties of specified types.

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
  • 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 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, 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 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.

dbs.defineModel({
  name: 'city',
  properties: { // model properties
    name: DataType.STRING,       // property type "string"
    population: DataType.NUMBER, // property type "number"
  },
});

Full-form property definition.

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.

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

dbs.defineModel({
  name: 'user',
  properties: {
    email: {
      type: DataType.STRING,
      validate: 'isEmail',
    },
  },
});

Using global validators as an array.

dbs.defineModel({
  name: 'user',
  properties: {
    email: {
      type: DataType.STRING,
      validate: [
        'isEmail',
        'isLowerCase',
      ],
    },
  },
});

Using global validators with arguments.

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.

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.

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.

// 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.

// 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.

// 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 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.

dbs.defineModel({
  name: 'user',
  properties: {
    username: {
      type: DataType.STRING,
      transform: 'toLowerCase',
    },
  },
});

Using global transformers as an array.

dbs.defineModel({
  name: 'user',
  properties: {
    firstName: {
      type: DataType.STRING,
      transform: [
        'trim',
        'capitalize',
      ],
    },
  },
});

Using global transformers with arguments.

dbs.defineModel({
  name: 'article',
  properties: {
    annotation: {
      type: DataType.STRING,
      transform: {
        truncate: 200,
        capitalize: {firstWordOnly: true},
      },
    },
  },
});

Global transformers without parameters can accept any arguments.

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.

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.

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.

// 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.

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.

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 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:

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.

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

Arguments

  • id: number|string identifier (primary key)
  • data: object document data (used for writing)
  • where: object filter conditions (see Filtering)
  • filter: object query parameters (see 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, as the repository interacts directly with the database through the adapter specified in the datasource.

// 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:

create(
  data: WithOptionalId<FlatData, IdName>,
  filter?: ItemFilterClause<FlatData>,
): Promise<FlatData>;

Examples

Create a new document.

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.

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.

// 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:

replaceById(
  id: IdType,
  data: WithoutId<FlatData, IdName>,
  filter?: ItemFilterClause<FlatData>,
): Promise<FlatData>;

Examples

Replace a document by its identifier.

// 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:

replaceOrCreate(
  data: WithOptionalId<FlatData, IdName>,
  filter?: ItemFilterClause<FlatData>,
): Promise<FlatData>;

Examples

Create a new document if id: 3 does not exist.

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.

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:

patchById(
  id: IdType,
  data: PartialWithoutId<FlatData, IdName>,
  filter?: ItemFilterClause<FlatData>,
): Promise<FlatData>;

Examples

Partially update a document by its identifier.

// 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:

patch(
  data: PartialWithoutId<FlatData, IdName>,
  where?: WhereClause<FlatData>,
): Promise<number>;

Examples

Update documents based on a condition.

// updates all products with a price less than 30
const updatedCount = await productRep.patch(
  {inStock: false},
  {price: {lt: 30}},
);

Update all documents.

// 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:

find(filter?: FilterClause<FlatData>): Promise<FlatData[]>;

Examples

Find all documents.

const allProducts = await productRep.find();

Find documents by a where condition.

const cheapProducts = await productRep.find({
  where: {price: {lt: 100}},
});

Find with sorting and limiting the result set.

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:

findOne(
  filter?: FilterClause<FlatData>,
): Promise<FlatData | undefined>;

Examples

Find a single document by a condition.

const expensiveProduct = await productRep.findOne({
  where: {price: {gt: 1000}},
  order: 'price DESC',
});

Handling the case where a document is not found.

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:

findById(
  id: IdType,
  filter?: ItemFilterClause<FlatData>,
): Promise<FlatData>;

Examples

Find a document by id.

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.

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:

delete(where?: WhereClause<FlatData>): Promise<number>;

Examples

Delete documents based on a condition.

const deletedCount = await productRep.delete({
  inStock: false,
});

Delete all documents.

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:

deleteById(id: IdType): Promise<boolean>;

Examples

Delete a document by id.

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:

exists(id: IdType): Promise<boolean>;

Examples

Check if a document exists by id.

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:

count(where?: WhereClause<FlatData>): Promise<number>;

Examples

Count documents based on a condition.

const cheapCount = await productRep.count({
  price: {lt: 100},
});

Count all documents.

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

// 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.

Conditions can be combined with logical operators:

  • and (logical AND)
  • 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.

// 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.

// 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.

// 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.

// 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.

// 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.

// 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.

// 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.

// 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.

// 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).

// 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;
// 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).

// 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).

// 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).

// 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).

// 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.

// 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.

// 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.

// 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.

const res = await rep.find({
  order: 'createdAt',
});

Sort by the createdAt field in descending order.

const res = await rep.find({
  order: 'createdAt DESC',
});

Sort by multiple properties in different directions.

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).

Examples

Include a relation by its name.

const res = await rep.find({
  include: 'city',
});

Include nested relations.

const res = await rep.find({
  include: {
    city: 'country',
  },
});

Include multiple relations using an array.

const res = await rep.find({
  include: [
    'city',
    'address',
    'employees'
  ],
});

Using filtering for included documents.

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'    │
      └────────────────────┘
// 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.

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 The current model references the target model by its ID. type: "belongsTo" or type: RelationType.BELONGS_TO

  • Has One The inverse of belongsTo for a "one-to-one" relationship. type: "hasOne" or type: RelationType.HAS_ONE

  • Has Many The inverse of belongsTo for a "one-to-many" relationship. type: "hasMany" or type: RelationType.HAS_MANY

  • References Many The current model references the target model via an array of IDs. type: "referencesMany" or type: RelationType.REFERENCES_MANY

Polymorphic versions:

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:

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:

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:

// 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:

// 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:

// 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:

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 identifier is in "referenceId"
      polymorphic: true,
    },
  },
});

Relation definition with explicit properties:

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 identifier
      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:

// 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:

// 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 identifier
      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:

// 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:

// 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 identifier
      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.

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.

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.

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>('city');

// now, repository methods return
// the City type instead of Record<string, unknown>
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, which comes with a set of TypeScript decorators and additional tools for working in a TypeScript environment.

Tests

npm run test

License

MIT