diff --git a/.eslintrc.json b/.eslintrc.json index 23c4d6af4..4d2c17555 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,8 @@ { "root": true, "parserOptions": { - "ecmaVersion": "latest" + "ecmaVersion": "latest", + "sourceType": "module" }, "plugins": [ "prettier" @@ -11,25 +12,14 @@ "no-undef": "error" }, "env": { - "es6": true, - "node": true, - "mocha": true + "es2021": true, + "node": true }, "overrides": [ { - "files": [ - "lib/**/*.js" - ], + "files": ["tests/unit/relations/files/*.js"], "parserOptions": { - "ecmaVersion": 2020 - } - }, - { - "files": [ - "tests/**/*.js" - ], - "env": { - "es2021": true + "sourceType": "script" } } ] diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 229d3c58d..3ecbe0232 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,7 +7,7 @@ on: - 'doc/**' - '.github/workflows/docs.yml' - 'package.json' - - 'package-lock.json' + - 'pnpm-lock.yaml' workflow_dispatch: permissions: @@ -24,13 +24,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: node-version: 22 - cache: npm + cache: pnpm # Native modules (sqlite3) aren't needed to build the docs. - - run: npm ci --ignore-scripts - - run: npm run docs:build + - run: pnpm install --frozen-lockfile --ignore-scripts + - run: pnpm docs:build - uses: actions/upload-pages-artifact@v5 with: path: doc/.vitepress/dist diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eed870517..0368f4d12 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: tests on: push: - branches: [main] + branches: [next] pull_request: - branches: [main] + branches: [next] jobs: run-tests: @@ -13,55 +13,49 @@ jobs: strategy: fail-fast: false matrix: - node-version: [16.x, 18.x, 20.x, 22.x, 24.x] - knex: [default] + # 20.19.0 is the oldest supported version, the first one that can + # require() ES modules without a flag. + node-version: [20.19.0, 20.x, 22.x, 24.x] database: [default] - # Also test the oldest supported knex version, MySQL 5.7 and SQL Server, - # each as a separate job. + # Also test SQL Server as a separate job. include: - - node-version: 16.x - knex: 2.3.0 - database: default - node-version: 24.x - knex: default - database: mysql-5.7 - - node-version: 24.x - knex: default database: mssql env: - COMPOSE_FILE: ${{ matrix.database == 'mysql-5.7' && 'docker-compose.yml:docker-compose.mysql57.yml' || 'docker-compose.yml' }} - # The MySQL 5.7 and SQL Server jobs only need their database, the default - # services start otherwise. - SERVICES: ${{ matrix.database == 'mysql-5.7' && 'mysql' || matrix.database == 'mssql' && 'mssql' || '' }} + # The SQL Server job only needs its database, the default services start + # otherwise. + SERVICES: ${{ matrix.database == 'mssql' && 'mssql' || '' }} steps: - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} + cache: pnpm - name: Install dependencies - run: npm ci - - - name: Install knex ${{ matrix.knex }} - if: matrix.knex != 'default' - run: npm install --no-save knex@${{ matrix.knex }} + run: pnpm install --frozen-lockfile # The generated parsers have to match their grammars. One job is enough. - name: Check the generated parsers - if: matrix.node-version == '24.x' && matrix.knex == 'default' && matrix.database == 'default' + if: matrix.node-version == '24.x' && matrix.database == 'default' run: | - npm run build:parsers + pnpm build:parsers git diff --exit-code lib/queryBuilder/parsers # The documented minimum TypeScript version has to compile the typings - # and their tests. One job is enough. - - name: Check the typings with TypeScript 5.1 - if: matrix.node-version == '24.x' && matrix.knex == 'default' && matrix.database == 'default' - run: npx --yes -p typescript@5.1.6 tsc + # and their tests, `pnpm test` checks them with the latest one. One job + # is enough. + - name: Check the typings with TypeScript 5.8 + if: matrix.node-version == '24.x' && matrix.database == 'default' + run: | + pnpm dlx --package=typescript@5.8.3 tsc + pnpm dlx --package=typescript@5.8.3 tsc -p tests/ts/strict # Cache the database images, as their registries rate limit anonymous # pulls. The month in the key picks up updates of their floating tags. @@ -104,7 +98,7 @@ jobs: docker save -o "$IMAGES_PATH" $IMAGES fi fi - npm run db:up -- $SERVICES + pnpm db:up $SERVICES - name: Save database images if: steps.cache.outcome == 'success' && steps.cache.outputs.cache-hit != 'true' @@ -114,15 +108,13 @@ jobs: key: ${{ steps.images.outputs.key }} - name: Run tests - if: matrix.knex == 'default' && matrix.database == 'default' - run: npm test + if: matrix.database == 'default' + run: pnpm test - # The typings of older knex versions don't compile with current - # TypeScript, so only the test suite runs against them. The MySQL 5.7 and - # SQL Server jobs only run the tests of their database, the other jobs + # The SQL Server job only runs the tests of its database, the other jobs # already lint and check typings. - - name: Run tests without linting and typings - if: matrix.knex != 'default' || matrix.database != 'default' + - name: Run the tests of the database + if: matrix.database != 'default' env: - DATABASES: ${{ matrix.database == 'mysql-5.7' && 'mysql' || matrix.database == 'mssql' && 'mssql' || '' }} - run: npx mocha --slow 10 --timeout 15000 --reporter spec --recursive tests --exclude "tests/unit/relations/files/**" + DATABASES: ${{ matrix.database }} + run: pnpm vitest run diff --git a/.gitignore b/.gitignore index 61f6d8168..14f3bafdd 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ npm-debug.log* .vscode /**/yarn.lock doc/.vitepress/cache -doc/.vitepress/dist \ No newline at end of file +doc/.vitepress/dist +tests/bench/typings/.generated diff --git a/doc/.vitepress/config.mjs b/doc/.vitepress/config.mjs index 948f5ce1b..b71c1edb6 100644 --- a/doc/.vitepress/config.mjs +++ b/doc/.vitepress/config.mjs @@ -141,7 +141,7 @@ export default defineConfig({ activeMatch: '^/release-notes/', items: [ { text: 'Changelog', link: '/release-notes/changelog' }, - { text: 'Migration to 3.0', link: '/release-notes/migration' }, + { text: 'Migration to 4.0', link: '/release-notes/migration' }, { text: 'v2.x documentation', link: 'https://github.com/Vincit/objection.js/tree/v2/doc', diff --git a/doc/api/model/instance-methods.md b/doc/api/model/instance-methods.md index a260ee216..edf3d5b32 100644 --- a/doc/api/model/instance-methods.md +++ b/doc/api/model/instance-methods.md @@ -599,6 +599,8 @@ See [this section](/api/model/overview.html#model-data-lifecycle) for more infor | ------ | ----------------------- | | Object | Model as a JSON object. | +In TypeScript, the JSON object is typed `ModelJson`: the model's properties without methods, with the related models as `ModelJson` too. Values like dates aren't converted. + ##### Examples ```js @@ -635,9 +637,9 @@ See [this section](/api/model/overview.html#model-data-lifecycle) for more infor ```js class Person extends Model { - $parseDatabaseJson(json) { + $parseDatabaseJson(json, options) { // Remember to call the super class's implementation. - json = super.$parseDatabaseJson(json); + json = super.$parseDatabaseJson(json, options); // Do your conversion here. return json; } @@ -654,11 +656,14 @@ There are a couple of requirements for the implementation: 2. This function must be able to handle any subset of the model's properties coming in.You cannot assume that some column is present in the `json` object as it depends on the select statement. There can also be additional columns because of joins, aliases etc. This method must also be prepared for null values in _any_ property of the `json` object. +The default implementation applies the [columnNameMappers](/api/model/static-properties.html#static-columnnamemappers) and then parses the [jsonAttributes](/api/model/static-properties.html#static-jsonattributes) as [jsonColumnFormat](/api/model/static-properties.html#static-jsoncolumnformat) says. + ##### Arguments | Argument | Type | Description | | -------- | ------ | -------------------------------- | | json | Object | The JSON POJO in database format | +| options | Object | Optional. Passed by queries to parse the [jsonAttributes](/api/model/static-properties.html#static-jsonattributes) depending on the driver. Overrides pass it on to `super`. | ##### Return value @@ -802,13 +807,14 @@ Validates the JSON before setting values. modelInstance.$setDatabaseJson(json); ``` -Sets the values from a JSON object in database format. +Sets the values from a JSON object in database format, converted by [\$parseDatabaseJson](/api/model/instance-methods.html#parsedatabasejson). Called by hand, the source of `json` is unknown: unless [jsonColumnFormat](/api/model/static-properties.html#static-jsoncolumnformat) says otherwise, strings in [jsonAttributes](/api/model/static-properties.html#static-jsonattributes) that are valid JSON are parsed, and other strings are kept. ##### Arguments | Argument | Type | Description | | -------- | ------ | -------------------------------- | | json | Object | The JSON POJO in database format | +| options | Object | Optional. Passed by queries to parse the [jsonAttributes](/api/model/static-properties.html#static-jsonattributes) depending on the driver. Overrides pass it on to `super`. | ##### Return value @@ -846,6 +852,15 @@ modelInstance.$setRelated(relation, relatedModels); Sets related models to a corresponding property in the object. +The foreign keys follow the relation: + +- `BelongsToOneRelation`: the foreign key of the object is set to the key of the related model, e.g. `post.$setRelated('author', user)` also sets `post.authorId` to `user.id`. Clearing the relation with `null` or `[]`, or setting a related model without a key, sets the foreign key to `null`. +- `BelongsToManyRelation`: the array of keys of the object is set to the keys of the related models, e.g. `post.$setRelated('tags', tags)` sets `post.tagIds` to the ids of `tags`, and to `[]` when the relation is cleared. Related models without a key are left out of the array. +- `HasOneRelation` and `HasManyRelation`: the foreign keys of the related models are set to the key of the object, e.g. `user.$setRelated('posts', posts)` sets `authorId` of each post to `user.id`. The foreign keys of the models that are no longer related, because the relation is cleared or they are replaced, are set to `null`, unless they already point to another object. If the object has no key yet, the foreign keys of the related models are set to `null`. +- `ManyToManyRelation` and `HasOneThroughRelation`: nothing is set, as the keys live in the join table. + +Foreign keys set on the models before are overwritten. Only `$setRelated()` and `$appendRelated()` sync the keys: [fromJson](/api/model/static-methods.html#static-fromjson), [\$setJson](/api/model/instance-methods.html#setjson), queries and direct assignments leave them as they are. + ##### Arguments | Argument | Type | Description | @@ -877,7 +892,7 @@ console.log(person.children[0]); modelInstance.$appendRelated(relation, relatedModels); ``` -Appends related models to a corresponding property in the object. +Appends related models to a corresponding property in the object. The foreign keys of the appended models are synced like with [\$setRelated](/api/model/instance-methods.html#setrelated), and the models already in the relation keep theirs. For `BelongsToManyRelation`, the keys of the appended models are added to the array of the object, also when the relation isn't loaded. For `BelongsToOneRelation` and `HasOneRelation`, the related model is replaced like with `$setRelated()`. ##### Arguments diff --git a/doc/api/model/static-methods.md b/doc/api/model/static-methods.md index 0c7069b69..10bb89eb9 100644 --- a/doc/api/model/static-methods.md +++ b/doc/api/model/static-methods.md @@ -72,11 +72,9 @@ const sylvester = await Person.query().insert({ console.log(sylvester.fullName()); // --> 'Sylvester Stallone'. -// Batch insert. This only works on Postgresql as it is -// the only database that returns the identifiers of -// _all_ inserted rows. If you need to do batch inserts -// on other databases useknex* directly. -// (See .knexQuery() method). +// Batch insert. On MySQL, this only works for models that +// have their identifiers already, or have no `idColumn`, as +// MySQL doesn't return the identifiers of _all_ inserted rows. const inserted = await Person.query().insert([ { firstName: 'Arnold', lastName: 'Schwarzenegger' }, { firstName: 'Sylvester', lastName: 'Stallone' } @@ -278,7 +276,9 @@ If you want to use multiple databases, you can instead pass the knex instance to Set a knex instance: ```js -const knex = require('knex')({ +import Knex from 'knex'; + +const knex = Knex({ client: 'sqlite3', connection: { filename: 'database.db' @@ -408,6 +408,12 @@ class Person extends Model { A hook that is executed after find queries. +`args.result` is what the query returns: an array of models, or the model of [first](/api/query-builder/other-methods.html#first), [findById](/api/query-builder/find-methods.html#findbyid) and queries for to-one relations, which is `undefined` when nothing is found. With [page](/api/query-builder/other-methods.html#page) and [range](/api/query-builder/other-methods.html#range), it's the models of the page, as the `{ results, total }` object is created after the hook. In the queries of relations loaded with [withGraphFetched](/api/query-builder/eager-methods.html#withgraphfetched), it's the array of the related models of all owners in the batch. They are assigned to their owners once the relation query has resolved, after the hook and the [runAfter](/api/query-builder/other-methods.html#runafter) callbacks, so the hook can filter or reorder them and return the array. Returning other objects throws an error. + +The relations joined with [withGraphJoined](/api/query-builder/eager-methods.html#withgraphjoined) have no query of their own. After the query, the hooks of their model classes are called bottom-up, from the deepest relation to the root, with the related models of all owners as `result`, the `relation`, its owners as `items`, `context` and `transaction`. `asFindQuery()` and `cancelQuery()` throw an error there, and `beforeFind` isn't called for them. What the hooks return is assigned like with `withGraphFetched`. + +The hook runs after the instance hooks [\$afterFind](/api/model/instance-methods.html#afterfind) and before the [runAfter](/api/query-builder/other-methods.html#runafter) callbacks of the query, which get its return value. + See these sections for more information: - [static hooks guide](/guide/hooks.html#static-query-hooks) @@ -462,6 +468,8 @@ class Person extends Model { A hook that is executed after update and patch queries. +`args.result` is the number of updated rows, or the updated rows with [returning](/api/query-builder/find-methods.html#returning), in the shape the query returns them: one model, or `undefined`, for single queries like `findById()`, `first()`, `$query()` and `$relatedQuery()` of a to-one relation, an array otherwise. With [patchAndFetchById](/api/query-builder/mutate-methods.html#patchandfetchbyid), [patchAndFetch](/api/query-builder/mutate-methods.html#patchandfetch) and their `update*` variants, the hook runs after the row is fetched, and `result` is the fetched model, or `undefined` if nothing was updated. + See these sections for more information: - [static hooks guide](/guide/hooks.html#static-query-hooks) @@ -516,7 +524,7 @@ class Person extends Model { A hook that is executed after insert queries. -`args.result` is an array of the inserted model instances. These are the input models with only the values returned by the database from the `insert` statement merged in. Values generated by the database (column defaults, triggers) and values converted by the database (for example booleans stored as `0`/`1`) are therefore only present if the database returns them: +`args.result` is what the query returns: the inserted model instance, or an array of them if an array was inserted. These are the input models with only the values returned by the database from the `insert` statement merged in. Values generated by the database (column defaults, triggers) and values converted by the database (for example booleans stored as `0`/`1`) are therefore only present if the database returns them: - On PostgreSQL and SQL Server, objection adds `returning(idColumn)` to the insert by default, so only the id is merged. Use [returning('\*')](/api/query-builder/find-methods.html#returning) to merge all columns. - On MySQL and SQLite, only the generated id is set. @@ -524,8 +532,8 @@ A hook that is executed after insert queries. The different insert methods behave as follows: - [insert](/api/query-builder/mutate-methods.html#insert): `result` contains the inserted models as described above. -- [insertAndFetch](/api/query-builder/mutate-methods.html#insertandfetch): same as `insert`. The hook is called before the rows are fetched. The fetched values are then written onto the same model instances, overwriting any changes the hook made to those properties. -- [insertGraph](/api/query-builder/mutate-methods.html#insertgraph) and [insertGraphAndFetch](/api/query-builder/mutate-methods.html#insertgraphandfetch): the graph is inserted using one insert query per model class (one per row on databases other than PostgreSQL), and the hook of each model class is called once for each of these queries. `result` contains only the models inserted by that query. The return value of the hook is not used as the return value of the graph query. `insertGraphAndFetch` returns freshly fetched models, not the instances passed to the hook. +- [insertAndFetch](/api/query-builder/mutate-methods.html#insertandfetch): the hook is called after the rows are fetched, and `result` contains the inserted models with the fetched values. +- [insertGraph](/api/query-builder/mutate-methods.html#insertgraph) and [insertGraphAndFetch](/api/query-builder/mutate-methods.html#insertgraphandfetch): the graph is inserted using one insert query per model class (one per row on databases other than PostgreSQL), and the hook of each model class is called once for each of these queries. `result` contains only the models inserted by that query. The outer graph query calls no hooks, so the return value of the hook is not used as the return value of the graph query. `insertGraphAndFetch` returns freshly fetched models, not the instances passed to the hook. See these sections for more information: @@ -581,6 +589,8 @@ class Person extends Model { A hook that is executed after delete queries. +`args.result` is the number of deleted rows, or the deleted rows with [returning](/api/query-builder/find-methods.html#returning), in the shape the query returns them: one model, or `undefined`, for single queries like `findById()`, `first()`, `$query()` and `$relatedQuery()` of a to-one relation, an array otherwise. + See these sections for more information: - [static hooks guide](/guide/hooks.html#static-query-hooks) @@ -625,14 +635,16 @@ Also check out the [model binding pattern for transactions](/guide/transactions. ##### Examples ```js -const knex1 = require('knex')({ +import Knex from 'knex'; + +const knex1 = Knex({ client: 'sqlite3', connection: { filename: 'database1.db' } }); -const knex2 = require('knex')({ +const knex2 = Knex({ client: 'sqlite3', connection: { filename: 'database2.db' @@ -667,8 +679,8 @@ Alias for [bindKnex](/api/model/static-methods.html#static-bindknex). ##### Examples ```js -const { transaction } = require('objection'); -const Person = require('./models/Person'); +import { transaction } from 'objection'; +import Person from './models/Person.js'; await transaction(Person.knex(), async trx => { const TransactingPerson = Person.bindTransaction(trx); @@ -684,8 +696,8 @@ await transaction(Person.knex(), async trx => { This is 100% equivalent to the example above: ```js -const { transaction } = require('objection'); -const Person = require('./models/Person'); +import { transaction } from 'objection'; +import Person from './models/Person.js'; await transaction(Person, async TransactingPerson => { await TransactingPerson.query().insert({ firstName: 'Jennifer' }); @@ -748,11 +760,14 @@ Creates a model instance from a JSON object send by the database driver. Unlike [fromJson](/api/model/static-methods.html#static-fromjson), this method doesn't validate the input. The input is expected to be in the database format as explained [here](/api/model/overview.html#model-data-lifecycle). +Called by hand, the source of the row is unknown: unless [jsonColumnFormat](/api/model/static-properties.html#static-jsoncolumnformat) says otherwise, strings in [jsonAttributes](/api/model/static-properties.html#static-jsonattributes) that are valid JSON are parsed, and other strings are kept. + ##### Arguments | Argument | Type | Description | | -------- | ------ | --------------- | | row | Object | A database row. | +| options | Object | Optional. Passed by queries to parse the [jsonAttributes](/api/model/static-properties.html#static-jsonattributes) depending on the driver. Overrides pass it on to `super`. | ##### Return value @@ -821,7 +836,7 @@ class BaseModel extends Model { The default implementation: ```js -const AjvValidator = require('objection').AjvValidator; +import { AjvValidator } from 'objection'; class Model { static createValidator() { diff --git a/doc/api/model/static-properties.md b/doc/api/model/static-properties.md index 53f71d167..fd83d1ad0 100644 --- a/doc/api/model/static-properties.md +++ b/doc/api/model/static-properties.md @@ -38,7 +38,7 @@ Further reading: ##### Examples ```js -const { Model, ref } = require('objection'); +import { Model, ref } from 'objection'; class Person extends Model { static get tableName() { @@ -118,7 +118,7 @@ Composite id can be specified by giving an array of column names. Defaults to 'id'. -You can return `null` in order to tell objection there is no primary key. This may be useful in models of join tables. Such models can be inserted, queried, patched, updated and deleted using `where` clauses, but methods that need an identifier, like [findById](/api/query-builder/find-methods.html#findbyid), [deleteById](/api/query-builder/mutate-methods.html#deletebyid) or [\$query](/api/model/instance-methods.html#query), throw an error. [withGraphJoined](/api/query-builder/eager-methods.html#withgraphjoined) also requires a primary key. +You can return `null` (or `undefined`) in order to tell objection there is no primary key. This may be useful in models of join tables. Such models can be inserted, queried, patched, updated and deleted using `where` clauses, but methods that need an identifier, like [findById](/api/query-builder/find-methods.html#findbyid), [deleteById](/api/query-builder/mutate-methods.html#deletebyid) or [\$query](/api/model/instance-methods.html#query), throw an error. [withGraphJoined](/api/query-builder/eager-methods.html#withgraphjoined) also requires a primary key. ## `static` jsonSchema @@ -300,7 +300,7 @@ Person.query() ```js class Person extends Model { static get modelPaths() { - return [__dirname]; + return [import.meta.dirname]; } } ``` @@ -322,21 +322,17 @@ shared configuration such as this there. Using a shared `BaseModel` superclass: ```js -const { Model } = require('objection'); - // models/BaseModel.js -class BaseModel extends Model { +import { Model } from 'objection'; + +export class BaseModel extends Model { static get modelPaths() { - return [__dirname]; + return [import.meta.dirname]; } } -module.exports = { - BaseModel -}; - // models/Person.js -const { BaseModel } = require('./BaseModel'); +import { BaseModel } from './BaseModel.js'; class Person extends BaseModel { ... @@ -375,6 +371,24 @@ The properties listed here are serialized to JSON strings upon insertion/update If this property is left unset all properties declared as objects or arrays in the [jsonSchema](/api/model/static-properties.html#static-jsonschema) are implicitly added to this list. +How they're parsed depends on [jsonColumnFormat](/api/model/static-properties.html#static-jsoncolumnformat). + +## `static` jsonColumnFormat + +```js +class Person extends Model { + static get jsonColumnFormat() { + return 'text'; + } +} +``` + +How the database returns the columns of the [jsonAttributes](/api/model/static-properties.html#static-jsonattributes): + +- `'auto'` (default): the PostgreSQL drivers (`pg`, `pgnative`, `cockroachdb`) return `json` and `jsonb` columns parsed, and their values are left as they are. With other drivers, and in rows passed to `fromDatabaseJson()` by hand, strings that are valid JSON are parsed and other strings are kept. +- `'text'`: the columns hold JSON text, e.g. JSON in `text` columns on PostgreSQL, or on MariaDB. It is parsed with `JSON.parse()`, and invalid JSON throws an error. +- `'parsed'`: the driver returns the columns parsed. Their values are never parsed. + ## `static` cloneObjectAttributes ```js @@ -396,7 +410,7 @@ TLDR; Set this setting to `false` if you have large jsonb columns and you see th ## `static` columnNameMappers ```js -const { Model, snakeCaseMappers } = require('objection'); +import { Model, snakeCaseMappers } from 'objection'; class Person extends Model { static get columnNameMappers() { @@ -417,7 +431,7 @@ Further reading: If your columns are UPPER_SNAKE_CASE: ```js -const { Model, snakeCaseMappers } = require('objection'); +import { Model, snakeCaseMappers } from 'objection'; class Person extends Model { static get columnNameMappers() { @@ -435,13 +449,15 @@ class Person extends Model { // database --> code }, - format(obj) { + format(obj, isFieldExpression) { // code --> database } }; } ``` +`format` gets a second argument, `isFieldExpression(key)`, which tells keys with a `:` that are [field expressions](/api/types/#type-fieldexpression) like `'jsonColumn:someKey'` apart from columns that the model's [jsonSchema](/api/model/static-properties.html#static-jsonschema) declares, like `'ns:name'`. Mappers can ignore it. + ## `static` uidProp ```js @@ -568,7 +584,7 @@ class Person extends Model { Sets the default options for [withGraphFetched](/api/query-builder/eager-methods.html#withgraphfetched) and [withGraphJoined](/api/query-builder/eager-methods.html#withgraphjoined). See the possible fields [here](/api/types/#type-graphoptions). -Defaults to `{ minimize: false, separator: ':', aliases: {}, maxBatchSize: 10000 }`. +Defaults to `{ minimize: false, separator: ':', aliases: {}, maxBatchSize: 10000, limitPerParent: true }`. ## `static` useLimitInFirst @@ -710,3 +726,18 @@ class Person extends Model { ``` A relation type that can be used in [relationMappings](/api/model/static-properties.html#static-relationmappings) to create a has-one-through relationship. See [this section](/guide/relations.md) for more information about different relation types. + +## `static` BelongsToManyRelation + +```js +class Post extends Model { + static get relationMappings() { + tags: { + relation: Model.BelongsToManyRelation, + ... + } + } +} +``` + +A relation type that can be used in [relationMappings](/api/model/static-properties.html#static-relationmappings) to create a belongs-to-many relationship, where the owner holds an array of the related models' keys. See [this section](/guide/relations.md) for more information about different relation types. diff --git a/doc/api/objection/index.md b/doc/api/objection/index.md index 68d6a6cd9..955e512b2 100644 --- a/doc/api/objection/index.md +++ b/doc/api/objection/index.md @@ -1,8 +1,8 @@ # `module` objection ```js -const objection = require('objection'); -const { Model, ref } = require('objection'); +import * as objection from 'objection'; +import { Model, ref } from 'objection'; ``` The objection module is what you get when you import objection. It has a bunch of properties that are listed below. @@ -10,7 +10,7 @@ The objection module is what you get when you import objection. It has a bunch o ## Model ```js -const { Model } = require('objection'); +import { Model } from 'objection'; ``` [The model class](/api/model/) @@ -18,7 +18,7 @@ const { Model } = require('objection'); ## initialize ```js -const { initialize } = require('objection'); +import { initialize } from 'objection'; ``` For some queries objection needs to perform asynchronous operations in preparation, like fetch table metadata from the db. Objection does these preparations on-demand the first time such query is executed. However, some methods like `toKnexQuery` need these preparations to have been made so that the query can be built synchronously. In these cases you can use `initialize` to "warm up" the models and do all needed async preparations needed. You only need to call this function once if you choose to use it. @@ -30,7 +30,7 @@ You can also call this function if you want to be in control of when these async ##### Examples ```js -const { initialize } = require('objection'); +import { initialize } from 'objection'; await initialize(knex, [Person, Movie, Pet, SomeOtherModelClass]); ``` @@ -38,7 +38,7 @@ await initialize(knex, [Person, Movie, Pet, SomeOtherModelClass]); If knex has been installed for the `Model` globally, you can omit the first argument. ```js -const { initialize } = require('objection'); +import { initialize } from 'objection'; await initialize([Person, Movie, Pet, SomeOtherModelClass]); ``` @@ -46,7 +46,7 @@ await initialize([Person, Movie, Pet, SomeOtherModelClass]); ## transaction ```js -const { transaction } = require('objection'); +import { transaction } from 'objection'; ``` [The transaction function](/guide/transactions.html) @@ -54,7 +54,7 @@ const { transaction } = require('objection'); ## ref ```js -const { ref } = require('objection'); +import { ref } from 'objection'; ``` Factory function that returns a [ReferenceBuilder](/api/types/#class-referencebuilder) instance, that makes it easier to refer to tables, columns, json attributes etc. [ReferenceBuilder](/api/types/#class-referencebuilder) can also be used to type cast and alias the references. @@ -64,7 +64,7 @@ See [FieldExpression](/api/types/#type-fieldexpression) for more information abo ##### Examples ```js -const { ref } = require('objection'); +import { ref } from 'objection'; await Model.query() .select([ @@ -103,7 +103,7 @@ await Person.query() ## raw ```js -const { raw } = require('objection'); +import { raw } from 'objection'; ``` Factory function that returns a [RawBuilder](/api/types/#class-rawbuilder) instance. [RawBuilder](/api/types/#class-rawbuilder) is a wrapper for knex raw method that doesn't depend on knex. Instances of [RawBuilder](/api/types/#class-rawbuilder) are converted to knex raw instances lazily when the query is executed. @@ -117,7 +117,7 @@ When using raw SQL segments in queries, it's a good idea to use placeholders ins You can use `??` as a placeholder for identifiers (column names, aliases etc.) and `?` for values. ```js -const { raw } = require('objection'); +import { raw } from 'objection'; const result = await Person.query() .select(raw('coalesce(sum(??), 0) as ??', ['age', 'ageSum'])) @@ -157,7 +157,7 @@ await Person.query() You can nest `ref`, `raw`, `val` and query builders (both knex and objection) in `raw` calls ```js -const { val } = require('objection') +import { val } from 'objection'; await Person .query() @@ -174,7 +174,7 @@ await Person ## val ```js -const { val } = require('objection'); +import { val } from 'objection'; ``` Factory function that returns a [ValueBuilder](/api/types/#class-valuebuilder) instance. [ValueBuilder](/api/types/#class-valuebuilder) helps build values of different types. @@ -182,7 +182,7 @@ Factory function that returns a [ValueBuilder](/api/types/#class-valuebuilder) i ##### Examples ```js -const { val, ref } = require('objection'); +import { val, ref } from 'objection'; // Compare json objects await Model.query().where( @@ -200,7 +200,7 @@ await Model.query().insert({ ## fn ```js -const { fn } = require('objection'); +import { fn } from 'objection'; ``` Factory function that returns a [FunctionBuilder](/api/types/#class-functionbuilder) instance. `fn` helps calling SQL functions. The signature is: @@ -236,7 +236,7 @@ All arguments are interpreted as values by default. Use `ref` to refer to column ##### Examples ```js -const { fn, ref } = require('objection'); +import { fn, ref } from 'objection'; // Compare nullable numbers await Model.query().where(fn('coalesce', ref('age'), 0), '>', 30); @@ -254,7 +254,7 @@ await Model.query().whereRaw('coalesce(age, 0) > ?', 30); ## mixin ```js -const { mixin } = require('objection'); +import { mixin } from 'objection'; ``` The mixin helper for applying multiple [plugins](/guide/plugins.html). @@ -262,7 +262,7 @@ The mixin helper for applying multiple [plugins](/guide/plugins.html). ##### Examples ```js -const { mixin, Model } = require('objection'); +import { mixin, Model } from 'objection'; class Person extends mixin(Model, [ SomeMixin, @@ -276,7 +276,7 @@ class Person extends mixin(Model, [ ## compose ```js -const { compose } = require('objection'); +import { compose } from 'objection'; ``` The compose helper for applying multiple [plugins](/guide/plugins.html). @@ -284,7 +284,7 @@ The compose helper for applying multiple [plugins](/guide/plugins.html). ##### Examples ```js -const { compose, Model } = require('objection'); +import { compose, Model } from 'objection'; const mixins = compose( SomeMixin, @@ -297,10 +297,32 @@ const mixins = compose( class Person extends mixins(Model) {} ``` +## asArray + +```js +import { asArray } from 'objection'; +``` + +Returns arrays as they are, `[]` for `undefined`, and wraps any other value in an array. Use it in [static hooks](/guide/hooks.html#static-query-hooks) to handle a `result` that may be a single model, an array of models or `undefined` the same way. + +##### Examples + +```js +import { Model, asArray } from 'objection'; + +class Person extends Model { + static afterFind({ result }) { + for (const person of asArray(result)) { + person.fullName = `${person.firstName} ${person.lastName}`; + } + } +} +``` + ## snakeCaseMappers ```js -const { snakeCaseMappers } = require('objection'); +import { snakeCaseMappers } from 'objection'; ``` Function for adding snake_case to camelCase conversion to objection models. Better documented [here](/recipes/snake-case-to-camel-case-conversion.html). The `snakeCaseMappers` function accepts an options object. The available options are: @@ -311,12 +333,12 @@ Function for adding snake_case to camelCase conversion to objection models. Bett | underscoreBeforeDigits | boolean | `false` | When `true`, will place an underscore before digits (`foo1Bar2` becomes `foo_1_bar_2`). When `false`, `foo1Bar2` becomes `foo1_bar2`. | | underscoreBetweenUppercaseLetters | boolean | `false` | When `true`, will place underscores between consecutive uppercase letters (`fooBAR` becomes `foo_b_a_r`). When `false`, `fooBAR` will become `foo_bar`. | | noDoubleUnderscores | boolean | `false` | When `true`, will never insert an underscore directly after an existing one (with `underscoreBeforeDigits`, `foo_1` stays `foo_1` instead of becoming `foo__1`; `foo_Bar` becomes `foo_bar` instead of `foo__bar`). Underscores already present in the input are kept as is. | -| preserveJsonKeys | boolean | `false` | When `true`, only the column part of [field expressions](/api/types/#type-fieldexpression) used as property names, e.g. in `patch({ 'jsonColumn:someKey': value })`, is converted and the JSON path is kept as written (`json_column:someKey`). When `false`, the JSON keys are converted too (`json_column:some_key`). Keys of objects stored in JSON columns are never converted. Enabling this changes which JSON keys existing field expression updates write to. | +| preserveJsonKeys | boolean | `true` | When `true`, only the column part of [field expressions](/api/types/#type-fieldexpression) used as property names, e.g. in `patch({ 'jsonColumn:someKey': value })`, is converted and the JSON path is kept as written (`json_column:someKey`). When `false`, the JSON keys are converted too (`json_column:some_key`), as by default in objection 3. Keys of objects stored in JSON columns are never converted. Keys with a `:` that the model's [jsonSchema](/api/model/static-properties.html#static-jsonschema) declares in its `properties` are columns and converted as a whole: a column `ns:foo_bar` is read as `ns:fooBar` and written back to `ns:foo_bar` if the schema declares `ns:fooBar`. | ##### Examples ```js -const { Model, snakeCaseMappers } = require('objection'); +import { Model, snakeCaseMappers } from 'objection'; class Person extends Model { static get columnNameMappers() { @@ -328,7 +350,7 @@ class Person extends Model { If your columns are UPPER_SNAKE_CASE ```js -const { Model, snakeCaseMappers } = require('objection'); +import { Model, snakeCaseMappers } from 'objection'; class Person extends Model { static get columnNameMappers() { @@ -337,25 +359,25 @@ class Person extends Model { } ``` -To only convert the column part of field expressions and keep their JSON keys as written +To convert the JSON keys of field expressions too, as by default in objection 3 ```js -const { Model, snakeCaseMappers } = require('objection'); +import { Model, snakeCaseMappers } from 'objection'; class Person extends Model { static get columnNameMappers() { - return snakeCaseMappers({ preserveJsonKeys: true }); + return snakeCaseMappers({ preserveJsonKeys: false }); } } -// update "persons" set "json_column" = jsonb_set("json_column", '{someKey}', '1', true) +// update "persons" set "json_column" = jsonb_set("json_column", '{some_key}', '1', true) await Person.query().patch({ 'jsonColumn:someKey': 1 }); ``` ## knexSnakeCaseMappers ```js -const { knexSnakeCaseMappers } = require('objection'); +import { knexSnakeCaseMappers } from 'objection'; ``` Function for adding a snake_case to camelCase conversion to `knex`. Better documented [here](/recipes/snake-case-to-camel-case-conversion.html). The `knexSnakeCaseMappers` function accepts an options object. The available options are: @@ -370,8 +392,8 @@ Function for adding a snake_case to camelCase conversion to `knex`. Better docum ##### Examples ```js -const { knexSnakeCaseMappers } = require('objection'); -const Knex = require('knex'); +import { knexSnakeCaseMappers } from 'objection'; +import Knex from 'knex'; const knex = Knex({ client: 'postgres', @@ -389,8 +411,8 @@ const knex = Knex({ If your columns are UPPER_SNAKE_CASE ```js -const { knexSnakeCaseMappers } = require('objection'); -const Knex = require('knex'); +import { knexSnakeCaseMappers } from 'objection'; +import Knex from 'knex'; const knex = Knex({ client: 'postgres', @@ -408,8 +430,8 @@ const knex = Knex({ For older nodes: ```js -const Knex = require('knex'); -const knexSnakeCaseMappers = require('objection').knexSnakeCaseMappers; +import Knex from 'knex'; +import { knexSnakeCaseMappers } from 'objection'; const knex = Knex({ client: 'postgres', @@ -427,7 +449,7 @@ const knex = Knex({ ## knexIdentifierMapping ```js -const { knexIdentifierMapping } = require('objection'); +import { knexIdentifierMapping } from 'objection'; ``` Like [knexSnakeCaseMappers](/api/objection/#knexsnakecasemappers), but can be used to make an arbitrary static mapping between column names and property names. In the examples, you would have identifiers `MyId`, `MyProp` and `MyAnotherProp` in the database and you would like to map them into `id`, `prop` and `anotherProp` in the code. @@ -435,8 +457,8 @@ Like [knexSnakeCaseMappers](/api/objection/#knexsnakecasemappers), but can be us ##### Examples ```js -const { knexIdentifierMapping } = require('objection'); -const Knex = require('knex'); +import { knexIdentifierMapping } from 'objection'; +import Knex from 'knex'; const knex = Knex({ client: 'postgres', @@ -458,13 +480,21 @@ const knex = Knex({ Note that you can pretty easily define the conversions in some static property of your model. In this example we have added a property `column` to jsonSchema and use that to create the mapping object. ```js -const { knexIdentifierMapping } = require('objection'); -const Knex = require('knex'); -const path = require('path') -const fs = require('fs'); +import { knexIdentifierMapping } from 'objection'; +import Knex from 'knex'; +import path from 'node:path'; +import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; // Path to your model folder. -const MODELS_PATH = path.join(__dirname, 'models'); +const MODELS_PATH = path.join(import.meta.dirname, 'models'); + +// Import all models. +const modelClasses = await Promise.all( + fs.readdirSync(MODELS_PATH) + .filter(it => it.endsWith('.js')) + .map(async it => (await import(pathToFileURL(path.join(MODELS_PATH, it)).href)).default) +); const knex = Knex({ client: 'postgres', @@ -477,10 +507,8 @@ const knex = Knex({ // Go through all models and add conversions using the custom property // `column` in json schema. - ...knexIdentifierMapping(fs.readdirSync(MODELS_PATH) - .filter(it => it.endsWith('.js')) - .map(it => require(path.join(MODELS_PATH, it))) - .reduce((mapping, modelClass) => { + ...knexIdentifierMapping( + modelClasses.reduce((mapping, modelClass) => { const properties = modelClass.jsonSchema.properties; return Object.keys(properties).reduce((mapping, propName) => { mapping[properties[propName].column] = propName; @@ -491,33 +519,10 @@ const knex = Knex({ }); ``` -For older nodes: - -```js -const Knex = require('knex'); -const knexIdentifierMapping = require('objection').knexIdentifierMapping; - -const knex = Knex({ - client: 'postgres', - - connection: { - host: '127.0.0.1', - user: 'objection', - database: 'objection_test', - }, - - ...knexIdentifierMapping({ - MyId: 'id', - MyProp: 'prop', - MyAnotherProp: 'anotherProp', - }), -}); -``` - ## ValidationError ```js -const { ValidationError } = require('objection'); +import { ValidationError } from 'objection'; ``` The [ValidationError](/api/types/#class-validationerror) class. @@ -525,7 +530,7 @@ The [ValidationError](/api/types/#class-validationerror) class. ## NotFoundError ```js -const { NotFoundError } = require('objection'); +import { NotFoundError } from 'objection'; ``` The [NotFoundError](/api/types/#class-notfounderror) class. @@ -533,55 +538,55 @@ The [NotFoundError](/api/types/#class-notfounderror) class. ## DBError ```js -const { DBError } = require('objection'); +import { DBError } from 'objection'; ``` -The [DBError](https://github.com/Vincit/db-errors#dberror) from [db-errors](https://github.com/Vincit/db-errors) library. +The [DBError](https://github.com/Vincit/db-errors#dberror) class from objection's own copy of the [db-errors](https://github.com/Vincit/db-errors) library. ## ConstraintViolationError ```js -const { ConstraintViolationError } = require('objection'); +import { ConstraintViolationError } from 'objection'; ``` -The [ConstraintViolationError](https://github.com/Vincit/db-errors#constraintviolationerror) from [db-errors](https://github.com/Vincit/db-errors) library. +The [ConstraintViolationError](https://github.com/Vincit/db-errors#constraintviolationerror) class from objection's own copy of the [db-errors](https://github.com/Vincit/db-errors) library. ## UniqueViolationError ```js -const { UniqueViolationError } = require('objection'); +import { UniqueViolationError } from 'objection'; ``` -The [UniqueViolationError](https://github.com/Vincit/db-errors#uniqueviolationerror) from [db-errors](https://github.com/Vincit/db-errors) library. +The [UniqueViolationError](https://github.com/Vincit/db-errors#uniqueviolationerror) class from objection's own copy of the [db-errors](https://github.com/Vincit/db-errors) library. ## NotNullViolationError ```js -const { NotNullViolationError } = require('objection'); +import { NotNullViolationError } from 'objection'; ``` -The [NotNullViolationError](https://github.com/Vincit/db-errors#notnullviolationerror) from [db-errors](https://github.com/Vincit/db-errors) library. +The [NotNullViolationError](https://github.com/Vincit/db-errors#notnullviolationerror) class from objection's own copy of the [db-errors](https://github.com/Vincit/db-errors) library. ## ForeignKeyViolationError ```js -const { ForeignKeyViolationError } = require('objection'); +import { ForeignKeyViolationError } from 'objection'; ``` -The [ForeignKeyViolationError](https://github.com/Vincit/db-errors#foreignkeyviolationerror) from [db-errors](https://github.com/Vincit/db-errors) library. +The [ForeignKeyViolationError](https://github.com/Vincit/db-errors#foreignkeyviolationerror) class from objection's own copy of the [db-errors](https://github.com/Vincit/db-errors) library. ## CheckViolationError ```js -const { CheckViolationError } = require('objection'); +import { CheckViolationError } from 'objection'; ``` -The [CheckViolationError](https://github.com/Vincit/db-errors#checkviolationerror) from [db-errors](https://github.com/Vincit/db-errors) library. +The [CheckViolationError](https://github.com/Vincit/db-errors#checkviolationerror) class from objection's own copy of the [db-errors](https://github.com/Vincit/db-errors) library. ## DataError ```js -const { DataError } = require('objection'); +import { DataError } from 'objection'; ``` -The [DataError](https://github.com/Vincit/db-errors#dataerror) from [db-errors](https://github.com/Vincit/db-errors) library. +The [DataError](https://github.com/Vincit/db-errors#dataerror) class from objection's own copy of the [db-errors](https://github.com/Vincit/db-errors) library. diff --git a/doc/api/query-builder/eager-methods.md b/doc/api/query-builder/eager-methods.md index 8a0c32c39..4ee6b6db7 100644 --- a/doc/api/query-builder/eager-methods.md +++ b/doc/api/query-builder/eager-methods.md @@ -15,8 +15,7 @@ As mentioned, this method uses multiple queries to fetch the related objects. Ob **Limitations:** - Relations cannot be referenced in the root query because they are not joined. -- `limit`, `offset` and `page` methods apply to the whole query that fetches a relation for multiple parents when used in `modifyGraph`, `modifiers` or the `modify` of a relation mapping, not to the related items of each parent. You can use them for the root query. To limit the related items per parent, set the `limitPerParent` [graph option](/api/types/#type-graphoptions), see [below](#limiting-related-items-per-parent). `page` and `range` always apply to all parents. -- Without `limitPerParent`, `first` doesn't limit the related items per parent when applied to a relation: all matching related items are still assigned to the parents, or with `useLimitInFirst`, the whole query is limited to one item. +- `page` and `range` apply to the whole query that fetches a relation for multiple parents when used in `modifyGraph`, `modifiers` or the `modify` of a relation mapping, not to the related items of each parent. `limit`, `offset` and `first` apply per parent, see [below](#limiting-related-items-per-parent). - The related items are assigned to their parents by comparing their key values in JavaScript, which doesn't know the collation of the columns. When a key column's collation ignores differences in case, accents or trailing spaces, as the defaults of MySQL and SQL Server partly do, the database also returns related rows whose keys only match by collation, for example `'CH'` for `'ch'`, but they aren't assigned to the parent. Store the keys in the same form in both tables, or use [withGraphJoined](/api/query-builder/eager-methods.html#withgraphjoined), which matches the rows in the database. See the [eager loading](/guide/query-examples.html#eager-loading) section for more examples and [RelationExpression](/api/types/#type-relationexpression) for more info about the relation expression language. @@ -29,11 +28,11 @@ Note that while [withGraphJoined](/api/query-builder/eager-methods.html#withgrap ##### Limiting related items per parent -With the `limitPerParent` [graph option](/api/types/#type-graphoptions), `limit`, `offset` and `first` in graph modifiers and in the `modify` of relation mappings apply to the related items of each parent: +`limit`, `offset` and `first` in graph modifiers and in the `modify` of relation mappings apply to the related items of each parent: ```js const people = await Person.query() - .withGraphFetched('pets(latest)', { limitPerParent: true }) + .withGraphFetched('pets(latest)') .modifiers({ latest: (query) => query.orderBy('createdAt', 'desc').limit(3) }); @@ -42,12 +41,13 @@ const people = await Person.query() console.log(people[0].pets.length); ``` -It can be enabled for all queries of a model through [defaultGraphOptions](/api/model/static-properties.html#static-defaultgraphoptions). Note: +Note: - The relation query becomes a subquery, and its order moves into a `row_number()` window function that numbers the related items of each parent. The order can only refer to the related table's columns and to the names of the result columns, including aliases: ordering by a column that isn't selected doesn't work, and neither does ordering by a column qualified with another table's name, e.g. `orderBy('owner.name')` after `joinRelated('owner')` or a column of the join table of a many-to-many relation. The names of the selected columns need to be unique. - Relation queries with `page` or `range` keep limiting all parents together. -- MySQL supports window functions since 8.0. On older versions, objection fetches relations with a limit with one query per parent instead. Knex 3.3 and newer detect the MySQL version on the first connection. With older versions of knex, set `version: '5.7.0'` in the knex configuration. -- `withGraphJoined` ignores the option and logs a warning. +- `withGraphJoined` doesn't limit related items per parent. It ignores `limitPerParent: true` and logs a warning. + +To apply them to the whole query that fetches the relation for all parents instead, set the `limitPerParent` [graph option](/api/types/#type-graphoptions) to `false`, or in [defaultGraphOptions](/api/model/static-properties.html#static-defaultgraphoptions) for all queries of a model. ##### Arguments @@ -275,8 +275,6 @@ function withChildren(query: QueryBuilder): QueryBuilder } ``` -The narrowing requires TypeScript 5.1 or newer, as do the typings. - ## withGraphJoined() ```js @@ -351,6 +349,16 @@ const people = await Person.query() .where('persons.id', '>', 100); ``` +The `ON` clause of the joins can be modified with [modifyJoin](/api/query-builder/join-methods.html#modifyjoin), e.g. to only join the pets that are younger than their owner, while still returning the people without such pets: + +```js +const people = await Person.query() + .withGraphJoined('pets') + .modifyJoin('pets', (join, { owner, related }) => { + join.andOn(`${related}.age`, '<', `${owner}.age`); + }); +``` + ##### Mixing withGraphJoined and withGraphFetched `withGraphJoined` and [withGraphFetched](/api/query-builder/eager-methods.html#withgraphfetched) can be used in the same query. The joined relations are joined to the main query, so you can filter by them, and the fetched relations are then loaded using separate queries for the resulting models. The order of the calls doesn't matter. diff --git a/doc/api/query-builder/find-methods.md b/doc/api/query-builder/find-methods.md index 0a00b3111..e40e27cda 100644 --- a/doc/api/query-builder/find-methods.md +++ b/doc/api/query-builder/find-methods.md @@ -32,7 +32,7 @@ Composite key: const person = await Person.query().findById([1, '10']); ``` -`findById` can be used together with `patch`, `delete` and any other query method. All it does is adds the needed `where` clauses to the query. +`findById` can be used together with `patch`, `delete` and any other query method: ```js await Person.query() @@ -40,13 +40,23 @@ await Person.query() .patch({ firstName: 'Jennifer' }); ``` +It adds the id condition to the query in its own parentheses, so the other `where` clauses can't widen it, not even `orWhere`: + +```js +// where "persons"."id" = 1 and ("firstName" = 'Jennifer' or "lastName" = 'Aniston') +await Person.query() + .findById(1) + .where('firstName', 'Jennifer') + .orWhere('lastName', 'Aniston'); +``` + ## findByIds() ```js queryBuilder = queryBuilder.findByIds(ids); ``` -Finds a list of items. The order of the returned items is not guaranteed to be the same as the order of the inputs. +Finds a list of items. The order of the returned items is not guaranteed to be the same as the order of the inputs. Like [findById](#findbyid), it adds the id condition in its own parentheses. ##### Arguments @@ -1141,6 +1151,8 @@ queryBuilder = queryBuilder.whereJsonSupersetOf( Where left hand json field reference is a superset of the right hand json value or reference. +On MySQL, this uses `JSON_CONTAINS()`, which also finds objects and values in the elements of arrays, e.g. `[{ "a": 1 }]` is a superset of `{}`. SQLite has no json containment, so this method throws an error there. + ##### Arguments | Argument | Type | Description | @@ -1235,7 +1247,7 @@ queryBuilder = queryBuilder.whereJsonSubsetOf( ); ``` -Where left hand json field reference is a subset of the right hand json value or reference. +Where left hand json field reference is a subset of the right hand json value or reference. Like [whereJsonSupersetOf](/api/query-builder/find-methods.html#wherejsonsupersetof), this uses `JSON_CONTAINS()` on MySQL and throws an error on SQLite. Object and array are always their own subsets. @@ -1339,7 +1351,7 @@ queryBuilder = queryBuilder.whereJsonHasAny(fieldExpression, keys); Where any of given strings is found from json object keys. ::: tip -This doesn't work for arrays. If you want to check if an array contains an item, see [this](https://github.com/Vincit/objection.js/issues/415) and [this](https://github.com/Vincit/objection.js/issues/1133) issue. +Like the `?|` and `?&` operators of PostgreSQL, this matches the keys of objects, the string elements of arrays and string values, but not other array elements. ::: ##### Arguments @@ -1368,7 +1380,7 @@ queryBuilder = queryBuilder.whereJsonHasAll(fieldExpression, keys); Where all of given strings are found from json object keys. ::: tip -This doesn't work for arrays. If you want to check if an array contains an item, see [this](https://github.com/Vincit/objection.js/issues/415) and [this](https://github.com/Vincit/objection.js/issues/1133) issue. +Like the `?|` and `?&` operators of PostgreSQL, this matches the keys of objects, the string elements of arrays and string values, but not other array elements. ::: ##### Arguments diff --git a/doc/api/query-builder/join-methods.md b/doc/api/query-builder/join-methods.md index 72c4ae6da..2b896ba39 100644 --- a/doc/api/query-builder/join-methods.md +++ b/doc/api/query-builder/join-methods.md @@ -120,6 +120,56 @@ Left outer join version of the [joinRelated](/api/query-builder/join-methods.htm Full outer join version of the [joinRelated](/api/query-builder/join-methods.html#joinrelated) method. +## modifyJoin() + +```js +queryBuilder = queryBuilder.modifyJoin(pathExpression, modifier); +``` + +Modifies the `ON` clause of the joins that [joinRelated](/api/query-builder/join-methods.html#joinrelated), its variants and [withGraphJoined](/api/query-builder/eager-methods.html#withgraphjoined) create for the relations at `pathExpression`. Conditions on the related table alone can also be expressed with [modifiers](/api/query-builder/other-methods.html#modifiers) or the `modify` of the relation mapping, but conditions that compare the related table with its owner need the `ON` clause. + +The `modifier` gets the [knex join clause](https://knexjs.org/guide/query-builder.html#join) and the aliases of the joined tables, which are generated: `pets` for the `pets` relation, `children:pets` for the `pets` of the `children`. `owner` is the alias of the owner table, `related` that of the related table and `joinTable` that of the join table of a `ManyToManyRelation` or `HasOneThroughRelation`. For these, the related table is inner joined to the join table in parentheses, and the modifier gets the `ON` clause of this nested join, so its conditions cover both tables, also in outer joins: `left join (persons_movies as movies_join inner join movies on ...) on ...`. In right and full joins, the nested join is a right or full join too, so the related rows without join table rows are kept like without a modifier. The conditions of the modifier are grouped in parentheses, so an `orOn` can't escape the conditions that join the relation. + +The path expression matches the relations by the names they're joined under: the aliases in the relation expression, e.g. `p` for `pets as p`, and for `joinRelated` also the names of its `alias` and `aliases` options. The `aliases` option of `withGraphJoined` only changes the table aliases, so its paths use the relation names. + +`modifyJoin` doesn't affect relations fetched with [withGraphFetched](/api/query-builder/eager-methods.html#withgraphfetched), as they aren't joined. Use [modifyGraph](/api/query-builder/eager-methods.html#modifygraph) to filter them. + +##### Arguments + +| Argument | Type | Description | +| -------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------- | +| pathExpression | [RelationExpression](/api/types/#type-relationexpression) | Expression that specifies the relations whose joins to modify. | +| modifier | function(join, { owner, related, joinTable }) | Function that adds conditions to the join clause using `andOn`, `onVal`, etc. | + +##### Return value + +| Type | Description | +| ----------------------------------- | ---------------------------------- | +| [QueryBuilder](/api/query-builder/) | `this` query builder for chaining. | + +##### Examples + +Join the pets that are younger than their owner, and keep the people without such pets: + +```js +const people = await Person.query() + .select('persons.id', 'pets.name as petName') + .leftJoinRelated('pets') + .modifyJoin('pets', (join, { owner, related }) => { + join.andOn(`${related}.age`, '<', `${owner}.age`); + }); +``` + +Conditions can also compare columns with values, and use the join table of a many-to-many relation: + +```js +const people = await Person.query() + .withGraphJoined('children.movies') + .modifyJoin('children.movies', (join, { joinTable }) => { + join.andOnVal(`${joinTable}.role`, 'lead'); + }); +``` + ## join() See [knex documentation](https://knexjs.org/guide/query-builder.html#join) diff --git a/doc/api/query-builder/mutate-methods.md b/doc/api/query-builder/mutate-methods.md index 570dc35a0..8b6f1f9f0 100644 --- a/doc/api/query-builder/mutate-methods.md +++ b/doc/api/query-builder/mutate-methods.md @@ -18,13 +18,25 @@ properties - see [this recipe](/recipes/returning-tricks.html) for some examples `returning(['only', 'some', 'props'])` note that the result object will still contain the input properies **plus** the properties listed in `returning`. On other databases you can use the [insertAndFetch](/api/query-builder/mutate-methods.html#insertandfetch) method. -Batch inserts only work on Postgres because Postgres is the only database engine -that returns the identifiers of _all_ inserted rows. knex supports batch inserts on -other databases also, but you only get the id of the first (or last) inserted object -as a result. If you need batch insert on other databases you can use knex directly -through [knexQuery](/api/model/static-methods.html#static-knexquery). +Batch inserts work on Postgres, SQL Server and SQLite, which return the identifiers +of _all_ inserted rows. On MySQL, they only work if all models have their identifiers +already or the model has no `idColumn`, as MySQL only returns the id of the first +inserted row. If you need batch inserts of new rows on MySQL, you can use knex +directly through [knexQuery](/api/model/static-methods.html#static-knexquery). -Relations in the inserted objects are ignored: `insert` only inserts the columns of the model's own table. Use [insertGraph](/api/query-builder/mutate-methods.html#insertgraph) to insert related objects too. In TypeScript, the data type (`PartialModelObject`) accepts nested relation data, so this isn't reported as a type error. +On SQLite, batch inserts of new rows need SQLite 3.35 or newer for `returning`, and +fail with a syntax error near "returning" on older versions. + +Rows that don't fit into one query, e.g. more than 500 rows on SQLite or 1000 on SQL Server, and rows that +leave out columns that other rows have on SQLite, where knex would write NULL instead of +the column defaults, are inserted in several queries. These run in the transaction of the +query, if there is one. Without one, a failure leaves the rows of the earlier queries +inserted, so use a [transaction](/guide/transactions.html) where all rows must be +inserted or none. + +`insert` writes whole values, so it rejects [FieldExpressions](/api/types/#type-fieldexpression) like `'meta:a.b'` that write parts of a json column with a [ValidationError](/api/types/#class-validationerror). Use [patch](/api/query-builder/mutate-methods.html#patch) for them. Keys with a `:` that the schema declares in `properties` are normal properties and columns, not field expressions. + +Relations in the inserted objects are ignored: `insert` only inserts the columns of the model's own table. Use [insertGraph](/api/query-builder/mutate-methods.html#insertgraph) to insert related objects too. In TypeScript, the data type (`PartialModelRow`) rejects relation properties, unless the data is a model instance. ##### Arguments @@ -49,7 +61,7 @@ const jennifer = await Person.query().insert({ console.log(jennifer.id); ``` -Batch insert (Only works on Postgres): +Batch insert (on MySQL only for models with identifiers): ```js const actors = await Movie.relatedQuery('actors') @@ -66,7 +78,7 @@ console.log(actors[1].firstName); You can also give raw expressions and subqueries as values like this: ```js -const { raw } = require('objection'); +import { raw } from 'objection'; await Person.query().insert({ age: Person.query().avg('age'), @@ -121,7 +133,7 @@ queryBuilder = queryBuilder.insertGraph(graph, options); See the [section about graph inserts](/guide/query-examples.html#graph-inserts). ::: warning -[onConflict()](/api/query-builder/mutate-methods.html#onconflict), [ignore()](/api/query-builder/mutate-methods.html#ignore) and [merge()](/api/query-builder/mutate-methods.html#merge) aren't supported by `insertGraph` (or `upsertGraph`). They are currently ignored and a warning is logged, and will cause the query to throw in objection 4.0. A graph is inserted using multiple insert queries, one for each table and batch, so there is no single query to apply a conflict target to. Applying it to the root table only isn't safe either: rows skipped by `ignore()` aren't returned from the database, so the related rows would be inserted without the correct foreign keys. Insert the rows that may conflict using a separate [insert()](/api/query-builder/mutate-methods.html#insert) query and relate them in the graph, for example using `#dbRef` or the `relate` option. +[onConflict()](/api/query-builder/mutate-methods.html#onconflict), [ignore()](/api/query-builder/mutate-methods.html#ignore) and [merge()](/api/query-builder/mutate-methods.html#merge) aren't supported by `insertGraph` (or `upsertGraph`), and make the query throw an error. A graph is inserted using multiple insert queries, one for each table and batch, so there is no single query to apply a conflict target to. Applying it to the root table only isn't safe either: rows skipped by `ignore()` aren't returned from the database, so the related rows would be inserted without the correct foreign keys. Insert the rows that may conflict using a separate [insert()](/api/query-builder/mutate-methods.html#insert) query and relate them in the graph, for example using `#dbRef` or the `relate` option. ::: ##### Arguments @@ -153,7 +165,7 @@ The patch object is validated against the model's [jsonSchema](/api/model/static If validation fails the Promise is rejected with a [ValidationError](/api/types/#class-validationerror). -Relations in the patch object are ignored, only the columns of the model's own table are updated. Use [upsertGraph](/api/query-builder/mutate-methods.html#upsertgraph) to update related objects too. In TypeScript, the data type (`PartialModelObject`) accepts nested relation data, so this isn't reported as a type error. +Relations in the patch object are ignored, only the columns of the model's own table are updated. Use [upsertGraph](/api/query-builder/mutate-methods.html#upsertgraph) to update related objects too. In TypeScript, the data type (`PartialModelPatch`) rejects relation properties, unless the data is a model instance, and takes [FieldExpression](/api/types/#type-fieldexpression) keys of the model's columns, like `'address:street'`, with values of any type. The return value of the query will be the number of affected rows. If you want to update a single row and retrieve the updated row as a result, you may want to use the [patchById](/api/query-builder/mutate-methods.html#patchbyid) or [patchAndFetchById](/api/query-builder/mutate-methods.html#patchandfetchbyid) methods or _take a look at [this recipe](/recipes/returning-tricks.html) if you're using Postgres_. @@ -208,7 +220,7 @@ const numberOfAffectedRows = await Person.query() You can also give raw expressions, subqueries and `ref()` as values and [FieldExpressions](/api/types/#type-fieldexpression) as keys. Note that the values given as raw expressions, subqueries and `ref()` are not validated. Objection cannot know what their values will be at the time the validation is done. ```js -const { ref, raw } = require('objection'); +import { ref, raw } from 'objection'; await Person.query().patch({ age: Person.query().avg('age'), @@ -321,7 +333,7 @@ The update object is validated against the model's [jsonSchema](/api/model/stati Use `update` if you update the whole row with all its columns. Otherwise, using the [patch](/api/query-builder/mutate-methods.html#patch) method is recommended. When `update` method is used, the validation respects the schema's `required` properties and throws a [ValidationError](/api/types/#class-validationerror) if any of them are missing. [patch](/api/query-builder/mutate-methods.html#patch) ignores the `required` properties and only validates the ones that are found. -[FieldExpressions](/api/types/#type-fieldexpression) like `'meta:a.b'` only write parts of a json column, so they are validated like in [patch](/api/query-builder/mutate-methods.html#patch): against the schema of the nested property, without its `required` properties and default values. The json column itself counts as present and doesn't get its `default`. Keys with a `:` that the schema declares in `properties` are normal properties and columns, not field expressions. +`update` replaces whole values, so it rejects [FieldExpressions](/api/types/#type-fieldexpression) like `'meta:a.b'` that write parts of a json column with a [ValidationError](/api/types/#class-validationerror). Use [patch](/api/query-builder/mutate-methods.html#patch) for them. Keys with a `:` that the schema declares in `properties` are normal properties and columns, not field expressions. The return value of the query will be the number of affected rows. If you want to update a single row and retrieve the updated row as a result, you may want to use the [updateById](/api/query-builder/mutate-methods.html#updatebyid) or [updateAndFetchById](/api/query-builder/mutate-methods.html#updateandfetchbyid) methods or _take a look at [this recipe](/recipes/returning-tricks.html) if you're using Postgres_. @@ -350,7 +362,7 @@ console.log(numberOfAffectedRows); You can also give raw expressions, subqueries and `ref()` as values like this: ```js -const { raw, ref } = require('objection'); +import { raw, ref } from 'objection'; await Person.query().update({ firstName: raw("'Jenni' || 'fer'"), @@ -360,12 +372,12 @@ await Person.query().update({ }); ``` -Updating single value inside json column and referring attributes inside json columns (only with postgres) etc.: +Referring to attributes inside json columns (PostgreSQL, MySQL and SQLite). Use [patch](/api/query-builder/mutate-methods.html#patch) to update single values inside json columns. ```js await Person.query().update({ - lastName: ref('someJsonColumn:mother.lastName').castText(), - 'detailsJsonColumn:address.street': 'Elm street' + firstName: 'Jennifer', + lastName: ref('someJsonColumn:mother.lastName').castText() }); ``` @@ -624,12 +636,15 @@ queryBuilder = queryBuilder.relate(ids); Relate (attach) an existing item to another item through a relation. This method doesn't create a new item but only updates the foreign keys. In -the case of a many-to-many relation, creates a join row to the join table. +the case of a many-to-many relation, creates a join row to the join table. In +the case of a belongs-to-many relation, adds the keys to the arrays of the +owners. -Multiple items can be related by giving an array of identifiers: for has-many -relations on all databases, for many-to-many relations only on Postgres and SQL -Server, as it needs a batch insert. Belongs-to-one relations take one -identifier. +Multiple items can be related by giving an array of identifiers: for has-many, +belongs-to-many and many-to-many relations on all databases. On MySQL, the join +rows of many-to-many relations need to have their ids, which they have unless a +`through.modelClass` is used whose `idColumn` isn't made of the join columns. Belongs-to-one relations take +one identifier. The return value of the query is the number of affected items. On all databases except MySQL you can chain [returning('\*')](/api/query-builder/find-methods.html#returning) @@ -639,7 +654,10 @@ rows for belongs-to-one relations. MySQL has no `returning`: there, many-to-many relations return the join rows built from the input, without columns generated by the database, and the other relations still return the number of affected items. As the TypeScript typings can't tell these apart, the rows are typed as -`any`. +plain objects, `Pojo[]`. + +For belongs-to-many relations, the return value is the number of owners whose +arrays changed, with or without `returning`. ##### Arguments @@ -716,7 +734,7 @@ const numRelatedRows = await person console.log('movie 50 is now related to person 123 through `movies` relation'); ``` -Relate multiple (only works with postgres) +Relate multiple ```js const numRelatedRows = await Person.relatedQuery('movies') @@ -769,13 +787,15 @@ queryBuilder = queryBuilder.unrelate(); Remove (detach) a connection between two items. Doesn't delete the items. Only removes the connection. For ManyToMany relations this -deletes the join row from the join table. For other relation types this sets the -join columns to null. +deletes the join row from the join table. For BelongsToMany relations this removes +the keys of the found items from the arrays of the owners. For other relation types +this sets the join columns to null. Note that, unlike for `relate`, you shouldn't pass arguments for the `unrelate` method. Use `unrelate` like `delete` and filter the rows using the returned query builder. -The return value of the query is the number of affected items. +The return value of the query is the number of affected items. For BelongsToMany +relations it's the number of owners whose arrays changed. ##### Return value @@ -908,7 +928,7 @@ See [knex documentation](https://knexjs.org/guide/query-builder.html#truncate) See [knex documentation](https://knexjs.org/guide/query-builder.html#onconflict) -Not supported by [insertGraph()](/api/query-builder/mutate-methods.html#insertgraph) and [upsertGraph()](/api/query-builder/mutate-methods.html#upsertgraph), which currently ignore `onConflict()`, `ignore()` and `merge()` with a warning, and will throw in objection 4.0. +Not supported by [insertGraph()](/api/query-builder/mutate-methods.html#insertgraph) and [upsertGraph()](/api/query-builder/mutate-methods.html#upsertgraph), which throw an error when they are combined with `onConflict()`, `ignore()` or `merge()`. ##### Return value diff --git a/doc/api/query-builder/other-methods.md b/doc/api/query-builder/other-methods.md index 728e58302..6d8a87220 100644 --- a/doc/api/query-builder/other-methods.md +++ b/doc/api/query-builder/other-methods.md @@ -25,7 +25,7 @@ Some objection queries, like when `withGraphFetched` is used, actually execute m In some rare cases the knex query cannot be built synchronously. In these cases you will get a clear error message. These cases can be handled by calling the optional [initialize](/api/objection/#initialize) method once before the failing `toKnexQuery` method is called. ```js -const { initialize } = require('objection'); +import { initialize } from 'objection'; await initialize([Person, Pet, Movie, SomeOtherModelClass]); ``` @@ -608,10 +608,10 @@ queryBuilder = queryBuilder.runAfter(runAfter); Registers a function to be called when the builder is executed. -These functions are executed as the last thing before any promise handlers registered using the [then](/api/query-builder/other-methods.html#then) method. Multiple functions can be chained like [then](/api/query-builder/other-methods.html#then) methods of a promise. +These functions are executed as the last thing before any promise handlers registered using the [then](/api/query-builder/other-methods.html#then) method, after the static after hooks of the model class, like [afterFind](/api/model/static-methods.html#static-afterfind). They get the result in the shape that the query returns, e.g. the model of [first](/api/query-builder/other-methods.html#first). Multiple functions can be chained like [then](/api/query-builder/other-methods.html#then) methods of a promise. ::: tip -In the queries of relations loaded using [withGraphFetched](/api/query-builder/eager-methods.html#withgraphfetched), for example in modifiers or [modifyGraph](/api/query-builder/eager-methods.html#modifygraph), the related models are assigned to their owners before `runAfter` is called. The function can modify the related models, but its return value is ignored. To replace or transform the loaded relations, use `runAfter` on the root query instead. +In the queries of relations loaded using [withGraphFetched](/api/query-builder/eager-methods.html#withgraphfetched), for example in modifiers or [modifyGraph](/api/query-builder/eager-methods.html#modifygraph), the related models are assigned to their owners after `runAfter` is called, so a relation gets what its query returns. The function can filter or reorder the related models it gets and return them. Returning other objects throws an error. To replace or transform the loaded relations otherwise, use `runAfter` on the root query instead. ::: ##### Arguments @@ -749,34 +749,6 @@ Gets the Model subclass this builder is bound to. | -------------------- | ------------------------------------------- | | [Model](/api/model/) | The Model subclass this builder is bound to | -## skipUndefined() - -```js -queryBuilder = queryBuilder.skipUndefined(); -``` - -::: warning -`skipUndefined()` is deprecated and will be removed in objection 4.0. -::: - -If this method is called for a builder then undefined values passed to the query builder methods don't cause an exception but are ignored instead. - -For example the following query will return all `Person` rows if `req.query.firstName` is `undefined`. - -##### Return value - -| Type | Description | -| ----------------------------------- | --------------------------------- | -| [QueryBuilder](/api/query-builder/) | `this` query builder for chaining | - -##### Examples - -```js -Person.query() - .skipUndefined() - .where('firstName', req.query.firstName); -``` - ## transacting() ```js diff --git a/doc/api/types/index.md b/doc/api/types/index.md index 24abf6be9..e300b5bbd 100644 --- a/doc/api/types/index.md +++ b/doc/api/types/index.md @@ -9,7 +9,7 @@ This page contains the documentation of all other types and classes than [Model] | Property | Type | Description | | ------------ | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| relation | function | The relation type. One of `Model.BelongsToOneRelation`, `Model.HasOneRelation`, `Model.HasManyRelation`, `Model.ManyToManyRelation` and `Model.HasOneThroughRelation`. | +| relation | function | The relation type. One of `Model.BelongsToOneRelation`, `Model.HasOneRelation`, `Model.HasManyRelation`, `Model.ManyToManyRelation`, `Model.HasOneThroughRelation` and `Model.BelongsToManyRelation`. | | modelClass | [Model](/api/model/)
string | Constructor of the related model class, an absolute path to a module that exports one or a path relative to [modelPaths](/api/model/static-properties.html#static-modelpaths) that exports a model class. | | join | [RelationJoin](/api/types/#type-relationjoin) | Describes how the models are related to each other. See [RelationJoin](/api/types/#type-relationjoin). | | modify | function([QueryBuilder](/api/query-builder/))
string
object | Optional modifier for the relation query. If specified as a function, it will be called each time before fetching the relation. If specified as a string, modifier with specified name will be applied each time when fetching the relation. If specified as an object, it will be used as an additional query parameter - e. g. passing {name: 'Jenny'} would additionally narrow fetched rows to the ones with the name 'Jenny'. | @@ -81,8 +81,8 @@ Such relations can be fetched, and the related rows can be updated, patched and | separator | string | Separator between relations in nested `withGraphJoined` query. Defaults to `:`. Dot (`.`) cannot be used at the moment because of the way knex parses the identifiers. | | aliases | Object | Aliases for relations in a `withGraphJoined` query. Defaults to an empty object. | | joinOperation | string | Which join type to use `['leftJoin', 'innerJoin', 'rightJoin', ...]` or any other knex join method name. Defaults to `leftJoin`. Only applies to the relations of the `withGraphJoined` call it's passed to, and is ignored with a warning for fetched relations. | -| maxBatchSize | integer | For how many parents should a relation be fetched using a single query at a time. If you set this to `1` then a separate query is used for each parent to fetch a relation. For example if you want to fetch pets for 5 persons, you get five queries (one for each person). Setting this to `1` will allow you to use stuff like `limit` and aggregate functions in `modifyGraph` and other graph modifiers. This can be used to replace the `naiveEager` objection 1.x had. | -| limitPerParent | boolean | If `true`, `limit`, `offset` and `first` in graph modifiers and in the `modify` of relation mappings apply to the related items of each parent instead of to all of them, e.g. `limit(3)` fetches up to three pets per person. Only applies to `withGraphFetched`. Defaults to `false`. See [limiting related items per parent](/api/query-builder/eager-methods.html#limiting-related-items-per-parent). | +| maxBatchSize | integer | For how many parents should a relation be fetched using a single query at a time. If you set this to `1` then a separate query is used for each parent to fetch a relation. For example if you want to fetch pets for 5 persons, you get five queries (one for each person). Setting this to `1` will allow you to use stuff like `page`, `range` and aggregate functions per parent in `modifyGraph` and other graph modifiers. `limit`, `offset` and `first` apply per parent anyway, see `limitPerParent`. This can be used to replace the `naiveEager` objection 1.x had. | +| limitPerParent | boolean | If `true`, `limit`, `offset` and `first` in graph modifiers and in the `modify` of relation mappings apply to the related items of each parent instead of to all of them, e.g. `limit(3)` fetches up to three pets per person. If `false`, they apply to the whole query that fetches the relation for all parents. Only applies to `withGraphFetched`. Defaults to `true`. See [limiting related items per parent](/api/query-builder/eager-methods.html#limiting-related-items-per-parent). | ## `type` UpsertGraphOptions @@ -92,23 +92,29 @@ Such relations can be fetched, and the related rows can be updated, patched and | unrelate | boolean
string
string[] | If `true`, relations are unrelated instead of deleted. Unrelate functionality can be enabled for a subset of relations of the graph by providing a list of relation paths or a relation expression. See the examples [here](/guide/query-examples.html#graph-upserts). | | insertMissing | boolean
string
string[] | If `true`, models that have identifiers _and_ are not found in the database, are inserted. By default this is `false` and an error is thrown. This functionality can be enabled for a subset of relations of the graph by providing a list of relation paths or a relation expression. See the examples [here](/guide/query-examples.html#graph-upserts). | | update | boolean
string
string[] | If `true`, update operations are performed instead of patch when altering existing models, affecting the way the data is validated. With update operations, all required fields need to be present in the data provided. This functionality can be enabled for a subset of relations of the graph by providing a list of relation paths or a relation expression. See the examples [here](/guide/query-examples.html#graph-upserts). | -| noInsert | boolean
string
string[] | If `true`, no inserts are performed. Inserts can be disabled for a subset of relations of the graph by providing a list of relation paths or a relation expression. Nothing is inserted or related below the models that aren't inserted, unless `#ref` references point there. See the examples [here](/guide/query-examples.html#graph-upserts). | +| noInsert | boolean
string
string[] | If `true`, no inserts are performed. Inserts can be disabled for a subset of relations of the graph by providing a list of relation paths or a relation expression. Nothing is inserted or related below the models that aren't inserted, unless `#ref` references point there. A skipped model in a to-one relation keeps the current model. See the examples [here](/guide/query-examples.html#graph-upserts). | | noUpdate | boolean
string
string[] | If `true`, no updates are performed. Updates can be disabled for a subset of relations of the graph by providing a list of relation paths or a relation expression. See the examples [here](/guide/query-examples.html#graph-upserts). | | noDelete | boolean
string
string[] | If `true`, no deletes are performed. Deletes can be disabled for a subset of relations of the graph by providing a list of relation paths or a relation expression. See the examples [here](/guide/query-examples.html#graph-upserts). | -| noRelate | boolean
string
string[] | If `true`, no relates are performed. Relate operations can be disabled for a subset of relations of the graph by providing a list of relation paths or a relation expression. See the examples [here](/guide/query-examples.html#graph-upserts). | +| noRelate | boolean
string
string[] | If `true`, no relates are performed. Relate operations can be disabled for a subset of relations of the graph by providing a list of relation paths or a relation expression. A skipped model in a to-one relation keeps the current model. See the examples [here](/guide/query-examples.html#graph-upserts). | | noUnrelate | boolean
string
string[] | If `true`, no unrelate operations are performed. Unrelate operations can be disabled for a subset of relations of the graph by providing a list of relation paths or a relation expression. See the examples [here](/guide/query-examples.html#graph-upserts). | | allowRefs | boolean | This needs to be `true` if you want to use `#ref` in your graphs. See [this section](/guide/query-examples.html#graph-inserts) for `#ref` usage examples. | | allowMarkers | boolean | This needs to be `true` if you want to use the `#unrelate` and `#delete` special properties in your graphs. They let the graph data unrelate or delete related models even if `noUnrelate` or `noDelete` is set, so don't enable this for untrusted input. See [this section](/guide/query-examples.html#graph-upserts) for details. | -The options that accept relation paths take either an array of relation paths like `['pets', 'movies.reviews']`, or a relation expression string in the same syntax as [withGraphFetched()](/api/query-builder/eager-methods.html#withgraphfetched), like `'[pets, movies.reviews]'`. A relation expression matches the same relations as the array of the relation paths it names: only the relations at the end of each path, not the relations along the way. So `'[pets, movies.reviews]'` matches `pets` and `movies.reviews`, but not `movies`, and `relate: 'movies.actors'` relates the actors of movies, but not the movies themselves. An expression can't name a relation together with relations below it, since `'[movies, movies.reviews]'` names `movies` twice and only the last one counts. Use an array like `['movies', 'movies.reviews']` for that. For the same reason, an expression can't repeat a relation: `'[movies.reviews, movies.actors]'` drops one of them, so use `'movies.[reviews, actors]'` instead. `'*'` matches all relations at any depth, `'movies.*'` all relations below `movies`, and recursive expressions like `'parent.^'` match every level of the recursion. Aliases (`movies as m`), `*` and `^` anywhere but at the end of a relation path (`movies.[reviews, ^]`) and empty expressions are rejected. The root models have no relation path and are never matched by a relation expression, so for example `noInsert: '*'` inserts new root models, but no new related models. +The options that accept relation paths take either an array of relation paths like `['pets', 'movies.reviews']`, or a relation expression string in the same syntax as [withGraphFetched()](/api/query-builder/eager-methods.html#withgraphfetched), like `'[pets, movies.reviews]'`. A relation expression matches the same relations as the array of the relation paths it names: only the relations at the end of each path, not the relations along the way. So `'[pets, movies.reviews]'` matches `pets` and `movies.reviews`, but not `movies`, and `relate: 'movies.actors'` relates the actors of movies, but not the movies themselves. An expression can't name a relation twice, so `'[movies, movies.reviews]'` throws an error. Use an array like `['movies', 'movies.reviews']` to match a relation together with relations below it. Instead of `'[movies.reviews, movies.actors]'`, which also throws, use `'movies.[reviews, actors]'`. `'*'` matches all relations at any depth, `'movies.*'` all relations below `movies`, and recursive expressions like `'parent.^'` match every level of the recursion. Aliases (`movies as m`) and empty expressions are rejected. The root models have no relation path and are never matched by a relation expression, so for example `noInsert: '*'` inserts new root models, but no new related models. + +`upsertGraph()` throws an error for unknown options, like a misspelled `noInset`. ## `type` InsertGraphOptions | Property | Type | Description | | --------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | relate | boolean
string
string[] | If `true`, models with an `id` are related instead of inserted. Relate functionality can be enabled for a subset of relations of the graph by providing a list of relation paths or a relation expression. See the examples [here](/guide/query-examples.html#graph-inserts). | +| noRelate | boolean
string
string[] | If `true`, no relates are performed, and the models that would be related are left out. Relate operations can be disabled for a subset of relations of the graph by providing a list of relation paths or a relation expression. | +| noInsert | boolean
string
string[] | If `true`, no inserts are performed. Inserts can be disabled for a subset of relations of the graph by providing a list of relation paths or a relation expression, and the models of these relations are left out. | | allowRefs | boolean | This needs to be `true` if you want to use `#ref` in your graphs. See [this section](/guide/query-examples.html#graph-inserts) for `#ref` usage examples. | +`insertGraph()` throws an error for the other options of `upsertGraph()`, which have no effect on an insert, and for unknown options. + ## `type` FetchGraphOptions | Property | Type | Description | @@ -145,8 +151,8 @@ The options that accept relation paths take either an array of relation paths li | transaction | knex
Transaction | If the query being executed has a transaction, this property will contain it. Otherwise this holds the knex instance installed for the query. Either way, this can and should be passed to any queries executed in the static hooks. | | context | object | The context of the query. See [context](/api/query-builder/other-methods.html#context). | | relation | [Relation](#class-relation) | If the query is for a relation, this property holds the [Relation](#class-relation) object. For example when you call `person.$relatedQuery('pets)` or `Person.relatedQuery('movies')` the `relation` will be a relation object for pets and movies relation of `Person` respectively. | -| cancelQuery | function(any) | Cancels the query being executed. You can pass an arugment for the function and that value will be the result of the query. | -| result | any[] | The result of the query. Only available in `after*` hooks. | +| cancelQuery | function(any) | Cancels the query being executed. You can pass an argument for the function, and that value is used as the result of the query, as if the database had returned it: rows for a find query, a row or rows that are merged into the input models of an insert, or a number for updates and deletes. It goes through the same steps as the result of the database, e.g. `first()` and the `after*` hooks. | +| result | any | The result of the query, in the shape that the query returns: a model, an array of models, a number or `undefined`. See the [after hooks](/api/model/static-methods.html#static-afterfind) for the details of each query. `undefined` in `before*` hooks. | ## `type` FieldExpression @@ -174,12 +180,12 @@ Caveats when using special characters in keys: 5. Empty keys `objectColumn:[""]` or `objectColumn:['']` Column `{ "" : "I was referred" }` 6. Keys containing dots, square brackets, single quotes and double quotes in one json key is not currently supported -Keys with a `:` in the objects passed to [patch](/api/query-builder/mutate-methods.html#patch) and [update](/api/query-builder/mutate-methods.html#update) are field expressions that write into json columns, e.g. `patch({ 'jsonColumn:some.key': value })`, unless the model's [jsonSchema](/api/model/static-properties.html#static-jsonschema) declares them in its `properties`. Declared keys like `'ns:name'` are normal properties and columns. The query only knows the properties of `jsonSchema`, not those added by [$beforeValidate](/api/model/instance-methods.html#beforevalidate). +Keys with a `:` in the objects passed to [patch](/api/query-builder/mutate-methods.html#patch) are field expressions that write into json columns, e.g. `patch({ 'jsonColumn:some.key': value })`, unless the model's [jsonSchema](/api/model/static-properties.html#static-jsonschema) declares them in its `properties`. Declared keys like `'ns:name'` are normal properties and columns, and so are keys that don't parse as field expressions, like `'ns:'`. [insert](/api/query-builder/mutate-methods.html#insert) and [update](/api/query-builder/mutate-methods.html#update) write whole values and reject field expression keys with a [ValidationError](/api/types/#class-validationerror). The query only knows the properties of `jsonSchema`, not those added by [$beforeValidate](/api/model/instance-methods.html#beforevalidate). There are some special methods that accept `FieldExpression` strings directly, like [whereJsonSupersetOf](/api/query-builder/find-methods.html#wherejsonsupersetof) but you can use `FieldExpressions` anywhere with [ref](/api/objection/#ref). Here's an example: ```js -const { ref } = require('objection'); +import { ref } from 'objection'; await Person.query() .select([ @@ -196,7 +202,7 @@ await Person.query() .where('age', '>', ref('someTable.ageLimit')); ``` -In the above example, we assume `persons` table has a column named `jsonColumn` of type `jsonb` (only works on postgres). +In the above example, we assume `persons` table has a column named `jsonColumn` of type `jsonb` on PostgreSQL or `json` on MySQL and SQLite. On MySQL and SQLite, `[1]` only refers to array elements and `.1` only to object keys, see the [JSON queries recipe](/recipes/json-queries.html). ## `type` RelationExpression @@ -222,8 +228,12 @@ The \* token must be used with caution or you will end up fetching your entire d Expression `parent.^` is equivalent to `parent.parent.parent.parent...` up to the point a relation no longer has results for the `parent` relation. The recursion can be limited to certain depth by giving the depth after the `^` character. For example `parent.^3` is equal to `parent.parent.parent`. +`*` and `^` end a relation path: nothing can follow them, and they take no modifiers or aliases. `*` can also be the whole expression. Expressions like `parent.^.pets`, `*.pets` or `[children, *]` throw an error. + Relations can be aliased using the `as` keyword. +A relation can only be named once at each level of an expression. `[children.movies, children.pets]` and `[children, children.movies]` throw an error, use `children.[movies, pets]` and `children.movies` instead. + For example the expression `children.[movies.actors.[pets, children], pets]` represents a tree: ``` @@ -403,7 +413,7 @@ Call this method to rollback the transaction. This only needs to be called if yo ## `class` ValidationError ```js -const { ValidationError } = require('objection'); +import { ValidationError } from 'objection'; throw new ValidationError({ type, message, data }); ``` @@ -456,7 +466,7 @@ If `type` is `"ModelValidation"` then `data` object should follow this pattern: ## `class` NotFoundError ```js -const { NotFoundError } = require('objection'); +import { NotFoundError } from 'objection'; throw new NotFoundError(data); ``` @@ -599,6 +609,8 @@ An instance of this is returned from the [ref](/api/objection/#ref) helper funct ### Instance Methods +The casts use the types of PostgreSQL. On MySQL, which can only cast to a few types, they are mapped to `CHAR` (`text`), `SIGNED` (`integer`, `bigint` and `boolean`), `DOUBLE` (`float`), `FLOAT` (`real`) and `DECIMAL(65,30)` (`decimal`). SQLite accepts the type names as they are. + #### castText() Cast reference to sql type `text`. @@ -629,14 +641,13 @@ Cast reference to sql type `boolean`. #### castTo() -Give custom type to which referenced value is cast to. +Give custom type to which referenced value is cast to. The types of the casts above are mapped on MySQL, others are used as they are. `.castTo('mytype') --> CAST(?? as mytype)` #### castJson() -In addition to other casts wrap reference to_jsonb() function so that final value -reference will be json type. +In addition to other casts, convert the reference to a json value: `to_jsonb()` on PostgreSQL, `JSON_EXTRACT(JSON_ARRAY(??), '$[0]')` on MySQL and `json_quote()` on SQLite. SQLite can't tell json columns from text columns, so a reference to a whole column needs a cast before `castJson()` there, e.g. `ref('name').castText().castJson()`, and throws without one. #### as() @@ -691,8 +702,7 @@ Give custom type to which referenced value is cast to. #### castJson() -Converts the value to json (jsonb in case of postgresql). The default -cast type for object values. +Converts the value to json: `jsonb` on PostgreSQL, `JSON` on MySQL and `json()` on SQLite. Object and array values are json by default, which is `CAST(? AS JSON)` on MySQL and `json(?)` on SQLite, while PostgreSQL infers the type from the context. #### asArray() @@ -735,7 +745,7 @@ You should use this instead of inserting the alias to the SQL to give objection ## `class` Validator ```js -const { Validator } = require('objection'); +import { Validator } from 'objection'; ``` Abstract class from which model validators must be inherited. See the example for explanation. Also check out the [createValidator](/api/model/static-methods.html#static-createvalidator) method. @@ -743,7 +753,7 @@ Abstract class from which model validators must be inherited. See the example fo #### Examples ```js -const { Validator } = require('objection'); +import { Validator } from 'objection'; class MyCustomValidator extends Validator { validate(args) { @@ -785,7 +795,7 @@ class MyCustomValidator extends Validator { } } -const { Model } = require('objection'); +import { Model } from 'objection'; // Override the `createValidator` method of a `Model` to use the // custom validator. @@ -799,7 +809,7 @@ class BaseModel extends Model { ## `class` AjvValidator ```js -const { AjvValidator } = require('objection'); +import { AjvValidator } from 'objection'; ``` The default [Ajv](https://github.com/epoberezkin/ajv) based json schema @@ -809,7 +819,7 @@ method of [Model](/api/model/) like in the example to modify the validator. #### Examples ```js -const { Model, AjvValidator } = require('objection'); +import { Model, AjvValidator } from 'objection'; class BaseModel extends Model { static createValidator() { diff --git a/doc/guide/contributing.md b/doc/guide/contributing.md index ac778e46c..d883dbdc3 100644 --- a/doc/guide/contributing.md +++ b/doc/guide/contributing.md @@ -39,39 +39,37 @@ For a pull request to get merged it needs to have the following things: git clone git@github.com:/objection.git ``` -3. **Run `npm ci` at the root of the repo** +3. **Run `pnpm install` at the root of the repo** -4. **Run `npm run docs:dev` and go to `http://localhost:5173/objection/` to see the generated documentation site when you change the markdown files in the `doc` folder.** +4. **Run `pnpm docs:dev` and go to `http://localhost:5173/objection/` to see the generated documentation site when you change the markdown files in the `doc` folder.** ## Running the tests -[Docker](https://www.docker.com/) is optional, but the easiest way to run the tests against all supported databases.: +You need Node.js `^20.19.0 || >=22.12.0` and [pnpm](https://pnpm.io/). [Docker](https://www.docker.com/) is optional, but the easiest way to run the tests against all supported databases: ```bash -npm ci -npm run db:up # Start the PostgreSQL and MySQL test databases. -npm test -npm run db:down # Stop the test databases and delete their data. +pnpm install +pnpm db:up # Start the PostgreSQL and MySQL test databases. +pnpm test +pnpm db:down # Stop the test databases and delete their data. ``` Docker only runs the two database servers, PostgreSQL 17 and MySQL 8.4, as defined in `docker-compose.yml`, which needs Docker Compose 2.23.1 or newer. The tests themselves run on your machine with your Node.js, and SQLite runs inside the test process. The containers create the `objection` user and the `objection_test` database on their own, so there is nothing else to set up. -`npm run db:up` returns once both databases accept connections. They listen on the ports 55432 (PostgreSQL) and 33306 (MySQL), so they don't collide with databases that you may have installed locally. The data isn't kept: `npm run db:down` removes the containers together with their data, and the next `npm run db:up` starts from scratch. +`pnpm db:up` returns once both databases accept connections. They listen on the ports 55432 (PostgreSQL) and 33306 (MySQL), so they don't collide with databases that you may have installed locally. The data isn't kept: `pnpm db:down` removes the containers together with their data, and the next `pnpm db:up` starts from scratch. -CI also runs the tests on MySQL 5.7. To do that locally, add its override file: `COMPOSE_FILE=docker-compose.yml:docker-compose.mysql57.yml npm run db:up`. - -CI also runs the tests on SQL Server 2022. Its image is large and runs emulated on arm64, so `npm run db:up` doesn't start it, and `npm test` only runs its tests when they are requested: +CI also runs the tests on SQL Server 2022. Its image is large and runs emulated on arm64, so `pnpm db:up` doesn't start it, and `pnpm test` only runs its tests when they are requested: ```bash -npm run db:up -- mssql # Start SQL Server, on the port 51433. -DATABASES=mssql npm run test:fast +pnpm db:up mssql # Start SQL Server, on the port 51433. +DATABASES=mssql pnpm test:fast ``` You can run the tests on a subset of databases by setting the `DATABASES` env variable. SQLite needs no setup, so this works without Docker: ```bash -DATABASES=sqlite3 npm test -DATABASES=postgres npm test +DATABASES=sqlite3 pnpm test +DATABASES=postgres pnpm test ``` To use your own databases instead, set the `OBJECTION_TEST_POSTGRES_*`, `OBJECTION_TEST_MYSQL_*` and `OBJECTION_TEST_MSSQL_*` env variables. The PostgreSQL user needs to own the database or be a superuser, as the tests create schemas in it. The MySQL user needs to be able to create databases and to use the `mysql_native_password` authentication plugin. The SQL Server user needs to be able to create tables and sequences. @@ -86,10 +84,10 @@ To use your own databases instead, set the `OBJECTION_TEST_POSTGRES_*`, `OBJECTI The SQLite tests use a new database file in the system's temporary directory for each run, which is deleted afterwards. Set `OBJECTION_TEST_SQLITE_FILE` to use a file of your choice, which is kept. -The tests are run with [mocha](https://mochajs.org/). `npm test` also lints the code and checks the typings. Use `npm run test:fast` to only run the tests, stopping at the first failure. +The tests are run with [vitest](https://vitest.dev/). `pnpm test` also lints the code and checks the typings. Use `pnpm test:fast` to only run the tests, stopping at the first failure, or `pnpm test:watch` to rerun them whenever files change. SQL Server can't insert explicit values into auto-incrementing (identity) columns. Tests that insert rows with explicit ids create their tables with `session.createTable()` instead of `knex.schema.createTable()`, and their id columns with the `increments()` it passes to the callback. -[prettier](https://prettier.io/) is used to format the code. Remember to run `npm run prettier` before committing code. +[prettier](https://prettier.io/) is used to format the code. Remember to run `pnpm prettier` before committing code. -The parsers of relation expressions and JSON field expressions in `lib/queryBuilder/parsers` are generated from the `.pegjs` grammars next to them with [Peggy](https://peggyjs.org/). After changing a grammar, run `npm run build:parsers` (Peggy 5 needs Node 20 or newer) and commit the generated files with it. CI checks that they match the grammars. +The parsers of relation expressions and JSON field expressions in `lib/queryBuilder/parsers` are generated from the `.pegjs` grammars next to them with [Peggy](https://peggyjs.org/). After changing a grammar, run `pnpm build:parsers` (Peggy 5 needs Node 20 or newer) and commit the generated files with it. CI checks that they match the grammars. diff --git a/doc/guide/documents.md b/doc/guide/documents.md index c3a2d9d54..94ab9012b 100644 --- a/doc/guide/documents.md +++ b/doc/guide/documents.md @@ -1,6 +1,6 @@ # Documents -Objection.js makes it easy to store non-flat documents as table rows. All properties of a model that are marked as objects or arrays in the model's [jsonSchema](/api/model/static-properties.html#static-jsonschema) are automatically converted to JSON strings in the database and back to objects when read from the database. The database columns for the object properties can be normal text columns. Postgresql has the `json` and `jsonb` data types that can be used instead for better performance and possibility to [query the documents](https://www.postgresql.org/docs/9.4/static/functions-json.html). If you don't want to use [jsonSchema](/api/model/static-properties.html#static-jsonschema) you can mark properties as objects using the [jsonAttributes](/api/model/static-properties.html#static-jsonattributes) +Objection.js makes it easy to store non-flat documents as table rows. All properties of a model that are marked as objects or arrays in the model's [jsonSchema](/api/model/static-properties.html#static-jsonschema) are automatically converted to JSON strings in the database and back to objects when read from the database. The database columns for the object properties can be normal text columns. Postgresql has the `json` and `jsonb` data types that can be used instead for better performance and possibility to [query the documents](https://www.postgresql.org/docs/9.4/static/functions-json.html). Its drivers return these columns parsed, so objection doesn't parse JSON in Postgresql `text` columns unless the model sets [jsonColumnFormat](/api/model/static-properties.html#static-jsoncolumnformat) to `'text'`. If you don't want to use [jsonSchema](/api/model/static-properties.html#static-jsonschema) you can mark properties as objects using the [jsonAttributes](/api/model/static-properties.html#static-jsonattributes) Model property. The `address` property of the Person model is defined as an object in the [Person.jsonSchema](/api/model/static-properties.html#static-jsonschema): diff --git a/doc/guide/getting-started.md b/doc/guide/getting-started.md index b329b33a3..db25c25dd 100644 --- a/doc/guide/getting-started.md +++ b/doc/guide/getting-started.md @@ -7,7 +7,7 @@ The next step is to create some migrations and models and start using objection. - [The minimal example](https://github.com/ditojs/objection/tree/main/examples/minimal) contains the bare minimum for you to start testing out things with objection. ```bash -git clone git@github.com:Vincit/objection.js.git objection +git clone git@github.com:ditojs/objection.git objection cd objection/examples/minimal npm install npm start @@ -16,7 +16,7 @@ npm start - [The koa example project](https://github.com/ditojs/objection/tree/main/examples/koa) is a simple [koa](https://koajs.com) server. The `client.js` file contains a bunch of http requests for you to start playing with the REST API. ```bash -git clone git@github.com:Vincit/objection.js.git objection +git clone git@github.com:ditojs/objection.git objection cd objection/examples/koa npm install npm start @@ -26,14 +26,14 @@ We also have a [typescript version](https://github.com/ditojs/objection/tree/mai Also check out our [API reference](/api/query-builder/) and [recipe book](/recipes/raw-queries.html). -If installing the example project seems like too much work, here is a simple standalone example. Just copy this into a file and run it: +If installing the example project seems like too much work, here is a simple standalone example. Just copy this into a file called `example.mjs` and run it with `node example.mjs`: ```js // run the following command to install: // npm install objection knex sqlite3 -const { Model } = require('objection'); -const Knex = require('knex'); +import { Model } from 'objection'; +import Knex from 'knex'; // Initialize knex. const knex = Knex({ diff --git a/doc/guide/hooks.md b/doc/guide/hooks.md index 9d08a863c..ebd6c54e2 100644 --- a/doc/guide/hooks.md +++ b/doc/guide/hooks.md @@ -60,9 +60,7 @@ await arnold.$query().delete(); // This will NOT execute the delete hooks because we don't have model // instance for Arnold. -await Person.query() - .delete() - .where('name', 'Arnold'); +await Person.query().delete().where('name', 'Arnold'); ``` Objection could fetch the items from the database and call the hooks for those model instances, but that would lead to unpredicable performance, and that's something objection tries to avoid. If you need to do something like this, you can do it using [the static query hooks](#static-query-hooks). @@ -77,9 +75,7 @@ class Person extends Model { } // This will print 'Jennifer' -await Person.query() - .patch({ firstName: 'Jennifer' }) - .where('id', 1); +await Person.query().patch({ firstName: 'Jennifer' }).where('id', 1); const arnold = await Person.query().findOne({ name: 'Arnold' }); @@ -225,14 +221,30 @@ await Person.relatedQuery('pets') // relation: pets ``` -In `afterDelete`, `afterUpdate`, `afterInsert` and `afterFind` hooks, the `result` property of the input argument contains the result of the query. In `afterInsert`, `result` contains the inserted model instances with only the values returned by the database merged in, even for `insertAndFetch`, which fetches the rows after the hook has run. See [afterInsert](/api/model/static-methods.html#static-afterinsert) for details. You can change the result by returning a non-undefined value from the hook: +In `afterDelete`, `afterUpdate`, `afterInsert` and `afterFind` hooks, the `result` property of the input argument contains what the query returns: an array of models, a single model, e.g. with `first()`, `findById()`, `insert()` of a single model or `patchAndFetchById()`, `undefined` if nothing was found, or the number of updated or deleted rows. The hooks run as the last step of the query, after `first()` and the fetch of `insertAndFetch()` and `patchAndFetch()`, and before the [runAfter](/api/query-builder/other-methods.html#runafter) callbacks. See the [after hooks](/api/model/static-methods.html#static-afterfind) for the details of each query. In `before*` hooks, `result` is `undefined`. + +To handle single models and arrays the same way, turn `result` into an array with [asArray](/api/objection/#asarray): + +```js +import { Model, asArray } from 'objection'; + +class Person extends Model { + static afterFind({ result }) { + for (const person of asArray(result)) { + person.fullName = `${person.firstName} ${person.lastName}`; + } + } +} +``` + +You can change the result by returning a non-undefined value from the hook: ```js class Person extends Model { static afterFind({ result }) { return { result, - success: true + success: true, }; } } @@ -243,6 +255,17 @@ console.log(result.success); console.log(result.result.length); ``` +[relate](/api/query-builder/mutate-methods.html#relate) and [unrelate](/api/query-builder/mutate-methods.html#unrelate) have no hooks of their own. They run a query in their place, and the hooks of that query get the `relation` and its owners as `items`: + +| Relation | Query | Hooks | +| --------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `HasManyRelation`, `HasOneRelation` | Update of the related rows | `beforeUpdate` and `afterUpdate` of the related model class | +| `BelongsToOneRelation` | Update of the owner rows | `beforeUpdate` and `afterUpdate` of the owner model class | +| `BelongsToManyRelation` | Find and update of the owner rows | `beforeFind` and `afterFind` of the owner model class, and `beforeUpdate` and `afterUpdate` of the owner model class for the owners whose keys change. `unrelate()` also runs the `beforeFind` and `afterFind` hooks of the related model class | +| `ManyToManyRelation`, `HasOneThroughRelation` | Insert or delete of join rows | `beforeInsert` and `afterInsert`, or `beforeDelete` and `afterDelete` of the join table's `through.modelClass` | + +The outer queries of [insertGraph](/api/query-builder/mutate-methods.html#insertgraph) and [upsertGraph](/api/query-builder/mutate-methods.html#upsertgraph) call no static hooks. The queries that they run for each model class of the graph call them instead. + ::: tip Note! diff --git a/doc/guide/installation.md b/doc/guide/installation.md index cec6d6325..001e1051f 100644 --- a/doc/guide/installation.md +++ b/doc/guide/installation.md @@ -7,6 +7,8 @@ npm install objection knex yarn add objection knex ``` +Objection is an ES module and needs node `^20.19.0 || >=22.12.0`: node 20.19 or newer within node 20, or node 22.12 or newer. The examples in this documentation use `import`, but CommonJS code can still load objection with `require('objection')`, see the [migration notes](/release-notes/migration.html#objection-is-an-es-module-package). + You also need to install one of the following depending on the database you want to use: ```bash @@ -24,4 +26,4 @@ npm install objection@next ## TypeScript -Objection ships with its own TypeScript typings. They require TypeScript 5.1 or newer. +Objection ships with its own TypeScript typings. They require TypeScript 5.8 or newer: 5.8, 6 and 7 are supported. diff --git a/doc/guide/models.md b/doc/guide/models.md index cd53aec4f..ba5e0eafd 100644 --- a/doc/guide/models.md +++ b/doc/guide/models.md @@ -15,7 +15,7 @@ Note that in addition to `idColumn`, you don't define properties, indexes or any A working model with minimal amount of code: ```js -const { Model } = require('objection'); +import { Model } from 'objection'; class MinimalModel extends Model { static get tableName() { @@ -23,13 +23,15 @@ class MinimalModel extends Model { } } -module.exports = MinimalModel; +export default MinimalModel; ``` Model with custom methods, json schema validation and relations. This model is used in the examples: ```js -const { Model } = require('objection'); +import { Model } from 'objection'; +import Animal from './Animal.js'; +import Movie from './Movie.js'; class Person extends Model { // Table name is the only required property. @@ -89,10 +91,8 @@ class Person extends Model { // This object defines the relations to other models. static get relationMappings() { - // Importing models here is one way to avoid require loops. - const Animal = require('./Animal'); - const Movie = require('./Movie'); - + // Accessing the imported models in a getter is one way to + // avoid problems with circular imports. return { pets: { relation: Model.HasManyRelation, @@ -197,9 +197,79 @@ class Person extends Model { } ``` +## Deriving model properties from the JSON schema in TypeScript + +If a model has a `jsonSchema`, its properties don't need to be declared twice. Declare the schema `as const` and merge `FromSchema` of it into the class with an interface of the same name: + +```ts +import { FromSchema, Model } from 'objection'; + +class Person extends Model { + static tableName = 'persons'; + + static jsonSchema = { + type: 'object', + required: ['firstName'], + properties: { + id: { type: 'integer' }, + firstName: { type: 'string', minLength: 1 }, + lastName: { type: ['string', 'null'] }, + role: { enum: ['admin', 'user'] } + } + } as const; + + // Relations are declared by hand. + pets?: Animal[]; +} + +interface Person extends FromSchema {} + +const person = await Person.query().findById(1).throwIfNotFound(); +person.firstName; // string +person.lastName; // string | null | undefined +person.role; // 'admin' | 'user' | undefined +``` + +`FromSchema` understands `type` (also as an array like `['string', 'null']`), `required`, `nullable`, `enum`, `const`, `items` (also as a tuple), nested `properties`, `anyOf` and `oneOf`. Properties that aren't `required` are optional, and formats like `'date-time'` stay strings, as in JSON. Without `as const`, TypeScript widens the schema and `FromSchema` can't read it. + +`as const` gives `static jsonSchema` the literal type of the schema, so a subclass can't replace it with a schema that requires more properties. For models that subclasses extend, declare the schema as a constant and give the static the general type `ReadonlyJSONSchema`: + +```ts +import { FromSchema, Model, ReadonlyJSONSchema } from 'objection'; + +const entrySchema = { + type: 'object', + required: ['id'], + properties: { + id: { type: 'integer' } + } +} as const; + +class Entry extends Model { + static jsonSchema: ReadonlyJSONSchema = entrySchema; +} + +interface Entry extends FromSchema {} + +const noteSchema = { + type: 'object', + required: [...entrySchema.required, 'text'], + properties: { + ...entrySchema.properties, + text: { type: 'string' } + } +} as const; + +class Note extends Entry { + static jsonSchema = noteSchema; +} + +interface Note extends FromSchema {} +``` + ## Checking relation mappings in TypeScript -The relation properties of a model have to be declared by hand, and TypeScript doesn't check them against `relationMappings` by default. Add `satisfies TypedRelationMappings` to the mappings to check that each key is a relation property of `Person`, that the relation type fits the property (`BelongsToOneRelation`, `HasOneRelation` or `HasOneThroughRelation` for a single model, `HasManyRelation` or `ManyToManyRelation` for an array), and that `modelClass` matches the property's model type: +The relation properties of a model have to be declared by hand, and TypeScript doesn't check them against `relationMappings` by default. Add `satisfies TypedRelationMappings` to the mappings to check that each key is a relation property of `Person`, that the relation type fits the property (`BelongsToOneRelation`, `HasOneRelation` or `HasOneThroughRelation` for a single model, `HasManyRelation`, `ManyToManyRelation` or `BelongsToManyRelation` for an array), and that `modelClass` matches the property's model type: ```ts import { Model, TypedRelationMappings } from 'objection'; @@ -234,9 +304,64 @@ class Person extends Model { `modelClass` is only checked when it is a class or a function returning one, not when it is a module path. Models are compared structurally, so a model class whose instances have all the properties of the expected model passes too. +## Checking queries in TypeScript + +The typings check the column names and values of queries against the models. A column is a property of the model that is neither a method nor a relation, nor starts with `$`. Columns qualified with a relation path, as objection names joined relations, are checked against the related model, whether the relation is joined before or after: + +```ts +await Person.query() + .where('firstName', 'Jennifer') + .where('pets.species', 'dog') + .where('pets:owner.age', '>', 30) + .joinRelated('pets.owner') + .orderBy('lastName'); + +await Person.query().where('firstNme', 'Jennifer'); // Error: not a column of Person +await Person.query().where('pets.nme', 'Fluffy'); // Error: not a column of Animal +await Person.query().where('age', 'thirty'); // Error: age is a number +``` + +`where()`, `select()`, `orderBy()`, `groupBy()`, `having()`, the aggregates, `whereIn()` and the other column methods are checked this way, and so are the names of relations in relation expressions, e.g. in `withGraphFetched()` and `joinRelated()`. Values are checked against the property types: `null` is accepted for any column, and dates also take strings. + +Qualifiers that aren't relation paths, like table names and aliases, are passed through unchecked: `persons.firstName`, or `p.name` after `joinRelated('pets as p')`. Column names that aren't literals, columns of other queries, and aliases of selections outside of `orderBy()` are referenced with `ref()`, or passed as `raw()`: + +```ts +await Person.query().orderBy(ref(sortColumn)); +await Animal.query().whereColumn('animals.ownerId', ref('persons.id')); +``` + +`select()` and the aggregates narrow the rows of the result to the selected columns, next to the model's methods and relations. Aliases take the type of their column, `count()`, `sum()` and `avg()` give numbers, but some drivers return them as strings, e.g. Postgres for bigint and numeric, and raw and subquery selections add properties of unknown type. `orderBy()` takes the aliases of the selections next to the columns: + +```ts +const people = await Person.query() + .select('persons.id', 'firstName as name') + .count('pets.id as petCount') + .joinRelated('pets') + .groupBy('persons.id', 'firstName') + .orderBy('petCount', 'desc'); + +people[0].name; // string +people[0].petCount; // number | string +people[0].lastName; // Error: not selected +``` + +`select('*')` and `select('persons.*')` keep all columns, `first()`, `findById()`, `page()` and `withGraphFetched()` keep the narrowed rows, and repeated selections add up. `clearSelect()` gives back all columns. `distinctOn()` doesn't select its columns and `jsonExtract()` isn't a selection: they don't narrow the rows, and `jsonExtract()` adds its alias to them, of unknown type. + +Models that map their columns with `snakeCaseMappers()` declare it, see [Snake case to camel case conversion](/recipes/snake-case-to-camel-case-conversion.html). Queries for models without declared columns, like the base `Model`, and queries in generic code, e.g. in the methods of custom query builders, aren't checked. After `withGraphFetched()` on a generic query builder, e.g. `this.$query()` in a model method, TypeScript resolves the narrowed query builder to a union, on which only `ref()` and `raw()` columns are accepted. + +To turn the checks off, augment `TypeConfig`: + +```ts +declare module 'objection' { + interface TypeConfig { + strict: false; + } +} +``` + ## Typing generated properties in TypeScript -`insert()` and `insertGraph()` accept any subset of the model's properties. To check that the data for a new row is complete, mark the properties the database generates, like auto-incremented ids, timestamps or columns with defaults, as `Generated`, and type the data as `Insertable`: +`insert()` accepts any subset of the model's properties without the relations, and `insertGraph()` accepts the relations too. To check that the data for a new row is complete, mark the properties the database generates, like auto-incremented ids, timestamps or columns with defaults, as `Generated`, and type the data as `Insertable`: ```ts import { Model, Generated, Insertable, InsertableGraph } from 'objection'; @@ -262,4 +387,4 @@ await Person.query().insertGraph({ } satisfies InsertableGraph); ``` -`Generated` reads and assigns like a `number`. `Insertable` requires all properties except optional, nullable and `Generated` ones, and leaves out relations. `InsertableGraph` adds the relations, which hold the insertable graphs of the related models or `#dbRef` / `#ref` references, and the `#id` of the graph. Both are assignable to the argument types of `insert()` and `insertGraph()`, which themselves are unchanged and still treat all properties as optional. `Generated` also works in an [interface merged into the model](#typing-model-properties-with-an-interface-in-typescript). +`Generated` reads and assigns like a `number`. `Insertable` requires all properties except optional, nullable and `Generated` ones, and leaves out relations. `InsertableGraph` adds the relations, which hold the insertable graphs of the related models or `#dbRef` / `#ref` references, and the `#id` of the graph. Both are assignable to the argument types of `insert()` and `insertGraph()`, which still treat all properties as optional. `Generated` also works in an [interface merged into the model](#typing-model-properties-with-an-interface-in-typescript). diff --git a/doc/guide/plugins.md b/doc/guide/plugins.md index c6f6406fa..ffa94f50e 100644 --- a/doc/guide/plugins.md +++ b/doc/guide/plugins.md @@ -57,7 +57,7 @@ class Person extends SomeMixin(SomeOtherMixin(Model)) {} There are a couple of helpers in objection main module for applying multiple mixins. ```js -const { mixin, Model } = require('objection'); +import { mixin, Model } from 'objection'; class Person extends mixin(Model, [ SomeMixin, @@ -69,7 +69,7 @@ class Person extends mixin(Model, [ ``` ```js -const { compose, Model } = require('objection'); +import { compose, Model } from 'objection'; const mixins = compose( SomeMixin, diff --git a/doc/guide/query-examples.md b/doc/guide/query-examples.md index 68c5552ba..9b8f8c295 100644 --- a/doc/guide/query-examples.md +++ b/doc/guide/query-examples.md @@ -892,6 +892,17 @@ const people = await Person.query() .where('children:pets.age', '>', 10); ``` +A `where` on a left joined relation drops the owners without matching related rows. To keep them, add the condition to the join itself using [modifyJoin](/api/query-builder/join-methods.html#modifyjoin). It gets the aliases of the owner and related tables, so the condition can compare their columns: + +```js +// All people, with only the pets that are younger than them. +const people = await Person.query() + .withGraphJoined('pets') + .modifyJoin('pets', (join, { owner, related }) => { + join.andOn(`${related}.age`, '<', `${owner}.age`); + }); +``` + ## Graph inserts Arbitrary relation graphs can be inserted using the [insertGraph](/api/query-builder/mutate-methods.html#insertgraph) method. This is best explained using examples, so check them out. @@ -987,7 +998,7 @@ Jennifer can't be inserted before Felix because of `favoritePetId`, and Felix ca This only works if the foreign key column is nullable (or the constraint is deferrable). If it isn't, the database rejects the insert of Jennifer, possibly after other models of the graph have already been inserted. Use a [transaction](/guide/transactions.html) to roll those back in that case. -Cycles that can't be resolved this way are rejected with a clear error message before anything is inserted. This is the case for cycles created using only `#ref{}` property references, cycles where the deferred foreign key is itself read by a `#ref{}` property reference, and cycles where one of the models isn't inserted, for example because of the `noInsert` option. +Cycles that can't be resolved this way are rejected with a clear error message before anything is inserted. This is the case for cycles created using only `#ref{}` property references, and cycles where the deferred foreign key is itself read by a `#ref{}` property reference. You can refer to the properties of other models anywhere in the graph using expressions of format `#ref{.}` as long as the reference doesn't create a circular dependency. For example: @@ -1013,6 +1024,10 @@ Again, make sure you set the `allowRefs` option to `true`. The query above will insert a pet named `I am the dog of Jennifer whose id is 523` for Jennifer. If `#ref{}` is used within a string, the references are replaced with the referred values inside the string. If the reference string contains nothing but the reference, the referred value is copied to its place preserving its type. +A `#ref` to a new model (a model without an id), or a `#ref{}` to the id of a new model, needs the id that the database gives the model when the graph inserts it. If the graph doesn't write that model, e.g. because the `noInsert` option skips it, or because it sits below a model that `upsertGraph` ignores because of the `noRelate` option or removes because of `#delete` or `#unrelate`, such a reference throws a `ValidationError` of type `InvalidGraph` before anything is written. A plain `#ref` to a model with an id relates the existing row, and a `#ref{}` to any other property reads the model as the graph gives it, so neither needs the model to be written. Below a model that `noInsert` skips, only the branches that references of written models point into are written, without the skipped model. + +`upsertGraph` writes the models below rows that it relates into a `HasManyRelation` or `HasOneRelation` from another owner after the rest of the graph (see [graph upserts](#graph-upserts)), so the rest of the graph can't `#ref` new models below them, nor read their ids with `#ref{}`. The references of these models are checked once the rest of the graph is written, before they are written themselves. + Existing rows can be related to newly inserted rows by using the `relate` option. `relate` can be `true` in which case all models in the graph that have an identifier get related. `relate` can also be an array of relation paths like `['children', 'children.movies.actors']` in which case only objects in those paths get related even if they have an idetifier. ```js @@ -1086,7 +1101,7 @@ await Person.query().insertGraph( ); ``` -`insertGraph` doesn't insert, update or relate anything below the related models, and new models below them are dropped. A warning is logged when that happens, and objection 4.0 will throw an error instead. New models below models that aren't related because of the `noRelate` option are dropped too. Use [upsertGraph](/api/query-builder/mutate-methods.html#upsertgraph) to modify the relations of related models. +`insertGraph` doesn't insert, update or relate anything below the related models, and throws a `ValidationError` of type `InvalidGraph` for new models (models without id) below them. Models with ids below the related models are ignored, so fetched models with loaded relations can be related. New models below models that aren't related because of the `noRelate` option are dropped. Use [upsertGraph](/api/query-builder/mutate-methods.html#upsertgraph) to modify the relations of related models. If you need to mix inserts and relates inside a single relation, you can use the special property `#dbRef` @@ -1260,12 +1275,41 @@ const graph = await Person.query().upsertGraph({ }); ``` -When the model of a `BelongsToOneRelation` like `parent` is deleted because of `parent: null` or `#delete`, its owner's foreign key is only set to `null` before the delete if the owner's [jsonSchema](/api/model/static-properties.html#static-jsonschema) allows `null` for it, through `type` like `{ type: ['integer', 'null'] }`, `anyOf` / `oneOf` or `nullable: true`. Then the owner is patched, so its update hooks run. Otherwise the row is deleted first and the owner's reference is left to the database, as the column may be `NOT NULL`: `ON DELETE SET NULL` clears it, `ON DELETE CASCADE` deletes the owner, and a foreign key without `ON DELETE` action makes the delete fail. +[upsertGraph](/api/query-builder/mutate-methods.html#upsertgraph) writes in this order: + +1. Relate the rows that move to existing owners of a `HasManyRelation` or `HasOneRelation`, by patching their foreign keys. +2. Delete or unrelate the removed rows that reference their owners: the rows of `HasManyRelation`s and `HasOneRelation`s, and the join rows of many-to-many relations. +3. Insert the new rows, and the join rows of many-to-many inserts and relates. +4. Patch or update the changed rows, relate the remaining rows, and point the owners of `BelongsToOneRelation`s to their new related rows or to `null`. +5. Delete the removed rows of `BelongsToOneRelation`s. +6. Upsert the models below the rows that moved into a `HasManyRelation` or `HasOneRelation` from another owner, in a pass of their own with the same steps, against their rows as they are after the move. + +Rows that move to another parent are moved before their old parent is deleted, so they survive `ON DELETE CASCADE` and foreign keys without `ON DELETE` action. New and changed rows can take the unique keys of the rows they replace, except the key of a row that moves to a new owner, see below. A relate into a `HasOneRelation` waits for the removal of the current related model, or for the relate that moves it to another owner, also a new one or one whose slot is freed first, but not into the models below a moved row, which are upserted last, so it can take its unique foreign key too. + +When the model of a `BelongsToOneRelation` like `parent` is deleted because of `parent: null` or `#delete`, its owner's foreign key is set to `null` before the delete, like [unrelate](/api/query-builder/mutate-methods.html#unrelate) does. The owner is patched, so its update hooks and its [jsonSchema](/api/model/static-properties.html#static-jsonschema) validation run, and a `NOT NULL` column makes the patch fail with a database error. Delete the owner itself if it can't exist without the related model. + +A row related from another owner can't take the unique key of a row that the graph removes in its new place, as the relate runs before the delete. With `unique(ownerId, name)` on the pets, relating person 2's pet `'Fluffy'` to person 1 while dropping person 1's own `'Fluffy'` fails with a `UniqueViolationError`. Delete the conflicting row with a separate query first: + +```js +await Person.transaction(async (trx) => { + await Pet.query(trx).delete().where({ ownerId: 1, name: 'Fluffy' }); + await Person.query(trx).upsertGraph( + { id: 1, pets: [{ id: 1 }, { id: 2 }] }, + { relate: true } + ); +}); +``` + +A new row can't take the unique key of a row that the graph moves to a new owner either. That move waits for the insert of its new owner, so the row still holds its key when the new row is inserted. With `unique(ownerId, name)`, moving person 1's `'Fluffy'` to a new person while inserting a new `'Fluffy'` for person 1 fails with a `UniqueViolationError`. The foreign key isn't cleared first, as it may be `NOT NULL`. Insert the new owner with a separate call first, or make the unique constraint deferred and run `upsertGraph` in a transaction. By giving `relate: true` and/or `unrelate: true` options as the second argument, you can change the behaviour so that instead of inserting and deleting rows, they are related and/or unrelated. Rows with no id still get inserted, but rows that have an id and are not currently related, get related. A row that is missing in one place but related or updated in another place of the same graph isn't deleted, it's only unrelated from the place where it's missing. This way, rows can be moved between parents in a single `upsertGraph` call. +A graph can't place one row in conflicting ways. [insertGraph](/api/query-builder/mutate-methods.html#insertgraph) and [upsertGraph](/api/query-builder/mutate-methods.html#upsertgraph) throw a `ValidationError` of type `InvalidGraph` that names both data paths before anything is written when the graph gives one row two different values for the same column, or the slot of a `HasOneRelation` two different rows, e.g. a pet in the `pets` of two people, a pet under one person whose own `owner` names another one, or a row marked with `#delete` in one place and kept in another. Placing a row the same way more than once is fine, e.g. `{ id: 1, pets: [{ id: 4, owner: { id: 1 } }] }`, and so is a row in places that write different columns, e.g. as a `child` and as a `boss`. A row that one place of an owner lists and another place of the same owner leaves out is kept, as the graph keeps it in the first place. The data paths of models in array input start with their index, e.g. `[1].pets[0]`. + +If a row that the graph relates or updates is gone by the time it's written, `upsertGraph` throws a `NotFoundError`. This happens when an earlier write of the same call deleted it, e.g. a row moved below a new row while its old parent is deleted with `ON DELETE CASCADE`, or when a concurrent query deleted it. Run `upsertGraph` in a transaction so that nothing is written in that case. + ```js const options = { relate: true, @@ -1463,8 +1507,8 @@ The special properties work like this: - `#unrelate` and `#delete` take precedence over the `unrelate`, `noUnrelate` and `noDelete` options. A model marked with `#delete` is deleted even if `unrelate` or `noDelete` is set for its relation, and a model marked with `#unrelate` is unrelated even if `noUnrelate` is set. - If both `#unrelate` and `#delete` are set, the model is unrelated. - The marked model itself is not updated and its relations are ignored, so nothing inside its graph gets inserted, updated, related, unrelated or deleted. Like other deletes, the delete is not recursive (unless you have defined `ON DELETE CASCADE` or other hooks in the db). -- Only models that are currently related to the parent model are unrelated or deleted. Marked models that don't exist or are related to a different model are ignored. They are never inserted or related, even with the `relate` or `insertMissing` options. +- Only models that are currently related to the parent model are unrelated or deleted. Marked models that don't exist or are related to a different model are ignored. They are never inserted or related, even with the `relate` or `insertMissing` options. In a to-one relation, a marked model that isn't the current one keeps the current model, as if the relation wasn't in the graph. In a to-many relation, the array is still the full list, so the current models missing from it are removed. - Only the value `true` marks a model. Other values are ignored and the properties are never written to the database. - Without the `allowMarkers` option, a `ValidationError` is thrown if a model is marked. - The special properties can't be used for the root models of the graph. A `ValidationError` is thrown if a root model is marked. -- The special properties can only be used in [upsertGraph](/api/query-builder/mutate-methods.html#upsertgraph). [insertGraph](/api/query-builder/mutate-methods.html#insertgraph) has nothing to unrelate or delete and throws a `ValidationError` if a model is marked, even with the `allowMarkers` option. +- The special properties can only be used in [upsertGraph](/api/query-builder/mutate-methods.html#upsertgraph). [insertGraph](/api/query-builder/mutate-methods.html#insertgraph) has nothing to unrelate or delete and throws a `ValidationError` if a model is marked. diff --git a/doc/guide/relations.md b/doc/guide/relations.md index f8c212d9f..aababc693 100644 --- a/doc/guide/relations.md +++ b/doc/guide/relations.md @@ -14,6 +14,9 @@ Relationships are a very basic concept in relational databases and if you aren't Each row in `C` joins one `A` with one `B`. Therefore an `A` row can be related to multiple `B` rows and a `B` row can be related to multiple `A` rows through table `C`. +4. Table `A` has a column that holds an array of table `B`'s ids. This relationship is called a `BelongsToManyRelation` in objection. + We can say that `A` belongs to many `B`'s. + While relations are usually created between the primary key of one table and a foreign key reference of another table, objection has no such limitations. You can create relationship using any two columns (or any sets of columns). You can even create relation using values nested deep inside json columns. If you've used other ORMs you may notice that objection's [relationMappings](/api/model/static-properties.html#static-relationmappings) are pretty verbose. There are couple of reasons for that: @@ -35,16 +38,18 @@ Vocabulary for the relation descriptions: class Animal extends Model { static tableName = 'animals'; - static relationMappings = { - owner: { - relation: Model.BelongsToOneRelation, - modelClass: Person, - join: { - from: 'animals.ownerId', - to: 'persons.id' + static get relationMappings() { + return { + owner: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { + from: 'animals.ownerId', + to: 'persons.id' + } } - } - }; + }; + } } ``` @@ -54,16 +59,18 @@ class Animal extends Model { class Person extends Model { static tableName = 'persons'; - static relationMappings = { - animals: { - relation: Model.HasManyRelation, - modelClass: Animal, - join: { - from: 'persons.id', - to: 'animals.ownerId' + static get relationMappings() { + return { + animals: { + relation: Model.HasManyRelation, + modelClass: Animal, + join: { + from: 'persons.id', + to: 'animals.ownerId' + } } - } - }; + }; + } } ``` @@ -73,16 +80,18 @@ class Person extends Model { class Person extends Model { static tableName = 'persons'; - static relationMappings = { - animal: { - relation: Model.HasOneRelation, - modelClass: Animal, - join: { - from: 'persons.id', - to: 'animals.ownerId' + static get relationMappings() { + return { + animal: { + relation: Model.HasOneRelation, + modelClass: Animal, + join: { + from: 'persons.id', + to: 'animals.ownerId' + } } - } - }; + }; + } } ``` @@ -92,21 +101,23 @@ class Person extends Model { class Person extends Model { static tableName = 'persons'; - static relationMappings = { - movies: { - relation: Model.ManyToManyRelation, - modelClass: Movie, - join: { - from: 'persons.id', - through: { - // persons_movies is the join table. - from: 'persons_movies.personId', - to: 'persons_movies.movieId' - }, - to: 'movies.id' + static get relationMappings() { + return { + movies: { + relation: Model.ManyToManyRelation, + modelClass: Movie, + join: { + from: 'persons.id', + through: { + // persons_movies is the join table. + from: 'persons_movies.personId', + to: 'persons_movies.movieId' + }, + to: 'movies.id' + } } - } - }; + }; + } } ``` @@ -116,56 +127,67 @@ class Person extends Model { class Person extends Model { static tableName = 'persons'; - static relationMappings = { - movie: { - relation: Model.HasOneThroughRelation, - modelClass: Movie, - join: { - from: 'persons.id', - through: { - // persons_movies is the join table. - from: 'persons_movies.personId', - to: 'persons_movies.movieId' - }, - to: 'movies.id' + static get relationMappings() { + return { + movie: { + relation: Model.HasOneThroughRelation, + modelClass: Movie, + join: { + from: 'persons.id', + through: { + // persons_movies is the join table. + from: 'persons_movies.personId', + to: 'persons_movies.movieId' + }, + to: 'movies.id' + } } - } - }; + }; + } } ``` -## Require loops (non ECMAScript modules only) - -Require loops (circular dependencies, circular requires) are a very common problem when defining relations. Whenever a module `A` imports module `B` that immediately (synchronously) imports module `A`, you create a require loop that node.js or objection cannot solve automatically. A require loop usually leads to the other imported value to be an empty object which causes all kinds of problems. Objection attempts to detect these situations and mention the words `require loop` in the thrown error. Objection offers multiple solutions to this problem. See the circular dependency solutions examples in this section. In addition to objection's solutions, you can always organize your code so that such loops are not created. - -If you are using [ECMAScript modules](https://nodejs.org/api/esm.html), circular imports are not a problem. You can just do: +`BelongsToManyRelation`: Use this relation when the source model holds an array of the related models' keys ```js -import Animal from "./Animal.js"; +class Post extends Model { + static tableName = 'posts'; -class Person extends Model { - static get tableName() { - return "persons"; - } + // The JSON array is parsed and stringified like any other JSON column. + static jsonAttributes = ['tagIds']; static get relationMappings() { return { - pets: { - relation: Model.HasManyRelation, - modelClass: Animal, + tags: { + relation: Model.BelongsToManyRelation, + modelClass: Tag, join: { - from: "persons.id", - to: "animals.ownerId", - }, - }, + // posts.tagIds holds an array like [1, 4, 7]. + from: 'posts.tagIds', + to: 'tags.id' + } + } }; } } ``` -However if you are not using ECMAScript modules, solutions to require loops are: +The array can be a JSON column on all databases: `json` or `jsonb` on PostgreSQL, `json` on MySQL, and a text column on SQLite and SQL Server. PostgreSQL also supports native array columns like `integer[]`, which are left out of `jsonAttributes`. The array can also sit inside a JSON column, mapped with a field expression like `'posts.data:tagIds'`. On SQL Server, the relation needs SQL Server 2016 or newer for `OPENJSON`. The keys in the array need to have the same JSON type as the related column, e.g. numbers for an integer `id`, and not strings like the `bigint` ids that node-postgres returns: on PostgreSQL and MySQL, joins and queries for owner ids or subqueries find no related rows for keys of another type. Only single-column keys are supported, and `relatedQuery().for()` with a query of another model class needs to select the array under the name of the owner's column. + +The related models of each owner come in the order of the relation query, e.g. as sorted by a modifier with `orderBy()`, not in the order of the keys in the array. A related model that the arrays of several owners hold is assigned to each of them. `limit()`, `offset()` and `first()` in the modifiers of `withGraphFetched()` can't be applied to each owner, and require the `limitPerParent: false` graph option. + +`relate()` adds keys to the arrays of the owners, and `unrelate()` removes the keys of the related rows that its query finds, so keys of rows that don't exist stay. `insert()` through the relation inserts the related models and adds their keys, and `delete()` deletes the related rows and removes their keys from the arrays of the owners of the query, not from other rows that hold them. These methods read the arrays and write the changed ones back with separate queries, so concurrent writes to the arrays of the same owners can overwrite each other: run them in a transaction that locks the owner rows first, e.g. with `forUpdate()`. Keys are stored as they are given, so pass them in the type of the related column, e.g. numbers and not the strings of request parameters, which PostgreSQL and MySQL wouldn't find in joins and queries for owner ids. For a JSON field expression like `'posts.data:tagIds'`, only the array at that path is written, which SQL Server doesn't support. `$setRelated()` and `$appendRelated()` keep the array of the model in sync with the related models. + +`insertGraph()` and `upsertGraph()` add the keys of the related models in the graph to the array, in the order of the graph. Like for the other to-many relations, the list in an `upsertGraph()` is the full list: related models that are left out are deleted, or only removed from the array with the `unrelate` option, and kept with `noDelete` or `noUnrelate`. Keys of rows that the relation's filter leaves out, and of rows that don't exist, stay in the array. Keys that the array already holds keep their place and their value, e.g. `relate('1')` doesn't replace a stored `1`. + +## Circular imports (require loops) + +Circular imports (circular dependencies, require loops) are a very common problem when defining relations. Whenever a module `A` imports module `B` that imports module `A`, one of the two modules is evaluated before the other one has finished. With ECMAScript modules, accessing the other module's class during that time throws a `ReferenceError`. With CommonJS modules, `require()` returns an incomplete, often empty, exports object instead, which causes all kinds of problems. In CommonJS, objection attempts to detect these situations and mention the words `require loop` in the thrown error. Objection offers multiple solutions to this problem. See the circular dependency solutions examples in this section. In addition to objection's solutions, you can always organize your code so that such loops are not created. ```js +import { Model } from 'objection'; +import Animal from './Animal.js'; + class Person extends Model { static get tableName() { return 'persons'; @@ -175,13 +197,10 @@ class Person extends Model { // Solution 1: // // relationMappings getter is accessed lazily when you execute - // your first query that needs it. Therefore if you `require` - // your models inside the getter, you don't end up with a require loop. - // Note that only one end of the relation needs to be required like - // this, not both. `relationMappings` can also be a method or - // a thunk if you prefer those instead of getters. - const Animal = require('./Animal'); - + // your first query that needs it. By then all modules have been + // evaluated, so you can use the imported models inside the getter + // even if the modules import each other. `relationMappings` can + // also be a method or a thunk if you prefer those instead of getters. return { pets: { relation: Model.HasManyRelation, @@ -190,47 +209,60 @@ class Person extends Model { from: 'persons.id', to: 'animals.ownerId' } - }, - - movies: { - relation: Model.ManyToManyRelation, - // Solution 2: - // - // Absolute file path to a module that exports the model class. - // This is similar to solution 1, but objection calls `require` - // under the hood. The downside here is that you need to give - // an absolute file path because of the way `require` works. - modelClass: path.join(__dirname, 'Movie'), - join: { - from: 'persons.id', - through: { - // persons_movies is the join table. - from: 'persons_movies.personId', - to: 'persons_movies.movieId' - }, - to: 'movies.id' - } - }, - - movies: { - relation: Model.ManyToManyRelation, - // Solution 3: - // - // Use only a module name and define a `modelPaths` property - // for your model (or a superclass of your model). Search for - // `modelPaths` from the docs for more info. - modelClass: 'Movie', - join: { - from: 'persons.id', - through: { - from: 'persons_movies.personId', - to: 'persons_movies.movieId' - }, - to: 'movies.id' - } } }; } } ``` +The other solutions replace the `modelClass` of a relation, and also work when `relationMappings` is a static class field, which is evaluated when the class is defined: + +```js +import path from 'node:path'; +import { Model } from 'objection'; +import Movie from './Movie.js'; + +class Person extends Model { + static tableName = 'persons'; + + static relationMappings = { + movies: { + relation: Model.ManyToManyRelation, + // Solution 2: + // + // A thunk that returns the model class. objection calls it + // lazily, when the relation is first needed. + modelClass: () => Movie, + + // Solution 3: + // + // Absolute file path to a module that exports the model class. + // objection loads the module under the hood when it is first + // needed. The downside here is that you need to give an + // absolute file path. + // modelClass: path.join(import.meta.dirname, 'Movie.js'), + + // Solution 4: + // + // Use only a module name and define a `modelPaths` property + // for your model (or a superclass of your model). Search for + // `modelPaths` from the docs for more info. + // modelClass: 'Movie', + + join: { + from: 'persons.id', + through: { + // persons_movies is the join table. + from: 'persons_movies.personId', + to: 'persons_movies.movieId' + }, + to: 'movies.id' + } + } + }; +} +``` + +In CommonJS modules, solution 1 only works if you `require()` the models inside the getter instead of at the top of the file, because `require()` returns an incomplete exports object while the other module is still being evaluated. Only one end of the relation needs to be required like this, not both. + +For solutions 3 and 4, objection loads the file synchronously with `require()`, which on node 20.19 and newer loads both CommonJS modules and ECMAScript modules that don't use top-level await, or import modules that do. An ECMAScript module needs to export the model class as its default export, or as its only exported model class. Paths without a file extension are resolved the way `require()` resolves them, which finds `.js` files, but not `.mjs` files. diff --git a/doc/guide/transactions.md b/doc/guide/transactions.md index a5a44ba61..ae898d6d5 100644 --- a/doc/guide/transactions.md +++ b/doc/guide/transactions.md @@ -44,7 +44,7 @@ Note: Even if you start a transaction using `Person.transaction` it doesn't mean An alternative way to start a transaction is to use the [Model.startTransaction()](/api/model/static-methods.html#static-starttransaction) method: ```js -const { transaction } = require('objection'); +import { transaction } from 'objection'; const trx = await Person.startTransaction(); @@ -130,14 +130,14 @@ await insertPersonAndPet(person, pet); The second way to use transactions avoids passing around a transaction object by "binding" model classes to a transaction. You pass all models you want to bind as arguments to the [objection.transaction](/api/objection/#transaction) method and as the last argument you provide a callback that receives **copies** of the models that have been bound to a newly started transaction. All queries started through the bound copies take part in the transaction and you don't need to pass around a transaction object. Note that the models passed to the callback are actual copies of the models passed as arguments to [objection.transaction](/api/objection/#transaction) and starting a query through any other object will **not** be executed inside a transaction. ```js -const { transaction } = require('objection'); +import { transaction } from 'objection'; try { const scrappy = await transaction(Person, Animal, async (Person, Animal) => { // Person and Animal inside this function are bound to a newly // created transaction. The Person and Animal outside this function - // are not! Even if you do `require('./models/Person')` inside this - // function and start a query using the required `Person` it will + // are not! Even if you import `Person` from `./models/Person.js` + // and start a query using the imported `Person` it will // NOT take part in the transaction. Only the actual objects passed // to this function are bound to the transaction. @@ -158,7 +158,7 @@ try { You only need to give the [objection.transaction](/api/objection/#transaction) function the model classes you use explicitly. All the related model classes are implicitly bound to the same transaction: ```js -const { transaction } = require('objection'); +import { transaction } from 'objection'; try { const scrappy = await transaction(Person, async Person => { @@ -183,9 +183,9 @@ try { The only way you can mess up with the transactions is if you _explicitly_ start a query using a model class that is not bound to the transaction: ```js -const { transaction } = require('objection'); -const Person = require('./models/Person'); -const Animal = require('./models/Animal'); +import { transaction } from 'objection'; +import Person from './models/Person.js'; +import Animal from './models/Animal.js'; await transaction(Person, async BoundPerson => { // This will be executed inside the transaction. @@ -208,7 +208,7 @@ await transaction(Person, async BoundPerson => { The transaction object is always passed as the last argument to the callback: ```js -const { transaction } = require('objection'); +import { transaction } from 'objection'; await transaction(Person, async (Person, trx) => { // `trx` is the knex transaction object. diff --git a/doc/guide/validation.md b/doc/guide/validation.md index 18961b83e..f836c0b0e 100644 --- a/doc/guide/validation.md +++ b/doc/guide/validation.md @@ -75,3 +75,25 @@ try { } } ``` + +## Write-only properties + +`insertGraph()` and `upsertGraph()` validate their models once the references and keys of the graph are resolved, after the models are created. A property that a setter consumes when the model is created, e.g. a `password` that a setter turns into a hash, is gone by then, and fails a `required` rule or skips its other rules. Keep such properties on the model and transform them in the `$beforeInsert` and `$beforeUpdate` hooks, which run after the validation in all queries: + +```js +class User extends Model { + static jsonSchema = { + type: 'object', + required: ['password'], + properties: { + password: { type: 'string', minLength: 8 } + }, + }; + + async $beforeInsert(context) { + await super.$beforeInsert(context); + this.passwordHash = await hash(this.password); + delete this.password; + } +} +``` diff --git a/doc/recipes/custom-query-builder.md b/doc/recipes/custom-query-builder.md index 656fff65d..0d05daa16 100644 --- a/doc/recipes/custom-query-builder.md +++ b/doc/recipes/custom-query-builder.md @@ -4,9 +4,9 @@ You can extend the [QueryBuilder](/api/query-builder/) returned by [query()](/ap ```js // MyQueryBuilder.js -const { QueryBuilder } = require('objection'); +import { QueryBuilder } from 'objection'; -class MyQueryBuilder extends QueryBuilder { +export class MyQueryBuilder extends QueryBuilder { myCustomMethod(something) { doSomething(something); return this; @@ -14,7 +14,7 @@ class MyQueryBuilder extends QueryBuilder { } // Person.js -const { MyQueryBuilder } = require('./MyQueryBuilder'); +import { MyQueryBuilder } from './MyQueryBuilder.js'; class Person extends Model { static get QueryBuilder() { @@ -33,16 +33,16 @@ If you want to set the custom query builder for all model classes you can just s ```js // BaseModel.js -const { MyQueryBuilder } = require('./MyQueryBuilder'); +import { MyQueryBuilder } from './MyQueryBuilder.js'; -class BaseModel extends Model { +export class BaseModel extends Model { static get QueryBuilder() { return MyQueryBuilder; } } // Person.js -const { BaseModel } = require('./BaseModel'); +import { BaseModel } from './BaseModel.js'; // Person now uses MyQueryBuilder class Person extends BaseModel {} @@ -70,19 +70,15 @@ TIP: Consider using [modifiers](/recipes/modifiers.html#usage-in-a-query) instea # Extending the query builder in typescript -With typescript, you need to add some extra type properties for the custom query builder. These are necessary until typescript fully supports our use case. The good news is that you only need to define them once for the shared `BaseModel`. If you don't already have one, it's time to create it. +With typescript, you need to add an extra type property to the custom query builder, so that methods like `first()`, `findById()`, `patch()` or `page()` return your query builder instead of the plain `QueryBuilder`. You also need to declare the query builder type on the model. The good news is that you only need to define them once for the shared `BaseModel`. If you don't already have one, it's time to create it. ```ts -import { Model, Page } from 'objection'; +import { Model, QueryBuilder } from 'objection'; class MyQueryBuilder extends QueryBuilder { - // These are necessary. You can just copy-paste them and change the - // name of the query builder class. - declare ArrayQueryBuilderType: MyQueryBuilder; - declare SingleQueryBuilderType: MyQueryBuilder; - declare MaybeSingleQueryBuilderType: MyQueryBuilder; - declare NumberQueryBuilderType: MyQueryBuilder; - declare PageQueryBuilderType: MyQueryBuilder>; + // This is necessary. You can just copy-paste it and change the name of the + // query builder class. + declare RebindType: MyQueryBuilder; myCustomMethod(something: number): this { doSomething(something); @@ -105,8 +101,11 @@ class Person extends BaseModel { } await Person.query().where('id', 1).myCustomMethod(1).where('foo', 'bar'); +await Person.query().findById(1).myCustomMethod(1); ``` +Methods that override query builder methods can use the helper types for their return types, e.g. `MaybeSingleQueryBuilder` for `findById()`, `NumberQueryBuilder` for `patch()`, `NumberQueryBuilder>` for `patchById()` and `WithGraphQueryBuilder` for `withGraphFetched()`. + ::: tip TIP: Consider using [modifiers](/recipes/modifiers.html#usage-in-a-query) instead of extending the query builder. You can often achieve the same flexibility with both. ::: diff --git a/doc/recipes/custom-validation.md b/doc/recipes/custom-validation.md index def60c342..8921b4028 100644 --- a/doc/recipes/custom-validation.md +++ b/doc/recipes/custom-validation.md @@ -27,7 +27,7 @@ class Person extends Model { Modifying the [Ajv](https://github.com/epoberezkin/ajv) based JSON schema validation: ```js -const AjvValidator = require('objection').AjvValidator; +import { AjvValidator } from 'objection'; class Model { static createValidator() { @@ -51,7 +51,7 @@ Replace JSON schema validation with any other validation scheme by implementing ```js // MyCustomValidator.js -const { Validator } = require('objection'); +import { Validator } from 'objection'; class MyCustomValidator extends Validator { validate(args) { @@ -96,7 +96,7 @@ class MyCustomValidator extends Validator { // BaseModel.js -const Model = require('objection').Model; +import { Model } from 'objection'; // Override the `createValidator` method of a `Model` to use the // custom validator. diff --git a/doc/recipes/error-handling.md b/doc/recipes/error-handling.md index 2180c0e0a..a8312082e 100644 --- a/doc/recipes/error-handling.md +++ b/doc/recipes/error-handling.md @@ -9,7 +9,7 @@ Objection throws four kinds of errors: 2. [NotFoundError](/api/types/#class-notfounderror) when [throwIfNotFound](/api/query-builder/other-methods.html#throwifnotfound) was called for a query and no results were found. -3. Database errors as defined by the [db-errors library](https://github.com/Vincit/db-errors). You can access the error classes through objection. See the example. +3. Database errors as defined by the [db-errors library](https://github.com/Vincit/db-errors). objection includes its own copy of it, so import the error classes from objection. See the example. 4. A basic JavaScript `Error` when a programming or logic error is detected. In these cases there is nothing the users can do and the only correct way to handle the error is to send a 500 response to the user and to fix the program. @@ -19,7 +19,7 @@ Objection throws four kinds of errors: An example error handler function that handles all possible errors. Note that you should never send the errors directly to the client as they may contain SQL and other information that reveals too much about the inner workings of your app. ```js -const { +import { ValidationError, NotFoundError, DBError, @@ -29,7 +29,7 @@ const { ForeignKeyViolationError, CheckViolationError, DataError -} = require('objection'); +} from 'objection'; // In this example `res` is an express response object. function errorHandler(err, res) { diff --git a/doc/recipes/extra-properties.md b/doc/recipes/extra-properties.md index 01b900d2c..df67c0095 100644 --- a/doc/recipes/extra-properties.md +++ b/doc/recipes/extra-properties.md @@ -5,7 +5,7 @@ Sometimes when you have a many-to-many relationship, you want to store some prop Let's consider a schema like this: ```js -exports.up = knex => { +export function up(knex) { return knex.schema .createTable('actors', table => { table.increments('id').primary(); @@ -21,7 +21,7 @@ exports.up = knex => { // The actor's character's name in the movie. table.string('characterName'); }); -}; +} ``` In this schema, `characterName` is the `extra` property. When we fetch movies for an actor, we want the movie objects to contain the `characterName` in addition to normal movie properties. @@ -108,6 +108,6 @@ await linda .where('movies.name', 'Curvature'); ``` -A `patch` or `update` that changes `extra` properties runs several statements: it fetches the ids of the related rows matching the filters, updates their join rows, and then updates the related rows by their ids, in batches of up to 500 values per statement to stay below the database's limit. On MySQL and SQL Server, the only databases that support a limit in updates, `limit()` and `offset()` pick the related rows to update in the order of `orderBy()`, which can't reference the join table then, and only their join rows are updated. If one of these statements fails, the earlier ones aren't rolled back, so run the query in a [transaction](/guide/transactions.html) if it needs to be atomic. [asFindQuery](/api/types/#type-statichookarguments) in the static `after` hooks of such a query finds the updated rows by all their ids in one statement, so with a very large number of rows it can exceed that limit. +A `patch` or `update` that changes `extra` properties runs several statements: it fetches the ids of the related rows matching the filters, updates their join rows, and then updates the related rows by their ids. The ids are bound as one value, a JSON array on SQL Server (2016 or newer) and SQLite and an array on PostgreSQL, to stay below the database's limit of bound values. Binary ids bind one value per row, so SQL Server can't patch more than about 2100 of them in one query, and composite ids on PostgreSQL one value per column, so it can't patch more than about 32,000 related rows with two-column ids. On MySQL and SQL Server, the only databases that support a limit in updates, `limit()` and `offset()` pick the related rows to update in the order of `orderBy()`, which can't reference the join table then, and only their join rows are updated. If one of these statements fails, the earlier ones aren't rolled back, so run the query in a [transaction](/guide/transactions.html) if it needs to be atomic. [asFindQuery](/api/types/#type-statichookarguments) in the static `after` hooks of such a query finds the updated rows by the same ids. `extra` properties also work with [withGraphFetched](/api/query-builder/eager-methods.html#withgraphfetched) [insertGraph](/api/query-builder/mutate-methods.html#insertgraph) and [upsertGraph](/api/query-builder/mutate-methods.html#upsertgraph). diff --git a/doc/recipes/indexing-postgresql-jsonb-columns.md b/doc/recipes/indexing-postgresql-jsonb-columns.md index e75588963..fa85de4a9 100644 --- a/doc/recipes/indexing-postgresql-jsonb-columns.md +++ b/doc/recipes/indexing-postgresql-jsonb-columns.md @@ -44,7 +44,7 @@ Complete example how to try out different index choices. Migration: ```js -exports.up = knex => { +export function up(knex) { return knex.schema .createTable('Hero', table => { table.increments('id').primary(); @@ -67,7 +67,7 @@ exports.up = knex => { 'Place', 'details' ]); -}; +} ``` Results following schema: diff --git a/doc/recipes/json-queries.md b/doc/recipes/json-queries.md index cf0947bbe..0216cd753 100644 --- a/doc/recipes/json-queries.md +++ b/doc/recipes/json-queries.md @@ -4,10 +4,22 @@ You can use the [ref](/api/objection/#ref) function from the main module to refe See [FieldExpression](/api/types/#type-fieldexpression) for more information about how to refer to json fields. -Json queries currently only work with postgres. Using json field expressions (`ref('column:path')`, `'column:path'` keys in `patch()` and `update()`) or the `whereJson*()` methods of objection with another database logs a warning, as the generated SQL is Postgres-only. Use the JSON methods of knex instead, like [whereJsonPath()](https://knexjs.org/guide/query-builder.html#wherejsonpath) and [jsonExtract()](https://knexjs.org/guide/query-builder.html#jsonextract). On those databases, `whereJsonSupersetOf(column, value)` and `whereJsonSubsetOf(column, value)` with a plain column and a JSON object or array are passed on to knex, which supports them on MySQL. +Json field expressions in `ref()`, `'column:path'` keys in `patch()` and `merge()` and the `whereJson*()` methods of objection work on PostgreSQL, MySQL and SQLite: objection writes the SQL of each database, e.g. `"column"#>'{a,b}'` on PostgreSQL, ``JSON_EXTRACT(`column`, '$."a"."b"')`` on MySQL and ``json_extract(`column`, '$."a"."b"')`` on SQLite, and `jsonb_set()`, `JSON_SET()` and `json_set()` for patches. SQLite has no json containment, so `whereJsonSupersetOf()` and `whereJsonSubsetOf()` throw an error there. On SQL Server and Oracle, json field expressions throw an error, and on other databases they log a warning and use the SQL of PostgreSQL. Use the JSON methods of knex there, like [whereJsonPath()](https://knexjs.org/guide/query-builder.html#wherejsonpath) and [jsonExtract()](https://knexjs.org/guide/query-builder.html#jsonextract). + +The databases differ in a few ways: + +- On PostgreSQL, `column:1` and `column:[1]` both refer to an array element or an object key. On MySQL and SQLite, `[1]` only refers to array elements and `.1` only to object keys. +- Without a cast, a reference is a json value on PostgreSQL and MySQL and the plain SQL value on SQLite, where objects and arrays are json text. The `mysql` driver returns json values as json text, `mysql2` and `pg` parse them. +- The casts use the types of the database, e.g. `castInt()` is `SIGNED` on MySQL, see [ReferenceBuilder](/api/types/#class-referencebuilder). +- SQLite can't tell json columns from text columns, so `ref('column').castJson()` throws there for a whole column. Cast it first, e.g. `ref('name').castText().castJson()`, or refer to a json path. +- Patching a path whose parent object is missing leaves the json unchanged on PostgreSQL and MySQL, while SQLite creates the parent objects. Patching an array index after the end appends to the array on PostgreSQL and MySQL, and leaves it unchanged on SQLite. +- MySQL assigns the columns of an update from left to right, so `patch({ name: 'a', 'json:name': ref('name') })` writes the new name into the json column there. +- On SQLite, a `ref()` to a whole json column or a subquery that selects one, as the value of a `'column:path'` key, is written as a json string, as SQLite can't tell json columns from text columns. Write `raw('json(??)', ['column'])` to write the json value. +- `whereJsonSupersetOf()` and `whereJsonSubsetOf()` use `JSON_CONTAINS()` on MySQL, which also finds objects and values in the elements of arrays, e.g. `[{ "a": 1 }]` is a superset of `{}` there. +- `whereJsonHasAny()` and `whereJsonHasAll()` use `JSON_OVERLAPS()` on MySQL. ```js -const { ref } = require('objection'); +import { ref } from 'objection'; await Person.query() .select([ diff --git a/doc/recipes/multitenancy-using-multiple-databases.md b/doc/recipes/multitenancy-using-multiple-databases.md index 2528c14a8..933bc5ac6 100644 --- a/doc/recipes/multitenancy-using-multiple-databases.md +++ b/doc/recipes/multitenancy-using-multiple-databases.md @@ -9,7 +9,7 @@ _NOTE:_ The following patterns don't work if you have a large amount of tenants If you have a different database for each tenant, a useful pattern is to add a middleware that adds the models to `req.models` hash and then _always_ use the models through `req.models` instead of requiring them directly. What [bindKnex](/api/model/static-methods.html#static-bindknex) method actually does is that it creates an anonymous subclass of the model class and sets its knex connection. That way the database connection doesn't change for the other requests that are currently being executed. ```js -const Knex = require('knex'); +import Knex from 'knex'; const knexCache = new Map(); diff --git a/doc/recipes/plugins.md b/doc/recipes/plugins.md index 0cca75d52..b59d8fb9b 100644 --- a/doc/recipes/plugins.md +++ b/doc/recipes/plugins.md @@ -3,8 +3,10 @@ ## TypeScript Example ```ts -export function Mixin(options = {}) { - return function(Base: T) { +type Constructor = new (...args: any[]) => T; + +export function Mixin() { + return function >(Base: T) { return class extends Base { mixinMethod() {} }; @@ -15,34 +17,27 @@ export function Mixin(options = {}) { class Person extends Model {} -const MixinPerson = Mixin(Person); - -// Or as a decorator: - -@Mixin -class Person extends Model {} +const MixinPerson = Mixin()(Person); ``` ## TypeScript Example with Custom QueryBuilder ```ts +type Constructor = new (...args: any[]) => T; + class CustomQueryBuilder extends QueryBuilder { - declare ArrayQueryBuilderType: CustomQueryBuilder; - declare SingleQueryBuilderType: CustomQueryBuilder; - declare MaybeSingleQueryBuilderType: CustomQueryBuilder; - declare NumberQueryBuilderType: CustomQueryBuilder; - declare PageQueryBuilderType: CustomQueryBuilder>; + declare RebindType: CustomQueryBuilder; someCustomMethod(): this { return this; } } -export function CustomQueryBuilderMixin(options = {}) { - return function(Base: T) { +export function CustomQueryBuilderMixin() { + return function >(Base: T) { return class extends Base { - static QueryBuilder = QueryBuilder; - QueryBuilderType: CustomQueryBuilder; + static QueryBuilder = CustomQueryBuilder; + declare QueryBuilderType: CustomQueryBuilder; mixinMethod() {} }; @@ -51,20 +46,17 @@ export function CustomQueryBuilderMixin(options = {}) { // Usage -class Person extends Model {} - -const MixinPerson = CustomQueryBuilderMixin(Person); - -// Or as a decorator: +class Person extends Model { + id!: number; +} -@CustomQueryBuilderMixin -class Person extends Model {} +const MixinPerson = CustomQueryBuilderMixin()(Person); async () => { const z = await MixinPerson.query() .whereIn('id', [1, 2]) .someCustomMethod() - .where('foo', 1) + .where('id', 1) .someCustomMethod(); z[0].mixinMethod(); diff --git a/doc/recipes/raw-queries.md b/doc/recipes/raw-queries.md index f3b11c9e9..940b507cc 100644 --- a/doc/recipes/raw-queries.md +++ b/doc/recipes/raw-queries.md @@ -9,7 +9,7 @@ There are also some helper methods such as [whereRaw](/api/query-builder/find-me ## Examples ```js -const { raw } = require('objection'); +import { raw } from 'objection'; const ageToAdd = 10; await Person.query().patch({ @@ -18,7 +18,7 @@ await Person.query().patch({ ``` ```js -const { raw } = require('objection'); +import { raw } from 'objection'; const childAgeSums = await Person.query() .select(raw('coalesce(sum(??), 0)', 'age').as('childAgeSum')) @@ -34,7 +34,7 @@ console.log(childAgeSums[0].childAgeSum); Also see the [fn](/api/objection/#fn) helper for calling SQL functions. The following example is equivalent the previous one. ```js -const { fn, ref } = require('objection'); +import { fn, ref } from 'objection'; const childAgeSums = await Person.query() .select(fn.coalesce(fn.sum(ref('age')), 0).as('childAgeSum')) @@ -50,7 +50,7 @@ console.log(childAgeSums[0].childAgeSum); Binding arguments can be other [raw](/api/objection/#raw) instances, [QueryBuilders](/api/query-builder/) or pretty much anything you can think of. ```js -const { raw, ref } = require('objection'); +import { raw, ref } from 'objection'; const people = await Person .query() diff --git a/doc/recipes/snake-case-to-camel-case-conversion.md b/doc/recipes/snake-case-to-camel-case-conversion.md index ed4a55feb..c09c468d3 100644 --- a/doc/recipes/snake-case-to-camel-case-conversion.md +++ b/doc/recipes/snake-case-to-camel-case-conversion.md @@ -4,23 +4,23 @@ You may want to use snake_cased names in database and camelCased names in code. 1. _Conversion in knex using [knexSnakeCaseMappers](/api/objection/#knexsnakecasemappers)_. When the conversion is done on knex level **everything** is converted to camel case including properties and identifiers in [relationMappings](/api/model/static-properties.html#static-relationmappings) and queries. When this method is used, objection has no idea the database is defined in snake case. All it ever sees is camel cased properties, identifiers and tables. This is because the conversion is done using knex's `postProcessResponse` and `wrapIdentifier` hooks which are executed by knex before objection receives the data. -2. _Conversion in objection using [snakeCaseMappers](/api/objection/#snakecasemappers)_. When the conversion is done on objection level only database columns of the returned rows (model instances) are convered to camel case. You still need to use snake case in [relationMappings](/api/model/static-properties.html#static-relationmappings) and queries. Note that [insert](/api/query-builder/mutate-methods.html#insert), [patch](/api/query-builder/mutate-methods.html#patch), [update](/api/query-builder/mutate-methods.html#update) and their variants still take objects in camel case. The reasoning is that objects passed to those methods usually come from the client that also uses camel case. By default, the JSON keys of [field expressions](/api/types/#type-fieldexpression) passed to those methods (e.g. `patch({ 'jsonColumn:someKey': 1 })`) are converted to snake case as well. Use the `preserveJsonKeys` option to only convert the column part. +2. _Conversion in objection using [snakeCaseMappers](/api/objection/#snakecasemappers)_. When the conversion is done on objection level only database columns of the returned rows (model instances) are convered to camel case. You still need to use snake case in [relationMappings](/api/model/static-properties.html#static-relationmappings) and queries. Note that [insert](/api/query-builder/mutate-methods.html#insert), [patch](/api/query-builder/mutate-methods.html#patch), [update](/api/query-builder/mutate-methods.html#update) and their variants still take objects in camel case. The reasoning is that objects passed to those methods usually come from the client that also uses camel case. Only the column part of [field expressions](/api/types/#type-fieldexpression) passed to [patch](/api/query-builder/mutate-methods.html#patch) (e.g. `patch({ 'jsonColumn:someKey': 1 })`) is converted, and their JSON keys are kept as written. Set the `preserveJsonKeys` option to `false` to convert the JSON keys to snake case as well. Let's assume this is our schema: ```js -exports.up = knex => { +export function up(knex) { return knex.schema.createTable('persons_table', table => { table.increments('id_column').primary(); table.string('first_name'); table.string('last_name'); table.integer('parent_id').references('persons_table.id_column'); }); -}; +} -exports.down = knex => { +export function down(knex) { return knex.schema.dropTableIfExists('persons_table'); -}; +} ``` **knexSnakeCaseMappers:** @@ -28,8 +28,8 @@ exports.down = knex => { See [here](/api/objection/#knexsnakecasemappers) for the full list of options that can be passed to `knexSnakeCaseMappers`. ```js -const Knex = require('knex'); -const { Model, knexSnakeCaseMappers } = require('objection'); +import Knex from 'knex'; +import { Model, knexSnakeCaseMappers } from 'objection'; const knex = Knex({ client: 'postgres', @@ -83,7 +83,7 @@ await Person.query().where('firstName', 'Jennifer'); See [here](/api/objection/#snakecasemappers) for the full list of options that can be passed to `snakeCaseMappers`. ```js -const { Model, snakeCaseMappers } = require('objection'); +import { Model, snakeCaseMappers } from 'objection'; // When `snakeCaseMappers` is used, you still define tables, // columns and relation mappings using snake_case. @@ -121,3 +121,32 @@ class Person extends Model { // Queries need to use the database casing. await Person.query().where('first_name', 'Jennifer'); ``` + +In TypeScript, the typings check the column names of queries against the model's properties. With `snakeCaseMappers`, declare the mapping on the model, so that queries take the snake case names of the properties, while `insert`, `patch` and the results keep the properties: + +```ts +class BaseModel extends Model { + static columnNameMappers = snakeCaseMappers(); + declare ColumnNameMapping: 'snakeCase'; +} + +class Person extends BaseModel { + firstName!: string; +} + +await Person.query().where('first_name', 'Jennifer'); +await Person.query().patch({ firstName: 'Jennifer' }); +``` + +`'snakeCase'` converts the names like `snakeCaseMappers()` with its default options. For `snakeCaseMappers({ upperCase: true })`, declare `'upperSnakeCase'`: + +```ts +class BaseModel extends Model { + static columnNameMappers = snakeCaseMappers({ upperCase: true }); + declare ColumnNameMapping: 'upperSnakeCase'; +} + +await Person.query().where('FIRST_NAME', 'Jennifer'); +``` + +For mappers with other options, e.g. `underscoreBeforeDigits`, or custom mappers, declare `ColumnNameMapping: 'open'`, which doesn't check the column names of the model's queries. With `knexSnakeCaseMappers`, nothing needs to be declared, as queries use the property names. diff --git a/doc/recipes/subqueries.md b/doc/recipes/subqueries.md index 4911658dc..e4baf6f74 100644 --- a/doc/recipes/subqueries.md +++ b/doc/recipes/subqueries.md @@ -31,7 +31,7 @@ console.log(peopleOlderThanAverage); You can use [ref](/api/objection/#ref) to reference the parent query in subqueries: ```js -const { ref } = require('objection'); +import { ref } from 'objection'; const peopleWithPetCount = await Person.query().select([ 'persons.*', diff --git a/doc/recipes/ternary-relationships.md b/doc/recipes/ternary-relationships.md index 53fa26cc0..2e18fa550 100644 --- a/doc/recipes/ternary-relationships.md +++ b/doc/recipes/ternary-relationships.md @@ -11,7 +11,9 @@ Here's how you could create your models: ```js // User.js -const { Model } = require('objection'); +import { Model } from 'objection'; +import Group from './Group.js'; +import Permission from './Permission.js'; class User extends Model { static get tableName() { @@ -22,7 +24,7 @@ class User extends Model { return { groups: { relation: Model.ManyToManyRelation, - modelClass: require('./Group'), + modelClass: Group, join: { from: 'user.id', through: { @@ -36,7 +38,7 @@ class User extends Model { permissions: { relation: Model.ManyToManyRelation, - modelClass: require('./Permission'), + modelClass: Permission, join: { from: 'user.id', through: { @@ -51,12 +53,14 @@ class User extends Model { } } -module.exports = User; +export default User; ``` ```js // Group.js -const { Model } = require('objection'); +import { Model } from 'objection'; +import User from './User.js'; +import Permission from './Permission.js'; class Group extends Model { static get tableName() { @@ -67,7 +71,7 @@ class Group extends Model { return { users: { relation: Model.ManyToManyRelation, - modelClass: require('./User'), + modelClass: User, join: { from: 'group.id', through: { @@ -81,7 +85,7 @@ class Group extends Model { permissions: { relation: Model.ManyToManyRelation, - modelClass: require('./Permission'), + modelClass: Permission, join: { from: 'group.id', through: { @@ -96,12 +100,14 @@ class Group extends Model { } } -module.exports = Group; +export default Group; ``` ```js // Permission.js -const { Model } = require('objection'); +import { Model } from 'objection'; +import User from './User.js'; +import Group from './Group.js'; class Permission extends Model { static get tableName() { @@ -112,7 +118,7 @@ class Permission extends Model { return { users: { relation: Model.ManyToManyRelation, - modelClass: require('./User'), + modelClass: User, join: { from: 'permission.id', through: { @@ -126,7 +132,7 @@ class Permission extends Model { groups: { relation: Model.ManyToManyRelation, - modelClass: require('./Group'), + modelClass: Group, join: { from: 'permission.id', through: { @@ -141,12 +147,15 @@ class Permission extends Model { } } -module.exports = Permission; +export default Permission; ``` ```js // UserGroupPermission.js -const { Model } = require('objection'); +import { Model } from 'objection'; +import User from './User.js'; +import Group from './Group.js'; +import Permission from './Permission.js'; class UserGroupPermission extends Model { static get tableName() { @@ -161,7 +170,7 @@ class UserGroupPermission extends Model { return { user: { relation: Model.BelongsToOneRelation, - modelClass: require('./User'), + modelClass: User, join: { from: 'user_group_permission.user_id', to: 'user.id' @@ -170,7 +179,7 @@ class UserGroupPermission extends Model { group: { relation: Model.BelongsToOneRelation, - modelClass: require('./Group'), + modelClass: Group, join: { from: 'user_group_permission.group_id', to: 'group.id' @@ -179,7 +188,7 @@ class UserGroupPermission extends Model { permission: { relation: Model.BelongsToOneRelation, - modelClass: require('./Permission'), + modelClass: Permission, join: { from: 'user_group_permission.permission_id', to: 'permission.id' @@ -189,7 +198,7 @@ class UserGroupPermission extends Model { } } -module.exports = UserGroupPermission; +export default UserGroupPermission; ``` Here's how you can query your models: diff --git a/doc/release-notes/changelog.md b/doc/release-notes/changelog.md index 59b6467f0..e410f0c65 100644 --- a/doc/release-notes/changelog.md +++ b/doc/release-notes/changelog.md @@ -1,5 +1,133 @@ # Changelog +## 4.0.0 + +### Breaking changes + +- objection is an ES module package with an `exports` map and no default export. Deep imports like `objection/lib/...` no longer work. See [migration](/release-notes/migration.md#objection-is-an-es-module-package) +- Node 20.19 or newer is required, or 22.12 or newer on node 22. See [migration](/release-notes/migration.md#dropped-support-for-node-20-19) +- The knex peer dependency is `^3.3.0`. See [migration](/release-notes/migration.md#dropped-support-for-knex-3-3) [#95](https://github.com/ditojs/objection/issues/95) +- The typings require TypeScript 5.8 or newer, and CI checks them with TypeScript 5.8 and 7. See [migration](/release-notes/migration.md#dropped-support-for-typescript-5-8) +- MySQL 8.0.17 or newer is required. MySQL 5.7 is no longer supported. See [migration](/release-notes/migration.md#dropped-support-for-mysql-8) [#292](https://github.com/ditojs/objection/issues/292) +- Many-to-many `patch()` / `update()` with join table extras update the related rows in one query instead of in batches of 500 values. On SQL Server they need SQL Server 2016 or newer, with a database compatibility level of 130 or higher, for `openjson`. Related rows with binary ids, which bind one value per row, fail above about 2100 rows on SQL Server and 999 on SQLite before 3.32, and related rows with composite ids on PostgreSQL above about 32,000 rows with two-column ids (65535 values). See [migration](/release-notes/migration.md#many-to-many-patches-with-join-table-extras-run-in-one-query) [#287](https://github.com/ditojs/objection/issues/287) +- The database error classes are objection's own, from a vendored copy of db-errors, and `instanceof` checks against classes from the `db-errors` package no longer match. See [migration](/release-notes/migration.md#database-errors-are-objection-s-own-classes) +- Model files given as paths are loaded with `require()`, so ES module model files need to export the model class as default export or as their only model class, and can't use top-level await. See [migration](/release-notes/migration.md#model-files-given-as-paths) +- The deprecated `skipUndefined()` is removed. `undefined` values passed to `where()` and the other SQL builder methods, and `undefined` ids, throw. See [migration](/release-notes/migration.md#skipundefined-is-removed) [#53](https://github.com/ditojs/objection/issues/53) +- The typings are ES module declarations instead of `export =`. See [migration](/release-notes/migration.md#typescript-the-typings-are-es-module-declarations) +- The `result` of the static `after*` hooks is what the query returns: a model with `first()`, `findById()`, `insert()` of a single model, `patchAndFetchById()` and queries for to-one relations, `undefined` if nothing is found, a number for updates and deletes, and arrays otherwise, instead of always an array. The hooks run as the last step of the query, after `first()` and the fetches of `insertAndFetch()` and `patchAndFetch()`, whose `afterInsert` and `afterUpdate` get the fetched model. In `before*` hooks, `result` is `undefined`. `insertAndFetch()` and `patchAndFetch()` throw if the `afterFind` of their fetch returns something else than the fetched models. See [migration](/release-notes/migration.md#static-hooks-get-the-result-in-the-shape-that-the-query-returns) [#66](https://github.com/ditojs/objection/issues/66) [#210](https://github.com/ditojs/objection/issues/210) +- `runAfter()` callbacks, also the ones of `context()`, `throwIfNotFound()` and `traverse()`, run after the static `after*` hooks and get the result in the shape that the query returns, also when they're added before `first()`, and before `page()` and `range()` wrap it in `{ results, total }`. See [migration](/release-notes/migration.md#static-hooks-get-the-result-in-the-shape-that-the-query-returns) [#66](https://github.com/ditojs/objection/issues/66) +- The outer queries of `upsertGraph()` and `upsertGraphAndFetch()` no longer call the static `beforeFind` and `afterFind` hooks of the root model class with the input models, nor `$afterFind` on the input models that they return, unless they read the written rows back with `returning()`. The queries that run for the graph call the hooks. See [migration](/release-notes/migration.md#static-hooks-get-the-result-in-the-shape-that-the-query-returns) [#66](https://github.com/ditojs/objection/issues/66) +- `fetchGraph()` / `$fetchGraph()` and the relations that `skipFetched` skips no longer call the static `beforeFind` hook of the models they already have, as they didn't call `afterFind`: the find hooks are only called for queries that run. See [migration](/release-notes/migration.md#static-hooks-get-the-result-in-the-shape-that-the-query-returns) [#66](https://github.com/ditojs/objection/issues/66) +- `withGraphFetched()` assigns what a relation query returns to the owners, after the static `afterFind` hook of the related model class and the `runAfter()` callbacks of the relation query, so the related models that they return are assigned, filtered or reordered. Returning other objects than these models throws an error, where 3.x assigned the related models before both and ignored their return values. The same applies to `$relatedQuery()` with `relatedFindQueryMutates`. See [migration](/release-notes/migration.md#withgraphfetched-assigns-what-the-relation-query-returns) [#1963](https://github.com/Vincit/objection.js/issues/1963) +- `withGraphJoined()` calls the static `afterFind` hooks of the joined related model classes, bottom-up after the query, with the related models of all owners, the `relation` and the owners as `items`, and assigns what they return like `withGraphFetched()` does with the same models, filtered or reordered. `asFindQuery()` and `cancelQuery()` throw in these hooks. See [migration](/release-notes/migration.md#withgraphfetched-assigns-what-the-relation-query-returns) [#1963](https://github.com/Vincit/objection.js/issues/1963) +- `StaticHookArguments` declares `result: R` with `R = undefined`, and the static hooks of `Model` declare the shape of `result` that they get. See [migration](/release-notes/migration.md#static-hooks-get-the-result-in-the-shape-that-the-query-returns) [#66](https://github.com/ditojs/objection/issues/66) +- `idColumn = undefined` means that the model has no primary key, like `idColumn = null`, instead of a column named `undefined` [#129](https://github.com/ditojs/objection/issues/129) +- `limit()`, `offset()` and `first()` of relations loaded with `withGraphFetched()` apply per parent by default: the `limitPerParent` graph option defaults to `true`. The relation query becomes a subquery numbered with `row_number()`, so its order can only refer to the related table's selected columns and their aliases. Set `limitPerParent: false` to limit the whole relation query for all parents as in 3.x. See [migration](/release-notes/migration.md#limit-offset-and-first-apply-per-parent-in-graph-modifiers) [#127](https://github.com/ditojs/objection/issues/127) [#429](https://github.com/Vincit/objection.js/issues/429) +- `insertGraph()`, `upsertGraph()` and their `*AndFetch()` variants throw an error when combined with `onConflict()`, `ignore()` or `merge()`, instead of ignoring the clause with a warning. See [migration](/release-notes/migration.md#onconflict-with-insertgraph-and-upsertgraph-throws) [#2156](https://github.com/Vincit/objection.js/issues/2156) +- The deprecated `allowMarkers` option is removed from the `InsertGraphOptions` typings. It has no effect since 3.8.0, where `insertGraph()` rejects `#unrelate` and `#delete` markers. See [migration](/release-notes/migration.md#typescript-insertgraph-has-no-allowmarkers-option) +- `snakeCaseMappers()` only converts the column part of field expressions like `patch({ 'jsonColumn:someKey': 1 })` and keeps their JSON keys as written: the `preserveJsonKeys` option defaults to `true`. Set `preserveJsonKeys: false` to convert the JSON keys to snake case too, as in 3.x. `knexSnakeCaseMappers()` never converted them. Keys with a `:` that the model's `jsonSchema` declares in `properties` are columns and converted as a whole, so a column like `ns:foo_bar` round-trips as `ns:fooBar`. See [migration](/release-notes/migration.md#snakecasemappers-keeps-the-json-keys-of-field-expressions) [#1089](https://github.com/Vincit/objection.js/issues/1089) +- `insert()` and `update()` reject field expression keys like `'meta:color'` with a `ValidationError` of type `ModelValidation`, as they write whole values. 3.x `update()` writes them with `jsonb_set` and validates them like a patch, and 3.x `insert()` passes them to the database as column names. Use `patch()` to write parts of json columns. Keys with a `:` that the schema declares in `properties`, e.g. `'ns:name'`, are normal properties and stay allowed, as do keys that don't parse as field expressions, e.g. `'ns:'`, which `patch()` and `merge()` also write as columns instead of failing to parse them. Only patch validation validates field expression keys against the json column's schema: `fromJson()` without `patch` validates them as top-level properties, as before 3.8. See [migration](/release-notes/migration.md#insert-and-update-reject-field-expression-keys) [#160](https://github.com/ditojs/objection/issues/160) +- BelongsToOne `unrelate()` applies the relation's `modify` / `filter` also without a where clause, and only unrelates owners whose related row matches it, like the other relations do, instead of unrelating all owners. With a filter, it clears the foreign keys of the owner models after the query, and only of those it unrelated [#234](https://github.com/ditojs/objection/issues/234) +- Relation expressions that name a relation twice, like `'[movies.reviews, movies.actors]'`, throw an error instead of using only the last one with a warning: in `withGraphFetched()`, `withGraphJoined()`, `allowGraph()`, `joinRelated()` and other relation expressions, and in the graph options of `insertGraph()` and `upsertGraph()`. See [migration](/release-notes/migration.md#relation-expressions-that-name-a-relation-twice-throw) [#131](https://github.com/ditojs/objection/issues/131) +- `insertGraph()`, `upsertGraph()` and their `*AndFetch()` variants throw an error for unknown graph options like `noInset` instead of ignoring them with a warning, and `insertGraph()` also for the options of `upsertGraph()` that have no effect on an insert, like `noDelete` or `allowMarkers`. See [migration](/release-notes/migration.md#unknown-graph-options-throw) [#84](https://github.com/ditojs/objection/issues/84) +- The type parameter of `RelateQueryBuilder` is the number query builder, e.g. `RelateQueryBuilder>` in custom query builders that override `relate()`. See [migration](/release-notes/migration.md#typescript-relatequerybuilder-takes-the-number-query-builder) +- Custom query builders declare one `RebindType` member instead of the five `ArrayQueryBuilderType`, `SingleQueryBuilderType`, `MaybeSingleQueryBuilderType`, `NumberQueryBuilderType` and `PageQueryBuilderType` members, which are removed: `declare RebindType: MyQueryBuilder`. The helper types `SingleQueryBuilder` etc. take a query builder, and `WithGraphModelQueryBuilder` and `ReturningMethod` are removed. See [migration](/release-notes/migration.md#typescript-custom-query-builders-declare-rebindtype) [#125](https://github.com/ditojs/objection/issues/125) [#319](https://github.com/Vincit/objection.js/issues/319) +- `static jsonSchema` is typed `ReadonlyJSONSchema`, a JSON schema with readonly arrays, so that it takes schemas declared `as const`. A model's schema read through the static type can't be passed where a `JSONSchema` is expected, unless the model declares `static jsonSchema: JSONSchema`. See [migration](/release-notes/migration.md#typescript-model-jsonschema-is-a-readonly-schema) [#16](https://github.com/ditojs/objection/issues/16) +- `insert()`, `patch()`, `update()`, their `*AndFetch*()` and `*ById()` variants, `merge()` and `relate()` reject relation properties, which they ignore at runtime: they take a `PartialModelRow`, a model instance or its properties without relations, instead of `PartialModelObject`. Values whose type includes the relation properties are rejected too, e.g. `Partial`, `ModelObject` or `PartialModelObject` of models with relations. `insertGraph()` and `upsertGraph()` take relations. See [migration](/release-notes/migration.md#typescript-insert-patch-and-update-reject-relation-data) [#53](https://github.com/ditojs/objection/issues/53) +- The typings check the column names and values of queries against the models, and the relation names of relation expressions. Columns qualified with relation paths like `pets:owner.name` are checked against the related models, in any order of the joins. Dynamic column names, columns of other queries and aliases of selections outside of `orderBy()` need `ref()`. `select()` and the aggregates narrow the result rows to the selected columns and aliases, whose aliases `orderBy()` takes too, and `jsonExtract()` adds its alias to the rows. Models that use `snakeCaseMappers()` declare `ColumnNameMapping: 'snakeCase'` (or `'upperSnakeCase'` with `upperCase: true`) to check the snake case names. Opt out with `TypeConfig { strict: false }`. See [migration](/release-notes/migration.md#typescript-queries-are-checked-against-the-models) [#16](https://github.com/ditojs/objection/issues/16) +- `distinctOn()` is typed to take column names, `raw()` and `ref()`, like `groupBy()`, instead of any selection, as it doesn't select its columns. +- `ModelObject`, `PartialModelObject` and the other property types leave out properties starting with `$`, which objection treats as internal, like `toJSON()` does. See [migration](/release-notes/migration.md#typescript-queries-are-checked-against-the-models) [#16](https://github.com/ditojs/objection/issues/16) +- `$relatedQuery()` of a to-one relation, e.g. `person.$relatedQuery('parent')`, resolves to `Model | undefined`, as it does at runtime when there is no related row. See [migration](/release-notes/migration.md#typescript-relatedquery-of-to-one-relations-may-resolve-to-undefined) [#2355](https://github.com/Vincit/objection.js/issues/2355) +- Writes with `returning()` resolve in the result kind of their query: `patch()`, `update()` and `delete()` through `$relatedQuery()` of a to-one relation resolve to the written model, or `undefined`, like the find query, instead of an array, also as `result` of the static `afterUpdate()` and `afterDelete()` hooks. The typings give writes with `returning()`, before or after the write, one model after `$query()`, `findById()`, `first()`, the `*ById()` methods and to-one `$relatedQuery()`, which they typed as arrays, and an array otherwise. Custom query builders that override `patchById()`, `updateById()` or `deleteById()` declare `NumberQueryBuilder>`. See [migration](/release-notes/migration.md#writes-of-to-one-relation-queries-with-returning-resolve-to-one-model) [#53](https://github.com/ditojs/objection/issues/53) +- `toJSON()` and `$toJson()` return `ModelJson`: the properties without methods, and the related models as JSON too, instead of `ModelObject` with model instances. See [migration](/release-notes/migration.md#typescript-tojson-returns-modeljson) [#2380](https://github.com/Vincit/objection.js/issues/2380) +- The rows of `relate().returning()` are typed `Pojo[]` instead of `any[]`, as they are join rows, related rows or owner rows depending on the relation, not models of the query. Cast them to the row type you expect. See [migration](/release-notes/migration.md#typescript-relate-returning-rows-are-plain-objects) +- `insertGraph()` throws a `ValidationError` of type `InvalidGraph` for new models below related models, instead of ignoring them with a warning. Models with ids below related models are still ignored. See [migration](/release-notes/migration.md#insertgraph-throws-for-new-models-below-related-models) [#217](https://github.com/ditojs/objection/issues/217) +- `upsertGraph()` removing the row of a `BelongsToOneRelation` with `pet: null` or `#delete` always sets the owner's foreign key to `null` before it deletes the row, like `unrelate()`, also when the owner's `jsonSchema` doesn't allow `null` for it. 3.9 deleted the row first in that case and left the owner's reference to the database. Owners whose schema only allows a number now fail with a `ValidationError` and `NOT NULL` columns with a database error, where `ON DELETE CASCADE` deleted the owner and `ON DELETE SET NULL` cleared the reference. See [migration](/release-notes/migration.md#upsertgraph-sets-the-foreign-key-to-null-before-deleting-a-belongstoonerelation-row) [#212](https://github.com/ditojs/objection/issues/212) +- `upsertGraph()` keeps the current model of a `HasOneRelation` or `HasOneThroughRelation` when the graph holds a model marked with `#unrelate` or `#delete` that isn't the current one, or a replacement that `noInsert` or `noRelate` skips, like for `BelongsToOneRelation` since 3.9. 3.x deleted or unrelated the current model. To-many relations are unchanged: their array is the full list. See [migration](/release-notes/migration.md#upsertgraph-keeps-the-current-to-one-model-for-ignored-models) [#261](https://github.com/ditojs/objection/issues/261) +- `upsertGraph()` throws a `NotFoundError` when a relate or update finds no row to write, e.g. a row moved below a new row while `ON DELETE CASCADE` deletes it with its old parent, or a row deleted by a concurrent query, instead of reporting success and returning the row. Writes that a `beforeUpdate` hook resolves, e.g. with `cancelQuery()`, and patches that leave no column to write still succeed [#214](https://github.com/ditojs/objection/issues/214) +- Aggregates like `count()` and `sum()` select their aliases, not the columns they're passed, but still replace the select all of the query. `withGraphFetched()` and `withGraphJoined()` select the join columns that queries with aggregates like `count('* as n')` or `count('ownerId')` need, where 3.x left them out and returned empty relations. `withGraphFetched()` and relation queries use an aggregate, raw SQL or a subquery aliased as a join column, like `max('ownerId as ownerId')`, instead of selecting the column again. Grouped queries need to group by these columns too, or the database rejects them. In `withGraphJoined()`, the root query selects only its id columns next to aggregates instead of all its columns. See [migration](/release-notes/migration.md#aggregates-don-t-select-the-columns-they-re-passed) [#111](https://github.com/ditojs/objection/issues/111) +- Relation expressions with anything after `*` or `^` in a relation path, like `'parent.^.pets'`, `'*.pets'` or `'children.*.pets'`, or with `*` and `^` in a list, like `'[children, *]'`, throw an error instead of silently ignoring the rest: in `withGraphFetched()`, `withGraphJoined()`, `allowGraph()`, `joinRelated()` and other relation expressions, and in the graph options of `insertGraph()` and `upsertGraph()`. See [migration](/release-notes/migration.md#misplaced-and-in-relation-expressions-throw) [#221](https://github.com/ditojs/objection/issues/221) +- `update()`, `patch()` and `delete()` throw an error for `offset()` when the query is executed, instead of warning that knex leaves it out, and on MySQL, `delete()` throws for `orderBy()` with `limit()`, as knex leaves out the order. A many-to-many `patch()` / `update()` with join table extras on MySQL and SQL Server honours the offset and doesn't throw. See [migration](/release-notes/migration.md#offset-in-updates-and-deletes-throws) [#286](https://github.com/ditojs/objection/issues/286) [#235](https://github.com/ditojs/objection/issues/235) +- JSON field expressions (`ref('column:path')`, `'column:path'` keys in `patch()`), `ref('column').castJson()` and the `whereJson*()` methods of objection throw an error on SQL Server and Oracle instead of warning, as they generate SQL that these databases don't understand, and so do `whereJsonSupersetOf()` and `whereJsonSubsetOf()` on SQLite. Other databases than PostgreSQL, MySQL and SQLite still get a warning. See [migration](/release-notes/migration.md#json-field-expressions-throw-on-sql-server-and-oracle) [#113](https://github.com/ditojs/objection/issues/113) +- On MySQL, the casts of `ref()` and `val()` use MySQL's types: `castFloat()` is `DOUBLE` instead of `FLOAT` and `castReal()` `FLOAT` instead of `DOUBLE`, like `float` and `real` in PostgreSQL, and `castDecimal()` is `DECIMAL(65,30)` instead of `DECIMAL`, which rounds to integers. `castText()`, `castInt()`, `castBigInt()` and `castBool()`, which MySQL rejected, are `CHAR` and `SIGNED`. On MySQL and SQLite, `val()` with an object or array and `val().castJson()` are json values, `CAST(? AS JSON)` on MySQL and `json(?)` on SQLite, instead of text. See [migration](/release-notes/migration.md#casts-json-values-and-json-methods-on-mysql-and-sqlite) [#276](https://github.com/Vincit/objection.js/issues/276) +- `$setRelated()` and `$appendRelated()` keep the foreign keys in sync with the relation. For a `BelongsToOneRelation` the object's foreign key is set to the related model's key, e.g. `post.$setRelated('author', user)` sets `post.authorId`, or to `null` when the relation is cleared or the related model has no key. For `HasOneRelation` and `HasManyRelation` the related models' foreign keys are set to the object's key, or to `null` when the object has no key, and those of the models that are removed or replaced are set to `null` if they still point to the object. Conflicting foreign keys are overwritten. Many-to-many and has-one-through relations are unchanged, and `fromJson()`, `$setJson()` and queries don't sync keys. See [migration](/release-notes/migration.md#setrelated-and-appendrelated-sync-foreign-keys) [#2001](https://github.com/Vincit/objection.js/issues/2001) +- `$fetchGraph()`, `fetchGraph()` and `$relatedQuery()` throw an error when owner models loaded from the database are missing a join property, e.g. an id left out of a partial `select()`, instead of warning and finding no related models for them. Owner models built in memory, e.g. with `fromJson()`, find no related models for a missing join property without a warning. This includes the foreign key of a `BelongsToOneRelation`, which 3.x didn't report unless it was part of the id. Models returned by `insert()` aren't loaded from the database, also with `returning()`. `$clone()` keeps whether a model was loaded from the database. See [migration](/release-notes/migration.md#missing-and-array-join-properties-throw) [#1832](https://github.com/Vincit/objection.js/issues/1832) +- `withGraphFetched()` and `$fetchGraph()` throw an error when related models come back without their join property, e.g. snake_case relation mappings with `knexSnakeCaseMappers()`, instead of warning and dropping them. See [migration](/release-notes/migration.md#missing-and-array-join-properties-throw) [#2258](https://github.com/Vincit/objection.js/issues/2258) +- Fetching a relation for owner models whose join property holds an array, e.g. a JSON array of keys, throws an error instead of warning and finding no related models. `$relatedQuery()` keeps working for them. See [migration](/release-notes/migration.md#missing-and-array-join-properties-throw) [#114](https://github.com/ditojs/objection/issues/114) +- `upsertGraph()` relates the rows that move to existing owners of a `HasManyRelation` or `HasOneRelation` before it deletes the removed rows, so rows moved out of a deleted parent, up a level or to a sibling are kept, where `ON DELETE CASCADE` deleted them and foreign keys without `ON DELETE` action failed. The update hooks of these relates run before the delete hooks. Rows that wait for the keys or references of models that the graph inserts are related after the inserts, as in 3.x. A relate into a `HasOneRelation` still waits for the removal of the current model, or for the relate that moves it to another owner, also a new one or one whose slot is freed first. A row related from another owner that takes the unique key of a row that the graph removes in its new place, e.g. with `unique(ownerId, name)`, fails with a `UniqueViolationError`, where 3.x removed the other row first, and so does a new row that takes the unique key of a row that moves to a new owner, where 3.x cleared the moved row's key first. See [migration](/release-notes/migration.md#upsertgraph-relates-moved-rows-before-it-deletes) [#214](https://github.com/ditojs/objection/issues/214) +- `findById()` and `findByIds()` add their id condition in its own parentheses, like `$query()` and the `*ById()` writes, so `orWhere()` can't widen it: `findById(1).orWhere(b)` is `id = 1 and (b)`, where 3.x built `id = 1 or b`, and `where(a).orWhere(b).findById(1)` is `(a or b) and id = 1`. See [migration](/release-notes/migration.md#findbyid-and-findbyids-keep-their-id-condition-in-its-own-group) [#271](https://github.com/ditojs/objection/issues/271) +- On PostgreSQL drivers (`pg`, `pgnative`, `cockroachdb`), JSON attributes keep the values that the driver returns, so a `json` / `jsonb` column holding a string that looks like JSON, e.g. `'123'` or `'{"a":1}'`, is no longer parsed a second time, and JSON in `text` columns is no longer parsed. Set `jsonColumnFormat = 'text'` on models that keep JSON in `text` columns on PostgreSQL. Other drivers parse JSON attributes as in 3.x. See [migration](/release-notes/migration.md#json-in-postgresql-text-columns-is-no-longer-parsed) [#205](https://github.com/ditojs/objection/issues/205) +- `upsertGraph()` upserts the models below related models in the same graph, instead of in nested `upsertGraph()` calls after all other writes: their rows are fetched together, their writes run in the same steps as the rest of the graph, and `#ref` can point between them. A related model with relations whose row doesn't exist throws a `NotFoundError` before anything is written, where 3.x wrote the rest of the graph first. Rows that the graph relates into a `HasManyRelation` or `HasOneRelation` from elsewhere are moved first, and the models below them are upserted in a pass of their own after the rest of the graph, against their rows after the move, like 3.x did. Errors about the models below them are thrown after the rest of the graph is written, which stays written without a transaction. See [migration](/release-notes/migration.md#upsertgraph-upserts-below-related-models-in-the-same-graph) +- `upsertGraph()` doesn't patch existing models that the graph gives no changes, e.g. a model with only its id and an `#id`, or a related model whose BelongsToOne key stays the same, so their update hooks don't run, where 3.x patched them and ran the hooks +- `upsertGraph()` patches the join rows of existing many-to-many models only with the join table extras that change, and with none under `noUpdate`, where 3.x wrote all extras that the graph gives with every patch of the related model. With `noUpdate`, a model whose changes `noUpdate` drops isn't patched and runs no update hooks, where 3.9.0 ran its `$beforeUpdate` hook once without writing +- `insertGraph()` and `upsertGraph()` throw a `ValidationError` of type `InvalidGraph` for graphs that give one row two different values for the same column, or the slot of a `HasOneRelation` two rows, e.g. a row under two owners through the same foreign key, or under an owner while its own `BelongsToOneRelation` names another model, or a model related twice through a many-to-many relation with different join table extras, instead of writing them in an undefined order that could delete rows the graph names. A model related twice with the same extras gets one join row. See [migration](/release-notes/migration.md#insertgraph-and-upsertgraph-reject-rows-placed-in-conflicting-ways) +- `upsertGraph()` checks that the models it relates exist, also through `#dbRef`, and throws a `NotFoundError` "model (id=N) does not exist" before anything is written if one doesn't and the graph doesn't insert it elsewhere, for all relation types. 3.9.0 only checked related models with relations of their own: it patched no row for a missing `HasManyRelation` or `HasOneRelation` model, and wrote the foreign key or join row of a missing `BelongsToOneRelation` or `ManyToManyRelation` model. Each pass of the graph fetches the ids of the related models without relations in one query per model class. See [migration](/release-notes/migration.md#upsertgraph-checks-that-related-models-exist) [#372](https://github.com/ditojs/objection/issues/372) +- `insertGraph()` and `upsertGraph()` start the data paths of models in array input with their index in the array: the keys of `ValidationError`s, the `dataPath` of `NotFoundError`s and the `dataPath` option that validators get, e.g. `[1].pets[0].name` instead of `pets[0].name`, which 3.x gave for every model of the array. Graphs of a single object keep their paths. See [migration](/release-notes/migration.md#insertgraph-and-upsertgraph-errors-name-the-index-of-array-input) +- `insertGraph()` and `upsertGraph()` throw a `ValidationError` of type `InvalidGraph` before anything is written for a `#ref` to a new model, or a `#ref{}` to the id of a new model, that the graph doesn't write, e.g. below a model that `noRelate` ignores or that is marked with `#delete`, or skipped by `noInsert`. Other `#ref{}` read the models as the graph gives them, also in branches that the graph doesn't write. In 3.x, a `#ref` into a branch that `noRelate`, `#delete` or `#unrelate` leave out failed with an error, sometimes after writing, and a `#ref{}` into such a branch wrote the `#ref{}` text itself, also for ids. A plain `#ref` to a model that `noInsert` skipped silently wrote `null`, and a `#ref{}` to its id wrote `null`, or failed after writing in a key that the relation sets too. See [migration](/release-notes/migration.md#ref-to-new-models-that-the-graph-doesn-t-write-throws) [#53](https://github.com/ditojs/objection/issues/53) +- `insertGraph()` and `upsertGraph()` write in fewer queries: rows are deleted with one query per relation, with all owners as `items` in the static hooks, instead of one query per owner. See [migration](/release-notes/migration.md#insertgraph-and-upsertgraph-write-in-fewer-queries) +- Custom relation classes that extend `Relation` directly declare where their keys are stored with a `keySide` getter (`'owner'`, `'related'` or `'join'`) to be used in `insertGraph()` and `upsertGraph()`. Relations that extend the built-in relation classes inherit it +- `insertGraph()` and `upsertGraph()` never return the `#id`, `#ref`, `#dbRef`, `#unrelate` and `#delete` markers, where 3.x returned them on models that the graph ignored or removed, and graph options with values of the wrong type throw also when no model of the graph reads them +- `insertGraph()` and `upsertGraph()` keep only the branches below a model that `noInsert` skips that a `#ref` or `#ref{}` of a written model points into, directly or through other kept branches, and write no join rows for the skipped model. In 3.9.0, one reference kept the whole branch, so the referenced model's siblings were inserted without foreign keys, and its many-to-many relations got join rows without the skipped model's key, which failed on NOT NULL join columns. An existing row that the graph only places in such a pruned branch is now removed where its owner elsewhere leaves it out, like below a skipped model that nothing references, where 3.9.0 kept it whenever a reference kept the branch. See [migration](/release-notes/migration.md#insertgraph-and-upsertgraph-keep-only-the-referenced-branches-below-noinsert-models) [#369](https://github.com/ditojs/objection/issues/369) + +### Features + +- `insert()` of several models works on SQLite (3.35 or newer) through `returning`, and on all databases when all models have their ids or the model has no `idColumn`, e.g. join rows. Rows that don't fit into one query, or that leave out columns that other rows have on SQLite, are inserted in several queries, which are only atomic in a transaction. `insertGraph()` and `upsertGraph()` insert in batches there too, where 3.x inserted one row per query on SQLite, so the static `beforeInsert` and `afterInsert` hooks run once per batch with several models +- `BelongsToManyRelation`, a relation where the owner holds an array of the related models' keys, e.g. `posts.tagIds = [1, 4, 7]` for `post.tags`, in a JSON column on all databases or a native array column on PostgreSQL. It supports finding the related models with `$relatedQuery()`, `relatedQuery()`, `withGraphFetched()`, `withGraphJoined()` and `joinRelated()`. `insert()`, `delete()`, `relate()` and `unrelate()` through the relation rewrite the arrays, `insertGraph()` and `upsertGraph()` write them, and `$setRelated()` and `$appendRelated()` keep them in sync. The reads of the keys that these writes run call no find hooks. See [relations](/guide/relations.md) [#2667](https://github.com/Vincit/objection.js/issues/2667) +- `Model.jsonColumnFormat` says how the database returns JSON columns: `'auto'` (default) leaves them as they are on PostgreSQL drivers and parses JSON text on others, `'text'` parses them strictly and throws for invalid JSON, `'parsed'` never parses them [#205](https://github.com/ditojs/objection/issues/205) +- JSON field expressions work on MySQL and SQLite: in `ref()`, e.g. `ref('column:a.b').castInt()` in `select()`, `where()`, `orderBy()`, joins and relation mappings, as keys in `patch()` and `merge()`, e.g. `patch({ 'column:a.b': 1 })`, and in the `whereJson*()` methods, except for `whereJsonSupersetOf()` and `whereJsonSubsetOf()` on SQLite. objection writes the SQL of each database, `JSON_EXTRACT()` and `JSON_SET()` on MySQL and `json_extract()` and `json_set()` on SQLite, and casts to their types. `ref().castJson()` works on both too. See [JSON queries](/recipes/json-queries.md) [#276](https://github.com/Vincit/objection.js/issues/276) +- `asArray()` is exported, to turn the `result` of static hooks into an array [#66](https://github.com/ditojs/objection/issues/66) +- The hooks of the queries that `relate()` and `unrelate()` run get the `relation` and its owners as `items`: the update hooks of the related model class for `HasManyRelation`, of the owner model class for `BelongsToOneRelation` and `BelongsToManyRelation` (which also runs find hooks), and the insert and delete hooks of the join model class for `ManyToManyRelation` [#66](https://github.com/ditojs/objection/issues/66) +- `modifyJoin()` modifies the `ON` clause of the joins of `joinRelated()` and `withGraphJoined()`, with the aliases of the owner, related and join tables, e.g. to compare the related table's columns with the owner's [#1975](https://github.com/Vincit/objection.js/issues/1975) + +### Fixes + +- The error of `upsertGraph()` queries with other query builder calls names `$query()`, which adds such a call too [#1168](https://github.com/Vincit/objection.js/issues/1168) +- `upsertGraphAndFetch()` loads the relations of a `withGraphFetched()` on its query with their modifiers, named modifiers, aliases and options into the fetched models, like `insertGraphAndFetch()`, instead of ignoring the modifiers and aliases [#839](https://github.com/Vincit/objection.js/issues/839) +- On SQLite, `insert()` stores arrays in columns that aren't `jsonAttributes` as JSON, like objects, instead of `'[object Object]'` +- `insertGraph()` and `upsertGraph()` resolve a `#ref{}` in a foreign key that the relation sets too, instead of failing with `replace is not a function` [#1378](https://github.com/Vincit/objection.js/issues/1378) +- `insertGraph()` and `upsertGraph()` insert several models without properties on PostgreSQL, instead of failing with "The query is empty" [#1694](https://github.com/Vincit/objection.js/issues/1694) +- `insertGraph()` and `upsertGraph()` fetch the keys that the database generates for inserted rows, like a uuid that isn't the id, when relations join through them, instead of writing join rows and foreign keys without them [#853](https://github.com/Vincit/objection.js/issues/853) +- `upsertGraph()` gives new models the keys of their existing parents that the graph leaves out, e.g. a `code` column that the relation joins through, instead of inserting them with NULL +- `insertGraph()` and `upsertGraph()` stay under the number of values that a query can bind on SQL Server, PostgreSQL and SQLite when they insert, patch and remove many rows, and fetch many roots or related models +- A many-to-many `patch()` / `update()` with join table extras and `limit()` on MySQL and SQL Server can order by columns of the join table [#161](https://github.com/ditojs/objection/issues/161) +- `asFindQuery()` in the static update hooks of a many-to-many `patch()` / `update()` with join table extras finds more related rows than SQL Server binds values in one statement, instead of failing after the rows were patched, as it did since 3.8.0. These patches filter the related rows by their ids in one statement instead of in batches of 500 values, bound as one JSON value on SQL Server and SQLite and as one array on PostgreSQL. See the breaking change above for binary and composite ids [#287](https://github.com/ditojs/objection/issues/287) +- A many-to-many `patch()` / `update()` with join table extras and `limit()` on MySQL and SQL Server writes the extras to the join rows of the related rows that the limit picks when the relation joins through a related column other than the id, instead of to the join rows that refer to other rows by these ids, as it did since 3.8.0 [#360](https://github.com/ditojs/objection/issues/360) +- `cancelQuery()` with a single row in the `beforeInsert` hook of an `insert()` merges it into the inserted model, like an array of rows, instead of ignoring it [#66](https://github.com/ditojs/objection/issues/66) +- `insertGraph()` and `upsertGraph()` reject only after the queries they already started have finished, so a transaction is rolled back after them. Before, rows of these queries could be committed after the rollback on SQLite. [#335](https://github.com/ditojs/objection/pull/335) +- `upsertGraph()` sets the foreign key of a model that a `#ref` relates into a `HasManyRelation` or `HasOneRelation`. Before, the row was left without it, and the id of the owner could be cleared [#311](https://github.com/ditojs/objection/issues/311) +- `upsertGraph()` no longer exposes the `objectiontmpjoin*` aliases of the owner join columns on the many-to-many related models of the graphs that it loads, e.g. in the `items` of the static hooks of its deletes, as 3.9.0 did [#370](https://github.com/ditojs/objection/issues/370) +- `patch()` and `merge()` with field expression keys like `'meta:color'` keep writing them when the query is built more than once, e.g. with `toKnexQuery()` before running it, or through a `clone()`, instead of resolving to 0 without a write. Building the query no longer changes the data of `patch()` and `merge()`: the patched model keeps these keys until the write has run, so `$afterUpdate()` sees them, and then loses them, also with `snakeCaseMappers()`, so the models that `$query().patch()`, `patchAndFetch*()` and `upsertGraph()` return don't have them [#53](https://github.com/ditojs/objection/issues/53) +- `upsertGraph()` with `relate` and `noUpdate` writes the foreign keys of models that it relates through a `HasManyRelation` or `HasOneRelation`, like it does for `BelongsToOneRelation` and `ManyToManyRelation`, instead of leaving them unrelated without an error, as it did since 1.5.0 [#53](https://github.com/ditojs/objection/issues/53) +- `upsertGraph()` resolves a `#ref{}` to a value of an existing model, e.g. a foreign key that an inserted related model gives it, once the graph has filled it in, instead of reading its old value [#356](https://github.com/ditojs/objection/issues/356) +- `upsertGraph()` with the `unrelate` option moves a has-many row that the graph relates to another owner through the same relation without unrelating it first, which failed on a NOT NULL foreign key when the new owner is inserted [#214](https://github.com/ditojs/objection/issues/214) +- `upsertGraph()` keeps a row that the graph keeps elsewhere when it replaces the row as the `BelongsToOneRelation` model of another one, e.g. a root whose new boss is the model it was the boss of: `{ id: 2, boss: { id: 1, boss: { id: 3 } } }` with 2 as the boss of 1, where 3.x deleted the root +- `upsertGraph()` keeps the join table extras of an existing join row when a `#ref` relates the many-to-many model again, where 3.x set them to `null` +- `upsertGraph()` inserts a new row into a `HasOneRelation` after the current row moves out of it to a new owner, and after the rows that move along with it from slot to slot, instead of failing with a `UniqueViolationError` on a unique foreign key. A current row that moves out only in a later pass of the graph, below a row that moves to another owner first, is unrelated before a row goes into its slot, and related in the later pass [#368](https://github.com/ditojs/objection/issues/368) [#398](https://github.com/ditojs/objection/issues/398) +- The models that `upsertGraph()` returns write all their props in later patches, e.g. with `$query().patch()`, instead of leaving out the props that the upsert found unchanged, as they did since 3.9.0 [#371](https://github.com/ditojs/objection/issues/371) +- `upsertGraph()` patches rows whose foreign keys point to each other one after the other, e.g. a row and the model that it relates both as its `BelongsToOneRelation` and its `HasOneRelation` model, instead of at the same time, which could fail with `ER_LOCK_DEADLOCK` on MySQL outside of transactions, as it could in 3.9.0 [#385](https://github.com/ditojs/objection/issues/385) +- `upsertGraph()` writes a model once when a `#ref` places it where the graph places the model itself, e.g. `pets: [{ '#id': 'p', name: 'new' }, { '#ref': 'p' }]`, where 3.x patched it again after writing it, which ran its update hooks +- `upsertGraph()` writes the props of a model marked with `#unrelate` through a `#ref` to it in another place, e.g. `{ id: 1, boss: { name: 'new', movies: [{ id: 3, '#id': 'm', title: 't', '#unrelate': true }] }, movies: [{ '#ref': 'm' }] }` writes the title, where 3.9.0 dropped the props, also those of the `#ref` +- `insertGraph()` and `upsertGraph()` write only the join table extras that a many-to-many `#ref` gives itself to its join row, and never write the extras of the join row of the model it refers to into other rows, e.g. `[{ name: 'a', movies: [{ '#ref': 'm' }] }, { name: 'b', movies: [{ '#id': 'm', title: 't', role: 'lead' }] }]` gives the join row of `a` no role, where 3.x copied `role: 'lead'` to it, and a `#ref` in a HasMany relation no longer fails with a missing `role` column +- `upsertGraph()` on SQLite throws for `BigInt` ids, which the SQLite driver binds as `NULL`, where 3.x wrote `NULL` keys or patched nothing +- `upsertGraph()` throws a `ValidationError` of type `InvalidGraph` before anything is written for a graph that changes a key that is part of a row's id, e.g. with `idColumn = ['ownerId', 'name']`, by relating the row to another owner, or by pointing its own `BelongsToOneRelation` to another row or clearing it with `null`, `#unrelate` or `#delete`. 3.9.0 wrote nothing and returned the model as changed, or threw a `NotFoundError` for its relations +- `upsertGraph()` keeps a row that the graph places in a HasMany or HasOne relation when another relation on the same key column leaves it out, e.g. pet 4 in `{ id: 2, pets: [{ id: 3 }, { id: 4, siblings: [] }] }` with `siblings` joined on `ownerId`, where 3.x unrelated it + +### Types + +- `InsertGraphOptions` includes `noInsert` and `noRelate`, which `insertGraph()` supports [#84](https://github.com/ditojs/objection/issues/84) +- Faster type checking of query builders +- `FromSchema` derives the properties of a model from its `jsonSchema`, declared `as const`. See [models](/guide/models.md#deriving-model-properties-from-the-json-schema-in-typescript) [#16](https://github.com/ditojs/objection/issues/16) +- `patch()`, `patchById()`, `patchAndFetch()`, `patchAndFetchById()` and `merge()` take field expression keys of the model's columns, like `'address:street'`, typed as `PartialModelPatch`. `insert()` and `update()` still reject them. + +### Other + +- `insertGraph()` and `upsertGraph()` are rewritten, with close to a third less code. Graphs write in far fewer queries, e.g. a 500 row insert in 6 queries instead of 500 on SQLite +- The tests run with vitest instead of mocha +- TypeScript 7 for development +- sqlite3 6 for development and the examples +- CI tests node 20.19.0 and up +- The examples and docs use ES modules + ## 3.9.0 ### Fixes @@ -351,7 +479,7 @@ Thanks to everyone whose pull requests made it into this release: @amit-meshbey, - Patch Validator: Prevent recursion on inner properties [#2520](https://github.com/Vincit/objection.js/pull/2520) ## 3.1.2 - + ### What's new - Patch Validator: Prevent recursion on inner properties [#2520](https://github.com/Vincit/objection.js/pull/2520) @@ -1196,7 +1324,7 @@ Person.prototype.fullName = function () { ### Breaking changes - Undefined values as query method arguments now throw an exception. Before they were just silently ignored - and for example `delete().where('id', undefined)` caused the entire table to be deleted. [skipUndefined](/api/query-builder/other-methods.html#skipundefined) + and for example `delete().where('id', undefined)` caused the entire table to be deleted. [skipUndefined](/release-notes/migration.html#skipundefined-is-removed) method can be called for a query builder to handle the undefined values the old way. - Deprecated method `dumpSql` is now removed. diff --git a/doc/release-notes/migration.md b/doc/release-notes/migration.md index 514d402d9..01a8cb322 100644 --- a/doc/release-notes/migration.md +++ b/doc/release-notes/migration.md @@ -1,3 +1,589 @@ +# Migration from objection 3.x to 4.0 + +This section lists the breaking changes in objection 4.0. + +- [objection is an ES module package](#objection-is-an-es-module-package) +- [Dropped support for node < 20.19](#dropped-support-for-node-20-19) +- [Dropped support for knex < 3.3](#dropped-support-for-knex-3-3) +- [Dropped support for TypeScript < 5.8](#dropped-support-for-typescript-5-8) +- [Dropped support for MySQL < 8](#dropped-support-for-mysql-8) +- [Database errors are objection's own classes](#database-errors-are-objection-s-own-classes) +- [Model files given as paths](#model-files-given-as-paths) +- [skipUndefined() is removed](#skipundefined-is-removed) +- [onConflict() with insertGraph() and upsertGraph() throws](#onconflict-with-insertgraph-and-upsertgraph-throws) +- [Relation expressions that name a relation twice throw](#relation-expressions-that-name-a-relation-twice-throw) +- [Unknown graph options throw](#unknown-graph-options-throw) +- [insertGraph() throws for new models below related models](#insertgraph-throws-for-new-models-below-related-models) +- [upsertGraph() sets the foreign key to null before deleting a BelongsToOneRelation row](#upsertgraph-sets-the-foreign-key-to-null-before-deleting-a-belongstoonerelation-row) +- [upsertGraph() keeps the current to-one model for ignored models](#upsertgraph-keeps-the-current-to-one-model-for-ignored-models) +- [offset() in updates and deletes throws](#offset-in-updates-and-deletes-throws) +- [JSON field expressions throw on SQL Server and Oracle](#json-field-expressions-throw-on-sql-server-and-oracle) +- [Casts, json values and json methods on MySQL and SQLite](#casts-json-values-and-json-methods-on-mysql-and-sqlite) +- [Missing and array join properties throw](#missing-and-array-join-properties-throw) +- [upsertGraph() relates moved rows before it deletes](#upsertgraph-relates-moved-rows-before-it-deletes) +- [upsertGraph() upserts below related models in the same graph](#upsertgraph-upserts-below-related-models-in-the-same-graph) +- [insertGraph() and upsertGraph() write in fewer queries](#insertgraph-and-upsertgraph-write-in-fewer-queries) +- [insertGraph() and upsertGraph() reject rows placed in conflicting ways](#insertgraph-and-upsertgraph-reject-rows-placed-in-conflicting-ways) +- [upsertGraph() checks that related models exist](#upsertgraph-checks-that-related-models-exist) +- [insertGraph() and upsertGraph() errors name the index of array input](#insertgraph-and-upsertgraph-errors-name-the-index-of-array-input) +- [`#ref` to new models that the graph doesn't write throws](#ref-to-new-models-that-the-graph-doesn-t-write-throws) +- [insertGraph() and upsertGraph() keep only the referenced branches below noInsert models](#insertgraph-and-upsertgraph-keep-only-the-referenced-branches-below-noinsert-models) +- [typescript: The typings are ES module declarations](#typescript-the-typings-are-es-module-declarations) +- [Static hooks get the result in the shape that the query returns](#static-hooks-get-the-result-in-the-shape-that-the-query-returns) +- [withGraphFetched() assigns what the relation query returns](#withgraphfetched-assigns-what-the-relation-query-returns) +- [`limit`, `offset` and `first` apply per parent in graph modifiers](#limit-offset-and-first-apply-per-parent-in-graph-modifiers) +- [typescript: insertGraph() has no allowMarkers option](#typescript-insertgraph-has-no-allowmarkers-option) +- [typescript: RelateQueryBuilder takes the number query builder](#typescript-relatequerybuilder-takes-the-number-query-builder) +- [typescript: Custom query builders declare RebindType](#typescript-custom-query-builders-declare-rebindtype) +- [typescript: Model.jsonSchema is a readonly schema](#typescript-model-jsonschema-is-a-readonly-schema) +- [typescript: insert(), patch() and update() reject relation data](#typescript-insert-patch-and-update-reject-relation-data) +- [typescript: Queries are checked against the models](#typescript-queries-are-checked-against-the-models) +- [typescript: $relatedQuery() of to-one relations may resolve to undefined](#typescript-relatedquery-of-to-one-relations-may-resolve-to-undefined) +- [Writes of to-one relation queries with returning() resolve to one model](#writes-of-to-one-relation-queries-with-returning-resolve-to-one-model) +- [typescript: toJSON() returns ModelJson](#typescript-tojson-returns-modeljson) +- [typescript: relate().returning() rows are plain objects](#typescript-relate-returning-rows-are-plain-objects) +- [`insert` and `update` reject field expression keys](#insert-and-update-reject-field-expression-keys) +- [`snakeCaseMappers` keeps the JSON keys of field expressions](#snakecasemappers-keeps-the-json-keys-of-field-expressions) +- [Aggregates don't select the columns they're passed](#aggregates-don-t-select-the-columns-they-re-passed) +- [Misplaced `*` and `^` in relation expressions throw](#misplaced-and-in-relation-expressions-throw) +- [`$setRelated` and `$appendRelated` sync foreign keys](#setrelated-and-appendrelated-sync-foreign-keys) +- [findById() and findByIds() keep their id condition in its own group](#findbyid-and-findbyids-keep-their-id-condition-in-its-own-group) +- [JSON in PostgreSQL text columns is no longer parsed](#json-in-postgresql-text-columns-is-no-longer-parsed) + +## objection is an ES module package + +objection is now published as an ES module only. Import it like any other ES module: + +```js +import { Model, raw, ref } from 'objection'; +``` + +objection has no default export. Replace `import objection from 'objection'` with a namespace import, which contains the same values: + +```js +import * as objection from 'objection'; +``` + +CommonJS code can still load objection with `require()`, because node 20.19 and newer can `require()` ES modules that don't use top-level await: + +```js +const { Model, raw, ref } = require('objection'); +``` + +`require('objection')` returns the module namespace object, with all the named exports. + +The package now has an `exports` map, so only `objection` and `objection/package.json` can be imported. Deep imports of internal files like `objection/lib/...` no longer work. + +## Dropped support for node < 20.19 + +Objection 4.0 needs at least node 20.19, the first version of node 20 that can `require()` ES modules without a flag. Node 22 needs at least 22.12. + +## Dropped support for knex < 3.3 + +Objection 4.0 needs at least knex 3.3. + +## Dropped support for TypeScript < 5.8 + +The typings of objection 4.0 need at least TypeScript 5.8, the first version that understands that node can `require()` ES modules. TypeScript 5.8, 6 and 7 are supported. + +## Dropped support for MySQL < 8 + +Objection 4.0 needs at least MySQL 8.0.17, for window functions, for derived tables that refer to the outer query, and for `JSON_OVERLAPS()` in `whereJsonHasAny()` and `whereJsonHasAll()` and the `MEMBER OF` operator of `BelongsToManyRelation`. MySQL 5.7 reached its end of life in October 2023. 3.x keeps supporting it. + +## Many-to-many patches with join table extras run in one query + +A many-to-many `patch()` / `update()` with join table extras updates the related rows by their ids in one query, instead of in batches of 500 values as in 3.8.0 and 3.9.0. The ids are bound as one value where the database allows it: + +- SQL Server reads them as one JSON value with `openjson`, which needs SQL Server 2016 or newer and a database compatibility level of 130 or higher. SQL Server 2014 reached its end of life in July 2024. +- Binary ids, e.g. `binary(16)` UUIDs, can't be represented in JSON and bind one value per row. On SQL Server, such a patch fails above about 2100 related rows, and on SQLite before 3.32 above 999. +- Composite ids on PostgreSQL bind one value per column, which fails above 65535 values, about 32,000 related rows with two-column ids. + +Run such patches on fewer related rows at once, e.g. by filtering them in several queries in a transaction. 3.x keeps the batches. + +## Database errors are objection's own classes + +The database error classes (`DBError`, `UniqueViolationError`, `NotNullViolationError`, `ForeignKeyViolationError`, `ConstraintViolationError`, `CheckViolationError` and `DataError`) are no longer imported from the [db-errors](https://github.com/Vincit/db-errors) library. objection includes its own copy of it, and doesn't depend on the `db-errors` package anymore. + +The classes have the same names and properties, but `instanceof` checks against classes imported directly from the `db-errors` package no longer match the errors objection throws. Import the classes from objection instead: + +```js +// Before: +const { UniqueViolationError } = require('db-errors'); + +// After: +import { UniqueViolationError } from 'objection'; +``` + +## Model files given as paths + +`modelClass` can still be an absolute file path, or a module name resolved using [modelPaths](/api/model/static-properties.html#static-modelpaths). objection loads the file synchronously with `require()`, so the file can be a CommonJS module or an ES module. An ES module needs to export the model class as its default export, or as its only exported model class. + +ES module model files can't use top-level await, or import modules that do, because `require()` can't load those. Paths without a file extension are resolved like `require()` resolves them, which finds `.js` files, but not `.mjs` files. Give the full file name for those, or pass the model class itself, or a function that returns it, which also avoids the problem of circular imports. + +## skipUndefined() is removed + +The deprecated `skipUndefined()` method is removed. `undefined` values passed to `where()` and the other SQL builder methods, and `undefined` ids, throw. Leave out the calls with `undefined` values instead: + +```js +// Before: +Person.query().skipUndefined().where('firstName', req.query.firstName); + +// After: +const query = Person.query(); + +if (req.query.firstName !== undefined) { + query.where('firstName', req.query.firstName); +} +``` + +## onConflict() with insertGraph() and upsertGraph() throws + +`insertGraph()`, `upsertGraph()` and their `*AndFetch()` variants throw an error when the query also uses `onConflict()`, `ignore()` or `merge()`. 3.x ignored these clauses and logged a warning. A graph is inserted using many insert queries, so there's no single query to apply the clause to. Insert the rows that may conflict using a separate `insert()` query and relate them in the graph, for example using `#dbRef` or the `relate` option. + +## Relation expressions that name a relation twice throw + +Relation expressions that name a relation twice at the same level, like `'[movies.reviews, movies.actors]'` or `'[movies, movies.reviews]'`, throw an error. 3.x logged a warning and only used the last one. This applies to `withGraphFetched()`, `withGraphJoined()`, `allowGraph()`, `joinRelated()` and all other methods that take relation expressions, and to the graph options of `insertGraph()` and `upsertGraph()`. Merge the relations instead, like `'movies.[reviews, actors]'`, or name the deepest relation only, like `'movies.reviews'`, which also loads `movies`. Graph options can use an array like `['movies', 'movies.reviews']` to match a relation together with relations below it. + +## Unknown graph options throw + +`insertGraph()`, `upsertGraph()` and their `*AndFetch()` variants throw an error for unknown keys in their options, like a misspelled `noInset` instead of `noInsert`. 3.x ignored them and logged a warning, so the operation ran with the default behaviour. `insertGraph()` also throws for the options of `upsertGraph()` that have no effect on an insert, which 3.x ignored silently: `noUpdate`, `noDelete`, `noUnrelate`, `insertMissing`, `update`, `unrelate`, `fetchStrategy` and `allowMarkers`. It accepts `relate`, `noRelate`, `noInsert` and `allowRefs`. Fix or remove the reported keys. + +## insertGraph() throws for new models below related models + +`insertGraph()` throws a `ValidationError` of type `InvalidGraph` for new models (models without id) below models that it relates, naming their data path. 3.x ignored them and logged a warning, as `insertGraph()` never writes anything below related models. Insert them with a separate query, or use `upsertGraph()` with the `relate` option, which also upserts the relations of related models. Models with ids below related models, like fetched models with loaded relations, are still ignored. + +## upsertGraph() sets the foreign key to null before deleting a BelongsToOneRelation row + +When `upsertGraph()` deletes the related row of a `BelongsToOneRelation` because of `pet: null` or `#delete`, it first sets the owner's foreign key to `null`, like `unrelate()`, whatever the owner's `jsonSchema` says. 3.9 only did this when the schema allowed `null` for the key, and otherwise deleted the row first and left the owner's reference to the database. Now an owner whose schema doesn't allow `null`, like `{ type: 'integer' }`, fails with a `ValidationError`, and a `NOT NULL` column fails with a database error, where `ON DELETE CASCADE` deleted the owner and `ON DELETE SET NULL` cleared the reference. Allow `null` in the schema and the column, or delete the owner itself if it can't exist without the related row. + +## upsertGraph() keeps the current to-one model for ignored models + +In a `HasOneRelation` or `HasOneThroughRelation`, `upsertGraph()` keeps the current model when the graph only holds a model that it ignores: a model marked with `#unrelate` or `#delete` that isn't the current one, or a replacement that `noInsert` or `noRelate` skips. The relation is left as if it wasn't in the graph, as for `BelongsToOneRelation` since 3.9. 3.x deleted or unrelated the current model. To remove the current model, mark it with its own id, like `{ pet: { id: 1, '#delete': true } }`, or pass `pet: null`. To-many relations are unchanged: an array is the full list, so `pets: [{ id: 2, '#delete': true }]` still removes the current pets. + +## offset() in updates and deletes throws + +`update()`, `patch()` and `delete()` throw an error for `offset()` when the query is executed. knex leaves the offset out of these statements, and on SQL Server the limit too, so they modified other rows than the offset asked for. 3.9 logged a warning. On MySQL, `delete()` also throws for `orderBy()` together with `limit()`, as knex leaves out the order but keeps the limit, so the limit deleted rows in any order. Fetch the ids of the rows to modify in a separate query and filter by them with `whereIn()`, or drop the offset if it was never applied. A many-to-many `patch()` / `update()` with join table extras on MySQL and SQL Server already selects the related rows first, so it honours the offset and doesn't throw. + +## JSON field expressions throw on SQL Server and Oracle + +JSON field expressions like `ref('column:path')` and `'column:path'` keys in `patch()`, `ref('column').castJson()` and the `whereJson*()` methods of objection throw an error on SQL Server and Oracle. 3.x generated the SQL of PostgreSQL, which these databases reject, and logged a warning. Use the JSON methods of knex instead, like `whereJsonPath()` and `jsonExtract()`. Other databases than PostgreSQL, MySQL and SQLite still get a warning. + +On MySQL and SQLite, objection writes the SQL of the database for json field expressions and the `whereJson*()` methods, where 3.x generated the SQL of PostgreSQL, which they reject or, worse, run with other results, e.g. MySQL reads the `#>` operator as the start of a comment. SQLite has no json containment, so `whereJsonSupersetOf()` and `whereJsonSubsetOf()` throw an error there. + +## Casts, json values and json methods on MySQL and SQLite + +The casts of `ref()` and `val()` use the types of MySQL there, as `CAST()` on MySQL only accepts a few types. `castText()`, `castInt()`, `castBigInt()` and `castBool()` were rejected by MySQL before and are `CHAR` and `SIGNED` now. These change their results: + +- `castFloat()` is `DOUBLE` instead of `FLOAT`, and `castReal()` is `FLOAT` instead of `DOUBLE`, like `float` and `real` on PostgreSQL. Use `castTo('FLOAT')` or `castTo('DOUBLE')` for the previous types. +- `castDecimal()` is `DECIMAL(65,30)` instead of `DECIMAL`, which rounds to integers. Use `castTo('DECIMAL(10,2)')` or similar for a fixed precision. +- Casts of json values in field expressions turn json `null` into `NULL`, like on PostgreSQL and SQLite, instead of `0` or `'null'`. + +On MySQL and SQLite, `val()` with an object or array and `val().castJson()` are json values, `CAST(? AS JSON)` on MySQL and `json(?)` on SQLite, so they compare as json. Before, they were json text, which MySQL compared as a string. Pass `JSON.stringify(value)` to compare with json text. + +On SQLite, `ref('column').castJson()` throws for a whole column, as SQLite can't tell json columns from text columns. Cast the column first, e.g. `ref('name').castText().castJson()`, or refer to a json path. + +## Missing and array join properties throw + +Fetching relations with `withGraphFetched()`, `$fetchGraph()`, `fetchGraph()` or `$relatedQuery()` throws an error in these cases, where 3.x logged a warning and returned no related models: + +- Owner models loaded from the database are missing a join property, e.g. because a partial `select()` left out the id. Select the column in the owner query. Owner models built in memory, e.g. with `fromJson()`, may not have their keys yet and still find no related models, now without a warning. This includes the foreign key of a `BelongsToOneRelation`, which 3.x didn't report. Models returned by `insert()` count as built in memory, also with `returning()`. +- Related models come back without their join property, e.g. because the relation mapping uses column names that `knexSnakeCaseMappers()` maps to other property names. Use the property names of the models in the relation mapping. +- An owner join property holds an array of keys, e.g. a JSON array column. Use a `BelongsToManyRelation` instead, or a `ManyToManyRelation` through a join table. `$relatedQuery()` without `withGraphFetched()` or `$fetchGraph()` keeps working for them. + +## upsertGraph() relates moved rows before it deletes + +`upsertGraph()` relates the rows that move to existing owners of a `HasManyRelation` or `HasOneRelation` first, before it deletes or unrelates the rows that the graph removes. 3.x deleted first, so a row that moved out of a deleted parent was deleted with it by `ON DELETE CASCADE`, and failed with a foreign key without `ON DELETE` action. The update hooks of these relates now run before the delete hooks of the same call. Rows that wait for the keys or references of models that the graph inserts, e.g. a name from a `#ref{}` to an inserted model, are related after the inserts, as in 3.x, so the relate throws a `NotFoundError` when `ON DELETE CASCADE` deleted the row with its old parent. + +A row related from another owner can no longer take the unique key of a row that the graph removes in its new place, e.g. with `unique(ownerId, name)`: the relate fails with a `UniqueViolationError`. Delete the conflicting row with a separate query in the same transaction first. Replacing such a row by a new one, or renaming a kept row to its key, still works, as do relates into a `HasOneRelation` with a unique foreign key. + +A new row can't take the unique key of a row that moves to a new owner either: the move waits for the insert of the new owner, so the row still holds its key when the new row is inserted. 3.x cleared the moved row's foreign key first, which fails for `NOT NULL` keys. Insert the new owner with a separate call first, or make the unique constraint deferred and run `upsertGraph()` in a transaction. + +## upsertGraph() upserts below related models in the same graph + +3.x upserted the models below a related model with a nested `upsertGraph()` call for each owner and relation, after all other writes of the graph. 4.0 fetches the rows of the related models with their relations together with the rest of the graph, and writes them in the same steps, so their deletes, inserts and patches run with the others of their kind instead of last. The options keep matching the full relation paths. + +A related model with relations whose row doesn't exist throws a `NotFoundError` saying that the model doesn't exist, before anything is written. 3.x wrote the rest of the graph first and then failed in the nested call, or with a foreign key error. + +Rows that the graph relates into a `HasManyRelation` or `HasOneRelation` from another owner are the exception: their relations change with the move, e.g. the `owner` of a pet that moves to another person, so the models below them are upserted in a pass of their own, after the rest of the graph, against their rows as they are after the move, like 3.x did. Errors about the models below them, like a model that isn't a child and isn't related, are thrown after the rest of the graph is written, so without a transaction the rest of the graph stays written: run `upsertGraph()` in a transaction. A row is written once for the move and once more if the models below it change its keys. A `#ref` from the rest of the graph can point to such models only if they have ids, and a `#ref{}` not at all, as they're written after it. + +## insertGraph() and upsertGraph() reject rows placed in conflicting ways + +A graph that gives one row two different values for the same column, or the slot of a `HasOneRelation` two different rows, throws a `ValidationError` of type `InvalidGraph` that names both data paths, before anything is written. A row is identified by its table and id, and a `#ref` or `#dbRef` model is a place of the row that it refers to. For example: + +- the row is placed under two owners through the same foreign key, e.g. a pet in the `pets` of two people, also through `#ref`; +- the row is placed under an owner through a `HasManyRelation` or `HasOneRelation`, and its own `BelongsToOneRelation` names another model, e.g. `{ id: 1, pets: [{ id: 4, owner: { name: 'new' } }] }` or a child with another `parent`; +- one place gives the row a foreign key prop that another place sets through a relation, e.g. `[{ id: 1, children: [{ id: 2 }] }, { id: 2, parentId: 5 }]`; +- the row appears twice with different values for the same prop; +- two places give the same `HasOneRelation` of an owner two different rows, or one row and `null`, also when one of the rows gets the owner's key through a foreign key prop or its own `BelongsToOneRelation`, e.g. `{ id: 1, profile: null }` next to `{ id: 6, person: { id: 1 } }`. A `HasOneRelation` with a `filter` only counts the rows placed through it; +- the row is marked with `#delete` in one place and kept in another, or marked with `#unrelate`, which clears its key, while another place puts it under an owner; +- a many-to-many pair is marked with `#unrelate` in one place and related or kept in another, e.g. `{ id: 1, movies: [{ id: 3, '#id': 'm', '#unrelate': true }, { '#ref': 'm' }] }`, as the pair has one join row; +- a model is related twice through a many-to-many relation with different join table extras, e.g. `{ id: 1, movies: [{ id: 2, role: 'director' }, { id: 2, role: 'producer' }] }`. A pair of models has one join row, as in 3.x, which wrote the last extras to it. To relate a pair more than once, model the join table with an id column of its own and relate its rows instead, e.g. as a `credits` relation from the person to a `Credit` model that belongs to the movie. + +3.x wrote these graphs in an order that the graph doesn't define, and often deleted rows that the graph names, like the root. A graph that places a row the same way more than once stays valid, e.g. `{ id: 1, pets: [{ id: 4, owner: { id: 1 } }] }`, and so does a row in places that write different columns, e.g. as a `child` and as a `boss`. A foreign key prop that a model gives next to its own relation, e.g. an old `parentId` next to a new `parent`, still follows the relation, and so does the key of a model that a relation places, e.g. a fetched pet with its old `ownerId` moved into the `pets` of another person. A row that one place of an owner lists and another place of the same owner leaves out is kept, as the graph keeps it in the first place. + +## upsertGraph() checks that related models exist + +`upsertGraph()` fetches the ids of the models that it relates, with `relate` or through `#dbRef`, and throws a `NotFoundError` before anything is written if one of them doesn't exist and the graph doesn't insert it elsewhere, e.g. with `insertMissing`: + +```js +await Person.query().upsertGraph({ id: 1, pets: [{ id: 1 }, { id: 99 }] }, { relate: true }); +// NotFoundError: model (id=99) does not exist. If you want to insert it with an id, use the insertMissing option +``` + +3.x only checked the related models that the graph gave relations of their own. For the others, it patched no row for a `HasManyRelation` or `HasOneRelation`, and wrote the foreign key or the join row of the missing model for a `BelongsToOneRelation` or `ManyToManyRelation`. Code that relied on these keys to missing rows, e.g. without foreign key constraints, needs to insert the rows first, or use `insertMissing`. `#ref` models are the models they refer to, and are only fetched when they have relations. + +## insertGraph() and upsertGraph() errors name the index of array input + +The data paths of models in array input start with their index in the array. This applies to the keys of the `data` of a `ValidationError`, the `dataPath` of a `NotFoundError`, and the `dataPath` option that a custom `Validator` gets. In 3.x, all models of the array had the same paths, so the path didn't tell which one failed: + +```js +await Person.query().insertGraph([{ firstName: 'Jennifer' }, { pets: [{ name: 1 }] }]); +// 3.x: err.data['pets[0].name'] +// 4.0: err.data['[1].pets[0].name'] +``` + +`NotFoundError`s give `dataPath: [1, 'pets', 0]` instead of `['pets', 0]`, and validators get `'[1].pets[0]'` instead of `'.pets[0]'`. Graphs of a single object keep their paths. + +## `#ref` to new models that the graph doesn't write throws + +A `#ref` to a new model, or a `#ref{}` to the id of a new model, that the graph doesn't write throws a `ValidationError` of type `InvalidGraph` before anything is written, e.g. for a model that `noInsert` skips, or below a model that `noRelate` ignores or that is marked with `#delete` or `#unrelate`. For references of models below rows that `upsertGraph()` moves into a `HasManyRelation` or `HasOneRelation`, it's thrown once the rest of the graph is written, before these models are. Point the reference to a model that the graph writes, or give the referenced model its id. Other `#ref{}` read the models as the graph gives them, also in branches that the graph doesn't write. In 3.x, a `#ref` into a branch that `noRelate`, `#delete` or `#unrelate` leave out failed with an error, sometimes after writing, and a `#ref{}` into such a branch wrote the `#ref{}` text itself, also for ids. A plain `#ref` to a model that `noInsert` skipped silently wrote `null`, and a `#ref{}` to its id wrote `null`, or failed after writing in a key that the relation sets too. See [the guide](/guide/query-examples.md#graph-inserts) for the rule. + +## insertGraph() and upsertGraph() keep only the referenced branches below noInsert models + +Below a model that `noInsert` skips, nothing is written, except for the branches that a `#ref` or `#ref{}` of a written model points into, directly or through other kept branches. In 3.9.0, one such reference kept the whole branch of the skipped model, with all its other related models. + +An existing row that the graph only places below a skipped model, in a branch that no reference keeps, isn't kept there anymore. If its owner elsewhere in the graph leaves it out, it's removed there, like any row that the graph doesn't keep: + +```js +// Person 1 has pet 2. +await Org.query().upsertGraph( + { + id: 1, + people: [ + { id: 1, pets: [] }, + { id: 2, favoritePet: { '#ref': 'cat' } }, + // Skipped by `noInsert`, but `cat` is referenced: + { name: 'new', pets: [{ id: 2 }, { '#id': 'cat', name: 'Cat' }] }, + ], + }, + { relate: true, noInsert: ['people'], allowRefs: true }, +); +// 3.9.0: pet 2 is kept, as the reference to `cat` kept the whole branch. +// 4.0: pet 2 is deleted, as only the branch of `cat` is kept. +``` + +To keep such a row, place it where the graph writes it, or use `noDelete` for its owner's relation. + +## insertGraph() and upsertGraph() write in fewer queries + +Rows that the graph deletes are deleted with one query per relation instead of one per owner, so the static `beforeDelete` and `afterDelete` hooks run once with all owners as `items`. Join rows and unrelated rows are still removed per owner. + +## typescript: The typings are ES module declarations + +The typings no longer use `export =`. They declare the named exports of the ES module they describe. Apart from the default import, ES module and bundler projects need no changes. + +TypeScript projects that compile to CommonJS can `require()` objection with `"module": "nodenext"`. With `"module": "node16"`, TypeScript refuses to `require()` objection from CommonJS files. On TypeScript < 7, projects that use `"module": "commonjs"` with the old `"moduleResolution": "node"` keep working. TypeScript 7 removed that resolution mode, so CommonJS projects need `"module": "nodenext"` there. + +## Static hooks get the result in the shape that the query returns + +The `result` of the static `after*` hooks is what the query returns, instead of an array that wraps it: + +- `afterFind` gets the model of `first()`, `findById()` and queries for to-one relations like `pet.$relatedQuery('owner')`, or `undefined` if nothing is found. With `page()` and `range()`, it still gets the array of models. +- `afterInsert` gets the inserted model when a single model is inserted, and runs after the fetch of `insertAndFetch()`, with the fetched values. +- `afterUpdate` and `afterDelete` get the number of updated or deleted rows. With `patchAndFetchById()`, `patchAndFetch()` and their `update*` variants, `afterUpdate` runs after the fetch and gets the fetched model, or `undefined` if nothing was updated. +- In `before*` hooks, `result` is `undefined` instead of `[]`. + +Code like `result.map(...)`, `result[0]` or `result.length` fails for single models, numbers and `undefined`. Use `asArray()` to get an array in all cases: + +```js +import { Model, asArray } from 'objection'; + +class Person extends Model { + static afterFind({ result }) { + for (const person of asArray(result)) { + // ... + } + } +} +``` + +The static `after*` hooks run as the last step of the query, after `first()`, `page()`'s models and the fetches of the `*AndFetch()` methods, but before the `runAfter()` callbacks, including the ones in `context()`, `throwIfNotFound()` and `traverse()`. These callbacks now get the result in the shape that the query returns and after the hooks, e.g. the model with `first()` also if `runAfter()` is called before `first()`, and they run before `page()` and `range()` wrap the result in `{ results, total }`. + +The outer queries of `upsertGraph()` and `upsertGraphAndFetch()` no longer call the static `beforeFind` and `afterFind` hooks of the root model class with the input models. The queries that run for the models of the graph and their relations call the hooks as before. Likewise, `fetchGraph()` / `$fetchGraph()` and the relations that `skipFetched` skips no longer call the static `beforeFind` hook of the models they already have, as they didn't call `afterFind`: the find hooks are only called for queries that run. + +TypeScript: `StaticHookArguments` declares `result: R`, with `R = undefined` by default, and the static hooks of `Model` declare the shapes that they get, e.g. `StaticHookArguments` for `afterFind`. Overrides that destructure `result` from `StaticHookArguments` need the type of the result as second type argument, e.g. `StaticHookArguments`. + +## withGraphFetched() assigns what the relation query returns + +`withGraphFetched()` assigns what a relation query returns to the owners, after the static `afterFind` hook of the related model class and the `runAfter()` callbacks of the relation query, e.g. in modifiers or `modifyGraph()`. 3.x assigned the related models before both and ignored their return values. Related models that they filter out are no longer assigned, and the order that they return is kept. They can only return the related models that the query found. Other objects, e.g. copies like `result.map((pet) => ({ ...pet }))` or a count, throw an error. Change the models in place instead, or transform them in `runAfter()` of the root query. + +`withGraphJoined()` now calls the static `afterFind` hooks of the joined related model classes too, and assigns what they return in the same way. The relations have no query of their own, so `asFindQuery()` and `cancelQuery()` throw an error in these hooks. `afterFind` hooks that call them fail for joined relations. `beforeFind` isn't called for joined relations. + +## `limit`, `offset` and `first` apply per parent in graph modifiers + +The `limitPerParent` [graph option](/api/types/#type-graphoptions) of `withGraphFetched` defaults to `true`, so `limit`, `offset` and `first` in graph modifiers and in the `modify` of relation mappings apply to the related items of each parent instead of to the whole query that fetches the relation for all parents: + +```js +// Up to three pets per person. In 3.x, up to three pets for all persons together. +const people = await Person.query() + .withGraphFetched('pets(latest)') + .modifiers({ + latest: (query) => query.orderBy('createdAt', 'desc').limit(3) + }); +``` + +The relation query becomes a subquery, and its order moves into a `row_number()` window function. The order can only refer to the related table's columns that are selected and to the aliases of the result columns, see [limiting related items per parent](/api/query-builder/eager-methods.html#limiting-related-items-per-parent). + +To keep the behaviour of 3.x, set `limitPerParent: false` in the graph options, or in [defaultGraphOptions](/api/model/static-properties.html#static-defaultgraphoptions) for all queries of a model. + +## typescript: insertGraph() has no allowMarkers option + +`InsertGraphOptions` no longer has the deprecated `allowMarkers` option. `insertGraph()` rejects `#unrelate` and `#delete` markers since 3.8.0, so the option had no effect. Remove it from `insertGraph()` calls. + +## typescript: RelateQueryBuilder takes the number query builder + +The type parameter of `RelateQueryBuilder` is the number query builder that `relate()` returns, instead of the query builder `relate()` is called on. Custom query builders that override `relate()` with an explicit return type use `RelateQueryBuilder>` instead of `RelateQueryBuilder`. + +## typescript: Custom query builders declare RebindType + +Custom query builders declare one `RebindType` member, from which the query builder types of `first()`, `findById()`, `patch()`, `page()` and the other methods that change the result type are derived. The five `*QueryBuilderType` members of 3.x are removed. Replace them: + +```ts +// 3.x +class MyQueryBuilder extends QueryBuilder { + declare ArrayQueryBuilderType: MyQueryBuilder; + declare SingleQueryBuilderType: MyQueryBuilder; + declare MaybeSingleQueryBuilderType: MyQueryBuilder; + declare NumberQueryBuilderType: MyQueryBuilder; + declare PageQueryBuilderType: MyQueryBuilder>; +} + +// 4.0 +class MyQueryBuilder extends QueryBuilder { + declare RebindType: MyQueryBuilder; +} +``` + +The helper types `SingleQueryBuilder`, `MaybeSingleQueryBuilder`, `ArrayQueryBuilder`, `NumberQueryBuilder` and `PageQueryBuilder` take any query builder, e.g. `MaybeSingleQueryBuilder` as the return type of an overridden `findById()`, and `NumberQueryBuilder>` for `patchById()`, `updateById()` and `deleteById()`, the writes of `findById()`. `WithGraphModelQueryBuilder` is removed. Use `QueryBuilderType>` instead. The `ReturningMethod` interface is removed too, `returning()` is a method of `QueryBuilder`. On query builders with an `any` result, e.g. `AnyQueryBuilder`, it returns `QueryBuilder | QueryBuilder` instead of `QueryBuilder`. + +## typescript: Model.jsonSchema is a readonly schema + +`static jsonSchema` is typed `ReadonlyJSONSchema`, a JSON schema with readonly arrays, so that schemas declared `as const` can be assigned to it, for `FromSchema`. A `JSONSchema` is assignable to it, but not the other way around: a model's schema read through the static type can't be passed to a parameter typed `JSONSchema`, or returned from `$beforeValidate()`. Declare the type on models that are used that way: + +```ts +import { JSONSchema, Model } from 'objection'; + +class Person extends Model { + static jsonSchema: JSONSchema = { + type: 'object', + required: ['firstName'], + properties: { firstName: { type: 'string' } } + }; +} + +function describeSchema(schema: JSONSchema) { + // ... +} + +describeSchema(Person.jsonSchema); +``` + +Code that reads the schema of any model class, e.g. `modelClass.jsonSchema` in plugins or helpers, takes it as `ReadonlyJSONSchema`. + +## typescript: insert(), patch() and update() reject relation data + +`insert()`, `patch()`, `update()` and their variants, `merge()` and `relate()` only write the columns of the model's own table and ignore relation properties. Their argument type is `PartialModelRow`: a model instance, or the model's properties without relations. Objects with relation data are type errors, and so are values whose type includes the relation properties, even if they hold no relation data: `Partial`, `ModelObject` (e.g. the result of `toJSON()`) and `PartialModelObject`, which these methods took in 3.x. Type such values as `PartialModelRow`, or pass the model instance: + +```ts +// 3.x: accepted, and `pets` silently ignored +await Person.query().insert({ firstName: 'Jennifer', pets: [{ name: 'Fluffy' }] }); + +// 4.0: insertGraph() inserts the relations +await Person.query().insertGraph({ firstName: 'Jennifer', pets: [{ name: 'Fluffy' }] }); + +// 4.0: type data without relations as PartialModelRow +function insertPerson(data: PartialModelRow) { + return Person.query().insert(data); +} +``` + +Custom query builders that override these methods use `PartialModelRow` for the data instead of `PartialModelObject`, and `PartialModelPatch` for `patch()`, its variants and `merge()`, which also takes field expression keys like `'address:street'`. + +## typescript: Queries are checked against the models + +The typings check the column names and values of queries against the models: `where('firstNme', …)`, `where('age', 'thirty')` or `withGraphFetched('petz')` are type errors. See [Checking queries in TypeScript](/guide/models.html#checking-queries-in-typescript) for the rules. Code that the checks reject: + +- Column names in variables typed `string`: pass them with `ref(column)`, or type them as a union of the model's columns. +- Columns of other queries, e.g. of an outer query in a subquery, and aliases of selections in `groupBy()` or `having()`: use `ref('persons.id')`. +- Properties of results that a `select()` leaves out: they aren't in the result type anymore. Select them, or select `*`. +- Columns that the model doesn't declare as properties: declare them, or use `ref()` / `raw()`. +- Column methods after `withGraphFetched()` / `withGraphJoined()` on a generic query builder, e.g. `this.$query().withGraphFetched('pets').where('id', this.id)` in a model method: TypeScript resolves the narrowed query builder to a union there, which only takes `ref()` / `raw()` columns, e.g. `where(ref('id'), this.id)`. +- Models with `snakeCaseMappers()`: declare `ColumnNameMapping: 'snakeCase'` on the model, so that the snake case names in queries are checked, see [Snake case to camel case conversion](/recipes/snake-case-to-camel-case-conversion.html). + +`ModelObject`, `PartialModelObject` and the other property types leave out properties starting with `$`, which objection treats as internal, like `toJSON()` does. Properties like `$app` declared on models aren't columns either. + +To keep the typings of 3.x, opt out of the checks: + +```ts +declare module 'objection' { + interface TypeConfig { + strict: false; + } +} +``` + +## typescript: $relatedQuery() of to-one relations may resolve to undefined + +`$relatedQuery()` of a `BelongsToOneRelation`, `HasOneRelation` or `HasOneThroughRelation` resolves to `undefined` when there is no related row, and its type says so now: + +```ts +const parent = await person.$relatedQuery('parent'); // Person | undefined + +// Throw instead, if the row has to exist: +const parent = await person.$relatedQuery('parent').throwIfNotFound(); // Person +``` + +## Writes of to-one relation queries with returning() resolve to one model + +`patch()`, `update()` and `delete()` with [returning](/api/query-builder/find-methods.html#returning) through `$relatedQuery()` of a `BelongsToOneRelation`, `HasOneRelation` or `HasOneThroughRelation` resolve to the written model, or `undefined` if there is no related row, like the find query does: only several written rows, which a has-one relation can have, stay an array. 3.x resolved to an array. The `result` of the static `afterUpdate()` and `afterDelete()` hooks of these writes is the model too. `relatedQuery()` with `for()` still resolves to an array: + +```js +// Before: +const [parent] = await person.$relatedQuery('parent').patch({ age: 50 }).returning('*'); + +// After: +const parent = await person.$relatedQuery('parent').patch({ age: 50 }).returning('*'); +``` + +The typings give writes with `returning()` the result kind of their query everywhere: one model, or `undefined`, after `$query()`, `findById()`, `first()`, the `*ById()` methods and `$relatedQuery()` of to-one relations, also with `returning()` before the write, and an array otherwise. They typed writes of `findById()`, `first()` and the `*ById()` methods as arrays, although they resolve to one model too. Custom query builders that override `patchById()`, `updateById()` or `deleteById()` declare `NumberQueryBuilder>` as their return type, see [Custom query builders declare RebindType](#typescript-custom-query-builders-declare-rebindtype). + +## typescript: toJSON() returns ModelJson + +`toJSON()` and `$toJson()` return `ModelJson`, which describes the plain object they produce: the model's properties without methods, and the related models as `ModelJson` too. They returned `ModelObject`, whose relations claimed model instances. Type the values that hold the result of `toJSON()` as `ModelJson` instead of `ModelObject`: + +```ts +function send(person: ModelJson) { + // ... +} + +send(person.toJSON()); +``` + +## typescript: relate().returning() rows are plain objects + +The rows of `relate().returning()` are typed `Pojo[]` instead of `any[]`: depending on the relation, they are the join rows, the related rows or the owner rows, but not models of the query. Cast them to the rows you expect: + +```ts +const rows = await person.$relatedQuery('movies').relate(50).returning('*'); +const joinRows = rows as { personId: number; movieId: number }[]; +``` + +## `insert` and `update` reject field expression keys + +[insert](/api/query-builder/mutate-methods.html#insert) and [update](/api/query-builder/mutate-methods.html#update) write whole values, so they throw a [ValidationError](/api/types/#class-validationerror) of type `ModelValidation` for [field expression](/api/types/#type-fieldexpression) keys like `'meta:color'`, which write parts of json columns. 3.x `update` wrote them with `jsonb_set`, and 3.x `insert` passed them to the database as column names. Use [patch](/api/query-builder/mutate-methods.html#patch) for them: + +```js +// Before: +await Person.query().update({ name: 'Jennifer', 'meta:color': 'red' }).where('id', 1); + +// After: +await Person.query().patch({ name: 'Jennifer', 'meta:color': 'red' }).where('id', 1); +``` + +Keys with a `:` that the model's [jsonSchema](/api/model/static-properties.html#static-jsonschema) declares in its `properties`, like `'ns:name'`, are normal properties and columns, and `insert` and `update` write them as before. To insert into a column with a `:` in its name, declare it in the schema. + +## `snakeCaseMappers` keeps the JSON keys of field expressions + +[snakeCaseMappers](/api/objection/#snakecasemappers) only converts the column part of [field expressions](/api/types/#type-fieldexpression) used as property names, and keeps their JSON keys as written: the `preserveJsonKeys` option defaults to `true`. 3.x converted the JSON keys too: + +```js +// 4.0: update "persons" set "json_column" = jsonb_set("json_column", '{someKey}', '1', true) +// 3.x: update "persons" set "json_column" = jsonb_set("json_column", '{some_key}', '1', true) +await Person.query().patch({ 'jsonColumn:someKey': 1 }); +``` + +If your JSON columns contain snake_case keys written this way, set `preserveJsonKeys: false` to keep converting them: + +```js +class Person extends Model { + static get columnNameMappers() { + return snakeCaseMappers({ preserveJsonKeys: false }); + } +} +``` + +Keys with a `:` that the model's [jsonSchema](/api/model/static-properties.html#static-jsonschema) declares in its `properties` are columns, not field expressions, and are converted as a whole. A column like `ns:foo_bar` is read as `ns:fooBar` and only written back to `ns:foo_bar` if the schema declares `ns:fooBar`. Declare such columns in the schema. [knexSnakeCaseMappers](/api/objection/#knexsnakecasemappers) never converted the JSON keys and is unchanged. + +## Aggregates don't select the columns they're passed + +Aggregates like `count()` and `sum()` select their aliases, not the columns they're passed, but they still replace the select all of a query. When a query with aggregates loads relations with `withGraphFetched()` or `withGraphJoined()`, objection selects the columns it needs to assign the related models. 3.x treated `count('* as n')` as selecting all columns and `count('ownerId')` as selecting `ownerId`, so these columns were missing and the relations came back empty: + +```js +// 4.0: select count(*) as "n", "pets"."ownerId" from "pets" group by "ownerId" +// 3.x: select count(*) as "n" from "pets" group by "ownerId", and every owner is null +await Pet.query().count('* as n').groupBy('ownerId').withGraphFetched('owner'); +``` + +Grouped queries need to group by the added columns too, or the database rejects them. Add the join columns to `groupBy()`, like `groupBy('ownerId')` above. In `withGraphJoined()`, the root query selects only its id columns next to the aggregates instead of all its columns. Select the columns you need explicitly. + +## Misplaced `*` and `^` in relation expressions throw + +`*` and `^` end a relation path. 3.x silently ignored everything after them, so `'parent.^.pets'` fetched `parent` recursively without `pets`, and `'*.pets'` fetched all relations. It also ignored modifiers and aliases on them, like `'parent.^(onlyAdults)'`, and treated `*` and `^` in a list, like `'[children, *]'`, as relation names. These expressions throw an error in 4.0, in all methods that take relation expressions and in the graph options of `insertGraph()` and `upsertGraph()`. Remove what follows `*` or `^`, and apply modifiers to the relation itself, like `'parent(onlyAdults).^'`. + +## `$setRelated` and `$appendRelated` sync foreign keys + +[\$setRelated](/api/model/instance-methods.html#setrelated) and [\$appendRelated](/api/model/instance-methods.html#appendrelated) set the foreign keys to match the relation. 3.x only assigned the related models: + +```js +post.$setRelated('author', user); // post.authorId === user.id +post.$setRelated('author', null); // post.authorId === null + +user.$setRelated('posts', [post1, post2]); // post1.authorId === post2.authorId === user.id +user.$setRelated('posts', [post2]); // post1.authorId === null, unless it already pointed to another user +new User().$setRelated('posts', [post3]); // post3.authorId === null, as the user has no id yet +``` + +The synced keys show up in `toJSON()` and are written by a later `patch()`, `update()` or `upsertGraph()` of these models. Code that assigned related models only to serialize them, and relied on their foreign keys staying as they were, can assign the relation property directly, e.g. `post.author = user`. + +## findById() and findByIds() keep their id condition in its own group + +[findById](/api/query-builder/find-methods.html#findbyid) and [findByIds](/api/query-builder/find-methods.html#findbyids) put their id condition in its own parentheses, like `$query()` and `patchById()`, `updateById()` and `deleteById()` already do. 3.x added it to the other `where` clauses of the query, so an `orWhere()` widened it: + +```js +// 4.0: where "persons"."id" = 1 and ("slug" = 'jennifer') +// 3.x: where "persons"."id" = 1 or "slug" = 'jennifer' +await Person.query().findById(1).orWhere('slug', 'jennifer'); +``` + +Queries that relied on this, like a lookup by id or slug, need to spell out both conditions: + +```js +await Person.query().where('id', idOrSlug).orWhere('slug', idOrSlug).first(); +``` + +## JSON in PostgreSQL text columns is no longer parsed + +The PostgreSQL drivers return `json` and `jsonb` columns parsed. 3.x parsed every string that came out of them a second time, so a JSON attribute that holds the string `'123'` came back as the number `123`, and saving the model wrote the change back. 4.0 keeps the values that these drivers return, so JSON stored in `text` columns comes back as a string. Change these columns to `jsonb`: + +```sql +ALTER TABLE persons ALTER COLUMN address TYPE jsonb USING address::jsonb; +``` + +Or tell objection that the model's JSON columns hold text, which it then parses strictly, throwing for invalid JSON: + +```js +class Person extends Model { + static jsonColumnFormat = 'text'; +} +``` + +Other drivers parse JSON attributes as in 3.x. On MariaDB, which stores JSON as `LONGTEXT`, `jsonColumnFormat = 'text'` avoids guessing whether a string is JSON. + +Overrides of the static [fromDatabaseJson](/api/model/static-methods.html#static-fromdatabasejson), [\$setDatabaseJson](/api/model/instance-methods.html#setdatabasejson) and [\$parseDatabaseJson](/api/model/instance-methods.html#parsedatabasejson) get the query's `options` as second argument. Pass them on to `super`, or JSON attributes are parsed as in 3.x. + +
+
+ # Migration from objection 2.x to 3.0 This document guides you through each breaking change in objection 3.0 and attempts to provide clear steps to follow. If you find something missing, please open an issue and we'll fix this guide ASAP. diff --git a/docker-compose.mysql57.yml b/docker-compose.mysql57.yml deleted file mode 100644 index 7ab039fd2..000000000 --- a/docker-compose.mysql57.yml +++ /dev/null @@ -1,10 +0,0 @@ -# Runs the MySQL tests on MySQL 5.7, used by its own CI job: -# COMPOSE_FILE=docker-compose.yml:docker-compose.mysql57.yml npm run db:up -services: - mysql: - image: 'mysql:5.7' - # There is no arm64 image of MySQL 5.7. - platform: linux/amd64 - # MySQL 5.7 doesn't know 8.4's options, and uses mysql_native_password by - # default. - command: --default-authentication-plugin=mysql_native_password diff --git a/docker-compose.yml b/docker-compose.yml index e40586706..b0101ca0f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,7 +41,7 @@ services: image: 'mcr.microsoft.com/mssql/server:2022-latest' # There is no arm64 image of SQL Server. platform: linux/amd64 - # Only started on request, e.g. `npm run db:up -- mssql`, as the image is + # Only started on request, e.g. `pnpm db:up mssql`, as the image is # large and runs emulated on arm64. profiles: [mssql] ports: diff --git a/examples/koa-ts/README.md b/examples/koa-ts/README.md index e2fc63f8c..87bb75a6e 100644 --- a/examples/koa-ts/README.md +++ b/examples/koa-ts/README.md @@ -1,6 +1,6 @@ # Koa typescript example project -This is an example project that targets Node.js 20.17 and up. The project is a simple Koa server with a REST API that demonstrates the basic functionalities of objection like models, queries, relations, eager loading and graph inserts. +This is an example project that targets Node.js 20.19 and up. The project is a simple Koa server with a REST API that demonstrates the basic functionalities of objection like models, queries, relations, eager loading and graph inserts. Note that this is not an example of how to build a web server. It's an example of how to use objection in a web server. All other aspects are kept as simple and minimal as possible. diff --git a/examples/koa-ts/api.ts b/examples/koa-ts/api.ts index b2b7557f2..b888a0673 100644 --- a/examples/koa-ts/api.ts +++ b/examples/koa-ts/api.ts @@ -1,5 +1,5 @@ import type Router from '@koa/router' -import type { PartialModelGraph, PartialModelObject } from 'objection' +import type { PartialModelGraph, PartialModelRow } from 'objection' import Animal from './models/Animal.js' import Movie from './models/Movie.js' import Person from './models/Person.js' @@ -98,7 +98,7 @@ export default (router: Router) => { router.patch('/persons/:id', async (ctx) => { const numUpdated = await Person.query() .findById(ctx.params.id) - .patch(ctx.request.body as PartialModelObject) + .patch(ctx.request.body as PartialModelRow) ctx.body = { success: numUpdated == 1, @@ -124,7 +124,7 @@ export default (router: Router) => { const child = await Person.relatedQuery('children') .for(personId) - .insert(ctx.request.body as PartialModelObject) + .insert(ctx.request.body as PartialModelRow) ctx.body = child }) @@ -175,7 +175,7 @@ export default (router: Router) => { const pet = await Person.relatedQuery('pets') .for(personId) - .insert(ctx.request.body as PartialModelObject) + .insert(ctx.request.body as PartialModelRow) ctx.body = pet }) @@ -207,7 +207,7 @@ export default (router: Router) => { * Insert a new movie. */ router.post('/movies', async (ctx) => { - const movie = await Movie.query().insert(ctx.request.body as PartialModelObject) + const movie = await Movie.query().insert(ctx.request.body as PartialModelRow) ctx.body = movie }) diff --git a/examples/koa-ts/client.js b/examples/koa-ts/client.js index 02b2142f7..824a9309b 100644 --- a/examples/koa-ts/client.js +++ b/examples/koa-ts/client.js @@ -1,11 +1,9 @@ -'use strict' - /** * This file contains a bunch of HTTP requests that use the * API defined in api.ts. */ -const axios = require('axios') +import axios from 'axios' const req = axios.create({ baseURL: 'http://localhost:8641/', @@ -13,7 +11,7 @@ const req = axios.create({ paramsSerializer: { indexes: null }, }) -;(async () => { +try { const matt = await insertPersonWithRelations() await fetchPeople() @@ -39,14 +37,14 @@ const req = axios.create({ const departed = await insertMovie({ name: 'The Departed' }) await addPersonToMovieAsActor(departed, matt) await removePersonFromMovie(departed, matt) -})().catch((err) => { +} catch (err) { if (err.response) { console.error('error:', err.response.status, err.response.data) } else { console.error('error:', err) } process.exitCode = 1 -}) +} async function insertPersonWithRelations() { console.log(` diff --git a/examples/koa-ts/knexfile.js b/examples/koa-ts/knexfile.js index 93c2a8bc3..bd97367a7 100644 --- a/examples/koa-ts/knexfile.js +++ b/examples/koa-ts/knexfile.js @@ -1,4 +1,4 @@ -module.exports = { +export default { development: { client: 'sqlite3', useNullAsDefault: true, diff --git a/examples/koa-ts/migrations/20150613161239_initial_schema.js b/examples/koa-ts/migrations/20150613161239_initial_schema.js index 8a1d0fdb6..5a5e21a81 100644 --- a/examples/koa-ts/migrations/20150613161239_initial_schema.js +++ b/examples/koa-ts/migrations/20150613161239_initial_schema.js @@ -1,4 +1,4 @@ -exports.up = (knex) => { +export function up(knex) { return knex.schema .createTable('persons', (table) => { table.increments('id').primary() @@ -55,7 +55,7 @@ exports.up = (knex) => { }) } -exports.down = (knex) => { +export function down(knex) { return knex.schema .dropTableIfExists('persons_movies') .dropTableIfExists('animals') diff --git a/examples/koa-ts/package.json b/examples/koa-ts/package.json index 3a356b5ce..343f10af8 100644 --- a/examples/koa-ts/package.json +++ b/examples/koa-ts/package.json @@ -1,7 +1,8 @@ { "name": "objection-example-koa-ts", - "version": "3.0.0", + "version": "4.0.0", "description": "Objection.js koa typescript example", + "type": "module", "main": "dist/app.js", "scripts": { "migrate": "knex migrate:latest", @@ -9,7 +10,7 @@ "start": "npm run migrate && npm run build && node dist/app.js" }, "engines": { - "node": ">=20.17.0" + "node": "^20.19.0 || >=22.12.0" }, "author": "Sami Koskimäki", "license": "MIT", @@ -19,7 +20,7 @@ "axios": "^1.20.0", "knex": "^3.3.0", "koa": "^3.2.1", - "objection": "^3.5.0", + "objection": "^4.0.0", "sqlite3": "^6.0.1" }, "devDependencies": { diff --git a/examples/koa/README.md b/examples/koa/README.md index cd8c00d6b..f96f47c33 100644 --- a/examples/koa/README.md +++ b/examples/koa/README.md @@ -1,6 +1,6 @@ # Koa example project -This is an example project that targets Node.js 20.17 and up. The project is a simple Koa server with a REST API that demonstrates the basic functionalities of objection like models, queries, relations, eager loading and graph inserts. +This is an example project that targets Node.js 20.19 and up. The project is a simple Koa server with a REST API that demonstrates the basic functionalities of objection like models, queries, relations, eager loading and graph inserts. Note that this is not an example of how to build a web server. It's an example of how to use objection in a web server. All other aspects are kept as simple and minimal as possible. diff --git a/examples/koa/api.js b/examples/koa/api.js index cb12f49e9..b591b7740 100644 --- a/examples/koa/api.js +++ b/examples/koa/api.js @@ -1,9 +1,7 @@ -'use strict' +import Person from './models/Person.js' +import Movie from './models/Movie.js' -const Person = require('./models/Person') -const Movie = require('./models/Movie') - -module.exports = (router) => { +export default (router) => { /** * Create a new Person. * diff --git a/examples/koa/app.js b/examples/koa/app.js index 5e1a5328c..e3be812ee 100644 --- a/examples/koa/app.js +++ b/examples/koa/app.js @@ -1,10 +1,10 @@ -const Koa = require('koa') -const Router = require('@koa/router') -const { bodyParser } = require('@koa/bodyparser') -const Knex = require('knex') -const { Model, ForeignKeyViolationError, ValidationError } = require('objection') -const knexConfig = require('./knexfile') -const registerApi = require('./api') +import Koa from 'koa' +import Router from '@koa/router' +import { bodyParser } from '@koa/bodyparser' +import Knex from 'knex' +import { Model, ForeignKeyViolationError, ValidationError } from 'objection' +import knexConfig from './knexfile.js' +import registerApi from './api.js' // Initialize knex. const knex = Knex(knexConfig.development) diff --git a/examples/koa/client.js b/examples/koa/client.js index 572498399..5b56f72f7 100644 --- a/examples/koa/client.js +++ b/examples/koa/client.js @@ -1,11 +1,9 @@ -'use strict' - /** * This file contains a bunch of HTTP requests that use the * API defined in api.js. */ -const axios = require('axios') +import axios from 'axios' const req = axios.create({ baseURL: 'http://localhost:8641/', @@ -13,7 +11,7 @@ const req = axios.create({ paramsSerializer: { indexes: null }, }) -;(async () => { +try { const matt = await insertPersonWithRelations() await fetchPeople() @@ -39,14 +37,14 @@ const req = axios.create({ const departed = await insertMovie({ name: 'The Departed' }) await addPersonToMovieAsActor(departed, matt) await removePersonFromMovie(departed, matt) -})().catch((err) => { +} catch (err) { if (err.response) { console.error('error:', err.response.status, err.response.data) } else { console.error('error:', err) } process.exitCode = 1 -}) +} async function insertPersonWithRelations() { console.log(` diff --git a/examples/koa/knexfile.js b/examples/koa/knexfile.js index 93c2a8bc3..bd97367a7 100644 --- a/examples/koa/knexfile.js +++ b/examples/koa/knexfile.js @@ -1,4 +1,4 @@ -module.exports = { +export default { development: { client: 'sqlite3', useNullAsDefault: true, diff --git a/examples/koa/migrations/20150613161239_initial_schema.js b/examples/koa/migrations/20150613161239_initial_schema.js index 8a1d0fdb6..5a5e21a81 100644 --- a/examples/koa/migrations/20150613161239_initial_schema.js +++ b/examples/koa/migrations/20150613161239_initial_schema.js @@ -1,4 +1,4 @@ -exports.up = (knex) => { +export function up(knex) { return knex.schema .createTable('persons', (table) => { table.increments('id').primary() @@ -55,7 +55,7 @@ exports.up = (knex) => { }) } -exports.down = (knex) => { +export function down(knex) { return knex.schema .dropTableIfExists('persons_movies') .dropTableIfExists('animals') diff --git a/examples/koa/models/Animal.js b/examples/koa/models/Animal.js index e539a3723..028716cc5 100644 --- a/examples/koa/models/Animal.js +++ b/examples/koa/models/Animal.js @@ -1,8 +1,7 @@ -'use strict' +import { Model } from 'objection' +import Person from './Person.js' -const { Model } = require('objection') - -class Animal extends Model { +export default class Animal extends Model { // Table name is the only required property. static get tableName() { return 'animals' @@ -25,18 +24,15 @@ class Animal extends Model { } } - // This object defines the relations to other models. + // This object defines the relations to other models. The getter is only + // accessed once the relations are needed, after all modules are loaded, so + // the circular imports between the models are not a problem. static get relationMappings() { - // One way to prevent circular references - // is to require the model classes here. - const Person = require('./Person') - return { owner: { relation: Model.BelongsToOneRelation, - // The related model. This can be either a Model subclass constructor or an - // absolute file path to a module that exports one. + // The related model. modelClass: Person, join: { @@ -47,5 +43,3 @@ class Animal extends Model { } } } - -module.exports = Animal diff --git a/examples/koa/models/Movie.js b/examples/koa/models/Movie.js index 425def82b..df5ad1df8 100644 --- a/examples/koa/models/Movie.js +++ b/examples/koa/models/Movie.js @@ -1,8 +1,7 @@ -'use strict' +import { Model } from 'objection' +import Person from './Person.js' -const { Model } = require('objection') - -class Movie extends Model { +export default class Movie extends Model { // Table name is the only required property. static get tableName() { return 'movies' @@ -23,17 +22,15 @@ class Movie extends Model { } } + // This object defines the relations to other models. The getter is only + // accessed once the relations are needed, after all modules are loaded, so + // the circular imports between the models are not a problem. static get relationMappings() { - // One way to prevent circular references - // is to require the model classes here. - const Person = require('./Person') - return { actors: { relation: Model.ManyToManyRelation, - // The related model. This can be either a Model subclass constructor or an - // absolute file path to a module that exports one. + // The related model. modelClass: Person, join: { @@ -49,5 +46,3 @@ class Movie extends Model { } } } - -module.exports = Movie diff --git a/examples/koa/models/Person.js b/examples/koa/models/Person.js index cab58260f..3ae62edeb 100644 --- a/examples/koa/models/Person.js +++ b/examples/koa/models/Person.js @@ -1,8 +1,8 @@ -'use strict' +import { Model } from 'objection' +import Animal from './Animal.js' +import Movie from './Movie.js' -const { Model } = require('objection') - -class Person extends Model { +export default class Person extends Model { // Table name is the only required property. static get tableName() { return 'persons' @@ -56,18 +56,14 @@ class Person extends Model { } } - // This object defines the relations to other models. + // This object defines the relations to other models. The getter is only + // accessed once the relations are needed, after all modules are loaded, so + // the circular imports between the models are not a problem. static get relationMappings() { - // One way to prevent circular references - // is to require the model classes here. - const Animal = require('./Animal') - const Movie = require('./Movie') - return { pets: { relation: Model.HasManyRelation, - // The related model. This can be either a Model subclass constructor or an - // absolute file path to a module that exports one. + // The related model. modelClass: Animal, join: { from: 'persons.id', @@ -109,5 +105,3 @@ class Person extends Model { } } } - -module.exports = Person diff --git a/examples/koa/package.json b/examples/koa/package.json index c41b20dd7..b4c3774a3 100644 --- a/examples/koa/package.json +++ b/examples/koa/package.json @@ -1,14 +1,15 @@ { "name": "objection-example-koa", - "version": "3.0.0", + "version": "4.0.0", "description": "Objection.js koa example", + "type": "module", "main": "app.js", "scripts": { "migrate": "knex migrate:latest", "start": "npm run migrate && node app.js" }, "engines": { - "node": ">=20.17.0" + "node": "^20.19.0 || >=22.12.0" }, "author": "Sami Koskimäki", "license": "MIT", @@ -18,7 +19,7 @@ "axios": "^1.20.0", "knex": "^3.3.0", "koa": "^3.2.1", - "objection": "^3.5.0", + "objection": "^4.0.0", "sqlite3": "^6.0.1" } } diff --git a/examples/minimal/README.md b/examples/minimal/README.md index c025849e1..d3db7fbdd 100644 --- a/examples/minimal/README.md +++ b/examples/minimal/README.md @@ -1,6 +1,6 @@ # Minimal example project -This example has the bare minimum to get you running queries and testing out things with objection. It requires Node.js 20.17 or newer. If you want to see a more realistic example, with multiple models, relations, a REST API etc. check out the [koa example](https://github.com/ditojs/objection/tree/main/examples/koa). +This example has the bare minimum to get you running queries and testing out things with objection. It requires Node.js 20.19 or newer. If you want to see a more realistic example, with multiple models, relations, a REST API etc. check out the [koa example](https://github.com/ditojs/objection/tree/main/examples/koa). # Install and run diff --git a/examples/minimal/app.js b/examples/minimal/app.js index 3479a8d26..34dc21557 100644 --- a/examples/minimal/app.js +++ b/examples/minimal/app.js @@ -1,9 +1,7 @@ -'use strict'; - -const Knex = require('knex'); -const { Model } = require('objection'); -const knexConfig = require('./knexfile'); -const { Person } = require('./models/Person'); +import Knex from 'knex'; +import { Model } from 'objection'; +import knexConfig from './knexfile.js'; +import { Person } from './models/Person.js'; // Initialize knex. const knex = Knex(knexConfig.development); @@ -13,7 +11,7 @@ const knex = Knex(knexConfig.development); // your model classes. Model.knex(knex); -async function main() { +try { // Delete all persons from the db. await Person.query().delete(); @@ -27,11 +25,6 @@ async function main() { const people = await Person.query(); console.log(people); +} finally { + await knex.destroy(); } - -main() - .catch((err) => { - console.error(err); - process.exitCode = 1; - }) - .finally(() => knex.destroy()); diff --git a/examples/minimal/knexfile.js b/examples/minimal/knexfile.js index 2abf26af3..fbceb8a25 100644 --- a/examples/minimal/knexfile.js +++ b/examples/minimal/knexfile.js @@ -1,4 +1,4 @@ -module.exports = { +export default { development: { client: 'sqlite3', useNullAsDefault: true, diff --git a/examples/minimal/migrations/20190330121219_initial_schema.js b/examples/minimal/migrations/20190330121219_initial_schema.js index c8454b2ef..b45a250f1 100644 --- a/examples/minimal/migrations/20190330121219_initial_schema.js +++ b/examples/minimal/migrations/20190330121219_initial_schema.js @@ -1,13 +1,11 @@ -'use strict'; - -exports.up = (knex) => { +export function up(knex) { return knex.schema.createTable('persons', (table) => { table.increments('id').primary(); table.string('firstName'); table.string('lastName'); }); -}; +} -exports.down = (knex) => { +export function down(knex) { return knex.schema.dropTableIfExists('persons'); -}; +} diff --git a/examples/minimal/models/Person.js b/examples/minimal/models/Person.js index b4ba38b7d..5e01b9231 100644 --- a/examples/minimal/models/Person.js +++ b/examples/minimal/models/Person.js @@ -1,14 +1,8 @@ -'use strict'; +import { Model } from 'objection'; -const { Model } = require('objection'); - -class Person extends Model { +export class Person extends Model { // Table name is the only required property. static get tableName() { return 'persons'; } } - -module.exports = { - Person, -}; diff --git a/examples/minimal/package.json b/examples/minimal/package.json index 0dee16798..41fb5e923 100644 --- a/examples/minimal/package.json +++ b/examples/minimal/package.json @@ -1,20 +1,21 @@ { "name": "objection-example-minimal", - "version": "2.0.0", + "version": "4.0.0", "description": "Objection.js minimal example project", + "type": "module", "main": "app.js", "scripts": { "migrate": "knex migrate:latest", "start": "npm run migrate && node app.js" }, "engines": { - "node": ">=20.17.0" + "node": "^20.19.0 || >=22.12.0" }, "author": "Sami Koskimäki", "license": "MIT", "dependencies": { "knex": "^3.3.0", - "objection": "^3.5.0", + "objection": "^4.0.0", "sqlite3": "^6.0.1" } } diff --git a/examples/plugin-with-options/README.md b/examples/plugin-with-options/README.md index 1fa95cd3f..2b1c19c1d 100644 --- a/examples/plugin-with-options/README.md +++ b/examples/plugin-with-options/README.md @@ -13,21 +13,19 @@ and returns a mixin. Usage example: ```js -const { Model } = require('objection'); -const sessionPlugin = require('path/to/this/example'); +import { Model } from 'objection'; +import sessionPlugin from 'path/to/this/example/index.js'; const Session = sessionPlugin({ setCreatedBy: false, setModifiedBy: false, }); -class Person extends Session(Model) { +export default class Person extends Session(Model) { static get tableName() { return 'Person'; } } - -module.exports = Person; ``` ```js diff --git a/examples/plugin-with-options/index.js b/examples/plugin-with-options/index.js index dd8d4e227..6f355b749 100644 --- a/examples/plugin-with-options/index.js +++ b/examples/plugin-with-options/index.js @@ -1,5 +1,3 @@ -'use strict'; - // Objection.js plugins are class mixins. Read this excellent article for detailed description: // http://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/ // @@ -8,7 +6,7 @@ // // If the plugin takes options the main module should be a factory function that returns a // mixin. This plugin is exactly the same as the `plugin` example, but adds a couple of options. -module.exports = (options) => { +export default (options) => { // Provide good defaults for the options if possible. options = { setModifiedBy: true, diff --git a/examples/plugin-with-options/package.json b/examples/plugin-with-options/package.json index 916950d65..fbc0c0ae0 100644 --- a/examples/plugin-with-options/package.json +++ b/examples/plugin-with-options/package.json @@ -1,22 +1,23 @@ { "name": "objection-plugin-with-options-example", - "version": "1.0.0", + "version": "4.0.0", "description": "An example plugin with options for objection", + "type": "module", "main": "index.js", "scripts": { "test": "node --test" }, "engines": { - "node": ">=20.17.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "objection": "^3.0.0" + "objection": "^4.0.0" }, "author": "Sami Koskimäki", "license": "MIT", "devDependencies": { "knex": "^3.3.0", - "objection": "^3.5.0", + "objection": "^4.0.0", "sqlite3": "^6.0.1" } } diff --git a/examples/plugin-with-options/test.js b/examples/plugin-with-options/test.js index 130a74781..354d93d3e 100644 --- a/examples/plugin-with-options/test.js +++ b/examples/plugin-with-options/test.js @@ -1,10 +1,8 @@ -'use strict'; - -const { after, before, beforeEach, describe, it } = require('node:test'); -const assert = require('node:assert/strict'); -const Knex = require('knex'); -const { Model } = require('objection'); -const sessionPluginFactory = require('./index'); +import { after, before, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import Knex from 'knex'; +import { Model } from 'objection'; +import sessionPluginFactory from './index.js'; const ISO_DATE_REGEX = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/; diff --git a/examples/plugin/README.md b/examples/plugin/README.md index 03fe9da84..72d1154bb 100644 --- a/examples/plugin/README.md +++ b/examples/plugin/README.md @@ -9,16 +9,14 @@ automatically based on the given session. Usage example: ```js -const { Model } = require('objection'); -const Session = require('path/to/this/example'); +import { Model } from 'objection'; +import Session from 'path/to/this/example/index.js'; -class Person extends Session(Model) { +export default class Person extends Session(Model) { static get tableName() { return 'Person'; } } - -module.exports = Person; ``` ```js diff --git a/examples/plugin/index.js b/examples/plugin/index.js index 5d0e8cb8d..376bef6a1 100644 --- a/examples/plugin/index.js +++ b/examples/plugin/index.js @@ -1,11 +1,9 @@ -'use strict'; - // Objection.js plugins are class mixins. Read this excellent article for detailed description: // http://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/ // // A plugin should be a function that takes a model class as an argument. A plugin then needs to // extends that model and return it. A plugin should never modify the model directly! -module.exports = (Model) => { +export default (Model) => { // If your plugin extends the QueryBuilder, you need to extend `Model.QueryBuilder` // since it may have already been extended by other plugins. class SessionQueryBuilder extends Model.QueryBuilder { diff --git a/examples/plugin/package.json b/examples/plugin/package.json index c067fab49..52b654519 100644 --- a/examples/plugin/package.json +++ b/examples/plugin/package.json @@ -1,22 +1,23 @@ { "name": "objection-plugin-example", - "version": "1.0.0", + "version": "4.0.0", "description": "An example plugin for objection", + "type": "module", "main": "index.js", "scripts": { "test": "node --test" }, "engines": { - "node": ">=20.17.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "objection": "^3.0.0" + "objection": "^4.0.0" }, "author": "Sami Koskimäki", "license": "MIT", "devDependencies": { "knex": "^3.3.0", - "objection": "^3.5.0", + "objection": "^4.0.0", "sqlite3": "^6.0.1" } } diff --git a/examples/plugin/test.js b/examples/plugin/test.js index 7c48b0f36..ff9f273d1 100644 --- a/examples/plugin/test.js +++ b/examples/plugin/test.js @@ -1,10 +1,8 @@ -'use strict'; - -const { after, before, beforeEach, describe, it } = require('node:test'); -const assert = require('node:assert/strict'); -const Knex = require('knex'); -const { Model } = require('objection'); -const sessionPlugin = require('./index'); +import { after, before, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import Knex from 'knex'; +import { Model } from 'objection'; +import sessionPlugin from './index.js'; const ISO_DATE_REGEX = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/; diff --git a/lib/.eslintrc.json b/lib/.eslintrc.json deleted file mode 100644 index b3e42fcff..000000000 --- a/lib/.eslintrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "parserOptions": { - "sourceType": "script" - }, - "rules": { - "strict": "error" - } -} diff --git a/lib/dbErrors/LICENSE b/lib/dbErrors/LICENSE new file mode 100644 index 000000000..bfb34c4c1 --- /dev/null +++ b/lib/dbErrors/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Vincit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/dbErrors/errorCodes/mssql.js b/lib/dbErrors/errorCodes/mssql.js new file mode 100644 index 000000000..5bad5ee26 --- /dev/null +++ b/lib/dbErrors/errorCodes/mssql.js @@ -0,0 +1,19 @@ +// select * from sysmessages where [error] in (2601, 2627, 515, 547, 241, 242, 8152, 245) and msglangid = 1033; + +// Top level key is the severity +const codes = { + 16: new Set([241, 242, 245, 515, 547, 8152, 50000]), + 14: new Set([2601, 2627]), +}; + +export const mssqlErrorCodes = { + has(code) { + const set = codes[code.severity]; + + if (set) { + return set.has(code.errorCode); + } + + return false; + }, +}; diff --git a/lib/dbErrors/errorCodes/mysql.js b/lib/dbErrors/errorCodes/mysql.js new file mode 100644 index 000000000..bca2d01b2 --- /dev/null +++ b/lib/dbErrors/errorCodes/mysql.js @@ -0,0 +1,805 @@ +export const mysqlErrorCodes = new Set([ + 'ER_HASHCHK', + 'ER_NISAMCHK', + 'ER_NO', + 'ER_YES', + 'ER_YES', + 'ER_CANT_CREATE_FILE', + 'ER_CANT_CREATE_TABLE', + 'ER_CANT_CREATE_DB', + 'ER_DB_CREATE_EXISTS', + 'ER_DB_DROP_EXISTS', + 'ER_DB_DROP_DELETE', + 'ER_DB_DROP_RMDIR', + 'ER_CANT_DELETE_FILE', + 'ER_CANT_FIND_SYSTEM_REC', + 'ER_CANT_GET_STAT', + 'ER_CANT_GET_WD', + 'ER_CANT_LOCK', + 'ER_CANT_OPEN_FILE', + 'ER_FILE_NOT_FOUND', + 'ER_CANT_READ_DIR', + 'ER_CANT_SET_WD', + 'ER_CHECKREAD', + 'ER_DISK_FULL', + 'ER_DUP_KEY', + 'ER_ERROR_ON_CLOSE', + 'ER_ERROR_ON_READ', + 'ER_ERROR_ON_RENAME', + 'ER_ERROR_ON_WRITE', + 'ER_FILE_USED', + 'ER_FILSORT_ABORT', + 'ER_FORM_NOT_FOUND', + 'ER_GET_ERRNO', + 'ER_ILLEGAL_HA', + 'ER_KEY_NOT_FOUND', + 'ER_NOT_FORM_FILE', + 'ER_NOT_KEYFILE', + 'ER_OLD_KEYFILE', + 'ER_OPEN_AS_READONLY', + 'ER_OUTOFMEMORY', + 'ER_OUT_OF_SORTMEMORY', + 'ER_UNEXPECTED_EOF', + 'ER_CON_COUNT_ERROR', + 'ER_OUT_OF_RESOURCES', + 'ER_BAD_HOST_ERROR', + 'ER_HANDSHAKE_ERROR', + 'ER_DBACCESS_DENIED_ERROR', + 'ER_ACCESS_DENIED_ERROR', + 'ER_NO_DB_ERROR', + 'ER_UNKNOWN_COM_ERROR', + 'ER_BAD_NULL_ERROR', + 'ER_BAD_DB_ERROR', + 'ER_TABLE_EXISTS_ERROR', + 'ER_BAD_TABLE_ERROR', + 'ER_NON_UNIQ_ERROR', + 'ER_SERVER_SHUTDOWN', + 'ER_BAD_FIELD_ERROR', + 'ER_WRONG_FIELD_WITH_GROUP', + 'ER_WRONG_GROUP_FIELD', + 'ER_WRONG_SUM_SELECT', + 'ER_WRONG_VALUE_COUNT', + 'ER_TOO_LONG_IDENT', + 'ER_DUP_FIELDNAME', + 'ER_DUP_KEYNAME', + 'ER_DUP_ENTRY', + 'ER_DUP_ENTRY_WITH_KEY_NAME', + 'ER_WRONG_FIELD_SPEC', + 'ER_PARSE_ERROR', + 'ER_EMPTY_QUERY', + 'ER_NONUNIQ_TABLE', + 'ER_INVALID_DEFAULT', + 'ER_MULTIPLE_PRI_KEY', + 'ER_TOO_MANY_KEYS', + 'ER_TOO_MANY_KEY_PARTS', + 'ER_TOO_LONG_KEY', + 'ER_KEY_COLUMN_DOES_NOT_EXITS', + 'ER_BLOB_USED_AS_KEY', + 'ER_TOO_BIG_FIELDLENGTH', + 'ER_WRONG_AUTO_KEY', + 'ER_READY', + 'ER_NORMAL_SHUTDOWN', + 'ER_GOT_SIGNAL', + 'ER_SHUTDOWN_COMPLETE', + 'ER_FORCING_CLOSE', + 'ER_IPSOCK_ERROR', + 'ER_NO_SUCH_INDEX', + 'ER_WRONG_FIELD_TERMINATORS', + 'ER_BLOBS_AND_NO_TERMINATED', + 'ER_TEXTFILE_NOT_READABLE', + 'ER_FILE_EXISTS_ERROR', + 'ER_LOAD_INFO', + 'ER_ALTER_INFO', + 'ER_WRONG_SUB_KEY', + 'ER_CANT_REMOVE_ALL_FIELDS', + 'ER_CANT_DROP_FIELD_OR_KEY', + 'ER_INSERT_INFO', + 'ER_UPDATE_TABLE_USED', + 'ER_NO_SUCH_THREAD', + 'ER_KILL_DENIED_ERROR', + 'ER_NO_TABLES_USED', + 'ER_TOO_BIG_SET', + 'ER_NO_UNIQUE_LOGFILE', + 'ER_TABLE_NOT_LOCKED_FOR_WRITE', + 'ER_TABLE_NOT_LOCKED', + 'ER_BLOB_CANT_HAVE_DEFAULT', + 'ER_WRONG_DB_NAME', + 'ER_WRONG_TABLE_NAME', + 'ER_TOO_BIG_SELECT', + 'ER_UNKNOWN_ERROR', + 'ER_UNKNOWN_PROCEDURE', + 'ER_WRONG_PARAMCOUNT_TO_PROCEDURE', + 'ER_WRONG_PARAMETERS_TO_PROCEDURE', + 'ER_UNKNOWN_TABLE', + 'ER_FIELD_SPECIFIED_TWICE', + 'ER_INVALID_GROUP_FUNC_USE', + 'ER_UNSUPPORTED_EXTENSION', + 'ER_TABLE_MUST_HAVE_COLUMNS', + 'ER_RECORD_FILE_FULL', + 'ER_UNKNOWN_CHARACTER_SET', + 'ER_TOO_MANY_TABLES', + 'ER_TOO_MANY_FIELDS', + 'ER_TOO_BIG_ROWSIZE', + 'ER_STACK_OVERRUN', + 'ER_WRONG_OUTER_JOIN', + 'ER_NULL_COLUMN_IN_INDEX', + 'ER_CANT_FIND_UDF', + 'ER_CANT_INITIALIZE_UDF', + 'ER_UDF_NO_PATHS', + 'ER_UDF_EXISTS', + 'ER_CANT_OPEN_LIBRARY', + 'ER_CANT_FIND_DL_ENTRY', + 'ER_FUNCTION_NOT_DEFINED', + 'ER_HOST_IS_BLOCKED', + 'ER_HOST_NOT_PRIVILEGED', + 'ER_PASSWORD_ANONYMOUS_USER', + 'ER_PASSWORD_NOT_ALLOWED', + 'ER_PASSWORD_NO_MATCH', + 'ER_UPDATE_INFO', + 'ER_CANT_CREATE_THREAD', + 'ER_WRONG_VALUE_COUNT_ON_ROW', + 'ER_CANT_REOPEN_TABLE', + 'ER_INVALID_USE_OF_NULL', + 'ER_REGEXP_ERROR', + 'ER_MIX_OF_GROUP_FUNC_AND_FIELDS', + 'ER_NONEXISTING_GRANT', + 'ER_TABLEACCESS_DENIED_ERROR', + 'ER_COLUMNACCESS_DENIED_ERROR', + 'ER_ILLEGAL_GRANT_FOR_TABLE', + 'ER_GRANT_WRONG_HOST_OR_USER', + 'ER_NO_SUCH_TABLE', + 'ER_NONEXISTING_TABLE_GRANT', + 'ER_NOT_ALLOWED_COMMAND', + 'ER_SYNTAX_ERROR', + 'ER_DELAYED_CANT_CHANGE_LOCK', + 'ER_TOO_MANY_DELAYED_THREADS', + 'ER_ABORTING_CONNECTION', + 'ER_NET_PACKET_TOO_LARGE', + 'ER_NET_READ_ERROR_FROM_PIPE', + 'ER_NET_FCNTL_ERROR', + 'ER_NET_PACKETS_OUT_OF_ORDER', + 'ER_NET_UNCOMPRESS_ERROR', + 'ER_NET_READ_ERROR', + 'ER_NET_READ_INTERRUPTED', + 'ER_NET_ERROR_ON_WRITE', + 'ER_NET_WRITE_INTERRUPTED', + 'ER_TOO_LONG_STRING', + 'ER_TABLE_CANT_HANDLE_BLOB', + 'ER_TABLE_CANT_HANDLE_AUTO_INCREMENT', + 'ER_DELAYED_INSERT_TABLE_LOCKED', + 'ER_WRONG_COLUMN_NAME', + 'ER_WRONG_KEY_COLUMN', + 'ER_WRONG_MRG_TABLE', + 'ER_DUP_UNIQUE', + 'ER_BLOB_KEY_WITHOUT_LENGTH', + 'ER_PRIMARY_CANT_HAVE_NULL', + 'ER_TOO_MANY_ROWS', + 'ER_REQUIRES_PRIMARY_KEY', + 'ER_NO_RAID_COMPILED', + 'ER_UPDATE_WITHOUT_KEY_IN_SAFE_MODE', + 'ER_KEY_DOES_NOT_EXITS', + 'ER_CHECK_NO_SUCH_TABLE', + 'ER_CHECK_NOT_IMPLEMENTED', + 'ER_CANT_DO_THIS_DURING_AN_TRANSACTION', + 'ER_ERROR_DURING_COMMIT', + 'ER_ERROR_DURING_ROLLBACK', + 'ER_ERROR_DURING_FLUSH_LOGS', + 'ER_ERROR_DURING_CHECKPOINT', + 'ER_NEW_ABORTING_CONNECTION', + 'ER_DUMP_NOT_IMPLEMENTED', + 'ER_FLUSH_MASTER_BINLOG_CLOSED', + 'ER_INDEX_REBUILD', + 'ER_MASTER', + 'ER_MASTER_NET_READ', + 'ER_MASTER_NET_WRITE', + 'ER_FT_MATCHING_KEY_NOT_FOUND', + 'ER_LOCK_OR_ACTIVE_TRANSACTION', + 'ER_UNKNOWN_SYSTEM_VARIABLE', + 'ER_CRASHED_ON_USAGE', + 'ER_CRASHED_ON_REPAIR', + 'ER_WARNING_NOT_COMPLETE_ROLLBACK', + 'ER_TRANS_CACHE_FULL', + 'ER_SLAVE_MUST_STOP', + 'ER_SLAVE_NOT_RUNNING', + 'ER_BAD_SLAVE', + 'ER_MASTER_INFO', + 'ER_SLAVE_THREAD', + 'ER_TOO_MANY_USER_CONNECTIONS', + 'ER_SET_CONSTANTS_ONLY', + 'ER_LOCK_WAIT_TIMEOUT', + 'ER_LOCK_TABLE_FULL', + 'ER_READ_ONLY_TRANSACTION', + 'ER_DROP_DB_WITH_READ_LOCK', + 'ER_CREATE_DB_WITH_READ_LOCK', + 'ER_WRONG_ARGUMENTS', + 'ER_NO_PERMISSION_TO_CREATE_USER', + 'ER_UNION_TABLES_IN_DIFFERENT_DIR', + 'ER_LOCK_DEADLOCK', + 'ER_TABLE_CANT_HANDLE_FT', + 'ER_CANNOT_ADD_FOREIGN', + 'ER_NO_REFERENCED_ROW', + 'ER_ROW_IS_REFERENCED', + 'ER_CONNECT_TO_MASTER', + 'ER_QUERY_ON_MASTER', + 'ER_ERROR_WHEN_EXECUTING_COMMAND', + 'ER_WRONG_USAGE', + 'ER_WRONG_NUMBER_OF_COLUMNS_IN_SELECT', + 'ER_CANT_UPDATE_WITH_READLOCK', + 'ER_MIXING_NOT_ALLOWED', + 'ER_DUP_ARGUMENT', + 'ER_USER_LIMIT_REACHED', + 'ER_SPECIFIC_ACCESS_DENIED_ERROR', + 'ER_LOCAL_VARIABLE', + 'ER_GLOBAL_VARIABLE', + 'ER_NO_DEFAULT', + 'ER_WRONG_VALUE_FOR_VAR', + 'ER_WRONG_TYPE_FOR_VAR', + 'ER_VAR_CANT_BE_READ', + 'ER_CANT_USE_OPTION_HERE', + 'ER_NOT_SUPPORTED_YET', + 'ER_MASTER_FATAL_ERROR_READING_BINLOG', + 'ER_SLAVE_IGNORED_TABLE', + 'ER_INCORRECT_GLOBAL_LOCAL_VAR', + 'ER_WRONG_FK_DEF', + 'ER_KEY_REF_DO_NOT_MATCH_TABLE_REF', + 'ER_OPERAND_COLUMNS', + 'ER_SUBQUERY_NO_1_ROW', + 'ER_UNKNOWN_STMT_HANDLER', + 'ER_CORRUPT_HELP_DB', + 'ER_CYCLIC_REFERENCE', + 'ER_AUTO_CONVERT', + 'ER_ILLEGAL_REFERENCE', + 'ER_DERIVED_MUST_HAVE_ALIAS', + 'ER_SELECT_REDUCED', + 'ER_TABLENAME_NOT_ALLOWED_HERE', + 'ER_NOT_SUPPORTED_AUTH_MODE', + 'ER_SPATIAL_CANT_HAVE_NULL', + 'ER_COLLATION_CHARSET_MISMATCH', + 'ER_SLAVE_WAS_RUNNING', + 'ER_SLAVE_WAS_NOT_RUNNING', + 'ER_TOO_BIG_FOR_UNCOMPRESS', + 'ER_ZLIB_Z_MEM_ERROR', + 'ER_ZLIB_Z_BUF_ERROR', + 'ER_ZLIB_Z_DATA_ERROR', + 'ER_CUT_VALUE_GROUP_CONCAT', + 'ER_WARN_TOO_FEW_RECORDS', + 'ER_WARN_TOO_MANY_RECORDS', + 'ER_WARN_NULL_TO_NOTNULL', + 'ER_WARN_DATA_OUT_OF_RANGE', + 'ER_WARN_USING_OTHER_HANDLER', + 'ER_CANT_AGGREGATE_2COLLATIONS', + 'ER_DROP_USER', + 'ER_REVOKE_GRANTS', + 'ER_CANT_AGGREGATE_3COLLATIONS', + 'ER_CANT_AGGREGATE_NCOLLATIONS', + 'ER_VARIABLE_IS_NOT_STRUCT', + 'ER_UNKNOWN_COLLATION', + 'ER_SLAVE_IGNORED_SSL_PARAMS', + 'ER_SERVER_IS_IN_SECURE_AUTH_MODE', + 'ER_WARN_FIELD_RESOLVED', + 'ER_BAD_SLAVE_UNTIL_COND', + 'ER_MISSING_SKIP_SLAVE', + 'ER_UNTIL_COND_IGNORED', + 'ER_WRONG_NAME_FOR_INDEX', + 'ER_WRONG_NAME_FOR_CATALOG', + 'ER_WARN_QC_RESIZE', + 'ER_BAD_FT_COLUMN', + 'ER_UNKNOWN_KEY_CACHE', + 'ER_WARN_HOSTNAME_WONT_WORK', + 'ER_UNKNOWN_STORAGE_ENGINE', + 'ER_WARN_DEPRECATED_SYNTAX', + 'ER_NON_UPDATABLE_TABLE', + 'ER_FEATURE_DISABLED', + 'ER_OPTION_PREVENTS_STATEMENT', + 'ER_DUPLICATED_VALUE_IN_TYPE', + 'ER_TRUNCATED_WRONG_VALUE', + 'ER_TOO_MUCH_AUTO_TIMESTAMP_COLS', + 'ER_INVALID_ON_UPDATE', + 'ER_UNSUPPORTED_PS', + 'ER_GET_ERRMSG', + 'ER_GET_TEMPORARY_ERRMSG', + 'ER_UNKNOWN_TIME_ZONE', + 'ER_WARN_INVALID_TIMESTAMP', + 'ER_INVALID_CHARACTER_STRING', + 'ER_WARN_ALLOWED_PACKET_OVERFLOWED', + 'ER_CONFLICTING_DECLARATIONS', + 'ER_SP_NO_RECURSIVE_CREATE', + 'ER_SP_ALREADY_EXISTS', + 'ER_SP_DOES_NOT_EXIST', + 'ER_SP_DROP_FAILED', + 'ER_SP_STORE_FAILED', + 'ER_SP_LILABEL_MISMATCH', + 'ER_SP_LABEL_REDEFINE', + 'ER_SP_LABEL_MISMATCH', + 'ER_SP_UNINIT_VAR', + 'ER_SP_BADSELECT', + 'ER_SP_BADRETURN', + 'ER_SP_BADSTATEMENT', + 'ER_UPDATE_LOG_DEPRECATED_IGNORED', + 'ER_UPDATE_LOG_DEPRECATED_TRANSLATED', + 'ER_QUERY_INTERRUPTED', + 'ER_SP_WRONG_NO_OF_ARGS', + 'ER_SP_COND_MISMATCH', + 'ER_SP_NORETURN', + 'ER_SP_NORETURNEND', + 'ER_SP_BAD_CURSOR_QUERY', + 'ER_SP_BAD_CURSOR_SELECT', + 'ER_SP_CURSOR_MISMATCH', + 'ER_SP_CURSOR_ALREADY_OPEN', + 'ER_SP_CURSOR_NOT_OPEN', + 'ER_SP_UNDECLARED_VAR', + 'ER_SP_WRONG_NO_OF_FETCH_ARGS', + 'ER_SP_FETCH_NO_DATA', + 'ER_SP_DUP_PARAM', + 'ER_SP_DUP_VAR', + 'ER_SP_DUP_COND', + 'ER_SP_DUP_CURS', + 'ER_SP_CANT_ALTER', + 'ER_SP_SUBSELECT_NYI', + 'ER_STMT_NOT_ALLOWED_IN_SF_OR_TRG', + 'ER_SP_VARCOND_AFTER_CURSHNDLR', + 'ER_SP_CURSOR_AFTER_HANDLER', + 'ER_SP_CASE_NOT_FOUND', + 'ER_FPARSER_TOO_BIG_FILE', + 'ER_FPARSER_BAD_HEADER', + 'ER_FPARSER_EOF_IN_COMMENT', + 'ER_FPARSER_ERROR_IN_PARAMETER', + 'ER_FPARSER_EOF_IN_UNKNOWN_PARAMETER', + 'ER_VIEW_NO_EXPLAIN', + 'ER_FRM_UNKNOWN_TYPE', + 'ER_WRONG_OBJECT', + 'ER_NONUPDATEABLE_COLUMN', + 'ER_VIEW_SELECT_DERIVED', + 'ER_VIEW_SELECT_CLAUSE', + 'ER_VIEW_SELECT_VARIABLE', + 'ER_VIEW_SELECT_TMPTABLE', + 'ER_VIEW_WRONG_LIST', + 'ER_WARN_VIEW_MERGE', + 'ER_WARN_VIEW_WITHOUT_KEY', + 'ER_VIEW_INVALID', + 'ER_SP_NO_DROP_SP', + 'ER_SP_GOTO_IN_HNDLR', + 'ER_TRG_ALREADY_EXISTS', + 'ER_TRG_DOES_NOT_EXIST', + 'ER_TRG_ON_VIEW_OR_TEMP_TABLE', + 'ER_TRG_CANT_CHANGE_ROW', + 'ER_TRG_NO_SUCH_ROW_IN_TRG', + 'ER_NO_DEFAULT_FOR_FIELD', + 'ER_DIVISION_BY_ZERO', + 'ER_TRUNCATED_WRONG_VALUE_FOR_FIELD', + 'ER_ILLEGAL_VALUE_FOR_TYPE', + 'ER_VIEW_NONUPD_CHECK', + 'ER_VIEW_CHECK_FAILED', + 'ER_PROCACCESS_DENIED_ERROR', + 'ER_RELAY_LOG_FAIL', + 'ER_PASSWD_LENGTH', + 'ER_UNKNOWN_TARGET_BINLOG', + 'ER_IO_ERR_LOG_INDEX_READ', + 'ER_BINLOG_PURGE_PROHIBITED', + 'ER_FSEEK_FAIL', + 'ER_BINLOG_PURGE_FATAL_ERR', + 'ER_LOG_IN_USE', + 'ER_LOG_PURGE_UNKNOWN_ERR', + 'ER_RELAY_LOG_INIT', + 'ER_NO_BINARY_LOGGING', + 'ER_RESERVED_SYNTAX', + 'ER_WSAS_FAILED', + 'ER_DIFF_GROUPS_PROC', + 'ER_NO_GROUP_FOR_PROC', + 'ER_ORDER_WITH_PROC', + 'ER_LOGGING_PROHIBIT_CHANGING_OF', + 'ER_NO_FILE_MAPPING', + 'ER_WRONG_MAGIC', + 'ER_PS_MANY_PARAM', + 'ER_KEY_PART_0', + 'ER_VIEW_CHECKSUM', + 'ER_VIEW_MULTIUPDATE', + 'ER_VIEW_NO_INSERT_FIELD_LIST', + 'ER_VIEW_DELETE_MERGE_VIEW', + 'ER_CANNOT_USER', + 'ER_XAER_NOTA', + 'ER_XAER_INVAL', + 'ER_XAER_RMFAIL', + 'ER_XAER_OUTSIDE', + 'ER_XAER_RMERR', + 'ER_XA_RBROLLBACK', + 'ER_NONEXISTING_PROC_GRANT', + 'ER_PROC_AUTO_GRANT_FAIL', + 'ER_PROC_AUTO_REVOKE_FAIL', + 'ER_DATA_TOO_LONG', + 'ER_SP_BAD_SQLSTATE', + 'ER_STARTUP', + 'ER_LOAD_FROM_FIXED_SIZE_ROWS_TO_VAR', + 'ER_CANT_CREATE_USER_WITH_GRANT', + 'ER_WRONG_VALUE_FOR_TYPE', + 'ER_TABLE_DEF_CHANGED', + 'ER_SP_DUP_HANDLER', + 'ER_SP_NOT_VAR_ARG', + 'ER_SP_NO_RETSET', + 'ER_CANT_CREATE_GEOMETRY_OBJECT', + 'ER_FAILED_ROUTINE_BREAK_BINLOG', + 'ER_BINLOG_UNSAFE_ROUTINE', + 'ER_BINLOG_CREATE_ROUTINE_NEED_SUPER', + 'ER_EXEC_STMT_WITH_OPEN_CURSOR', + 'ER_STMT_HAS_NO_OPEN_CURSOR', + 'ER_COMMIT_NOT_ALLOWED_IN_SF_OR_TRG', + 'ER_NO_DEFAULT_FOR_VIEW_FIELD', + 'ER_SP_NO_RECURSION', + 'ER_TOO_BIG_SCALE', + 'ER_TOO_BIG_PRECISION', + 'ER_M_BIGGER_THAN_D', + 'ER_WRONG_LOCK_OF_SYSTEM_TABLE', + 'ER_CONNECT_TO_FOREIGN_DATA_SOURCE', + 'ER_QUERY_ON_FOREIGN_DATA_SOURCE', + 'ER_FOREIGN_DATA_SOURCE_DOESNT_EXIST', + 'ER_FOREIGN_DATA_STRING_INVALID_CANT_CREATE', + 'ER_FOREIGN_DATA_STRING_INVALID', + 'ER_CANT_CREATE_FEDERATED_TABLE', + 'ER_TRG_IN_WRONG_SCHEMA', + 'ER_STACK_OVERRUN_NEED_MORE', + 'ER_TOO_LONG_BODY', + 'ER_WARN_CANT_DROP_DEFAULT_KEYCACHE', + 'ER_TOO_BIG_DISPLAYWIDTH', + 'ER_XAER_DUPID', + 'ER_DATETIME_FUNCTION_OVERFLOW', + 'ER_CANT_UPDATE_USED_TABLE_IN_SF_OR_TRG', + 'ER_VIEW_PREVENT_UPDATE', + 'ER_PS_NO_RECURSION', + 'ER_SP_CANT_SET_AUTOCOMMIT', + 'ER_MALFORMED_DEFINER', + 'ER_VIEW_FRM_NO_USER', + 'ER_VIEW_OTHER_USER', + 'ER_NO_SUCH_USER', + 'ER_FORBID_SCHEMA_CHANGE', + 'ER_ROW_IS_REFERENCED_2', + 'ER_NO_REFERENCED_ROW_2', + 'ER_SP_BAD_VAR_SHADOW', + 'ER_TRG_NO_DEFINER', + 'ER_OLD_FILE_FORMAT', + 'ER_SP_RECURSION_LIMIT', + 'ER_SP_PROC_TABLE_CORRUPT', + 'ER_SP_WRONG_NAME', + 'ER_TABLE_NEEDS_UPGRADE', + 'ER_SP_NO_AGGREGATE', + 'ER_MAX_PREPARED_STMT_COUNT_REACHED', + 'ER_VIEW_RECURSIVE', + 'ER_NON_GROUPING_FIELD_USED', + 'ER_TABLE_CANT_HANDLE_SPKEYS', + 'ER_NO_TRIGGERS_ON_SYSTEM_SCHEMA', + 'ER_REMOVED_SPACES', + 'ER_AUTOINC_READ_FAILED', + 'ER_USERNAME', + 'ER_HOSTNAME', + 'ER_WRONG_STRING_LENGTH', + 'ER_NON_INSERTABLE_TABLE', + 'ER_ADMIN_WRONG_MRG_TABLE', + 'ER_TOO_HIGH_LEVEL_OF_NESTING_FOR_SELECT', + 'ER_NAME_BECOMES_EMPTY', + 'ER_AMBIGUOUS_FIELD_TERM', + 'ER_FOREIGN_SERVER_EXISTS', + 'ER_FOREIGN_SERVER_DOESNT_EXIST', + 'ER_ILLEGAL_HA_CREATE_OPTION', + 'ER_PARTITION_REQUIRES_VALUES_ERROR', + 'ER_PARTITION_WRONG_VALUES_ERROR', + 'ER_PARTITION_MAXVALUE_ERROR', + 'ER_PARTITION_SUBPARTITION_ERROR', + 'ER_PARTITION_SUBPART_MIX_ERROR', + 'ER_PARTITION_WRONG_NO_PART_ERROR', + 'ER_PARTITION_WRONG_NO_SUBPART_ERROR', + 'ER_CONST_EXPR_IN_PARTITION_FUNC_ERROR', + 'ER_CONST_EXPR_IN_PARTITION_FUNC_ERROR', + 'ER_WRONG_EXPR_IN_PARTITION_FUNC_ERROR', + 'ER_CONST_EXPR_IN_PARTITION_FUNC_ERROR', + 'ER_WRONG_EXPR_IN_PARTITION_FUNC_ERROR', + 'ER_CONST_EXPR_IN_PARTITION_FUNC_ERROR', + 'ER_WRONG_EXPR_IN_PARTITION_FUNC_ERROR', + 'ER_WRONG_EXPR_IN_PARTITION_FUNC_ERROR', + 'ER_NO_CONST_EXPR_IN_RANGE_OR_LIST_ERROR', + 'ER_FIELD_NOT_FOUND_PART_ERROR', + 'ER_LIST_OF_FIELDS_ONLY_IN_HASH_ERROR', + 'ER_INCONSISTENT_PARTITION_INFO_ERROR', + 'ER_PARTITION_FUNC_NOT_ALLOWED_ERROR', + 'ER_PARTITIONS_MUST_BE_DEFINED_ERROR', + 'ER_RANGE_NOT_INCREASING_ERROR', + 'ER_INCONSISTENT_TYPE_OF_FUNCTIONS_ERROR', + 'ER_MULTIPLE_DEF_CONST_IN_LIST_PART_ERROR', + 'ER_PARTITION_ENTRY_ERROR', + 'ER_MIX_HANDLER_ERROR', + 'ER_PARTITION_NOT_DEFINED_ERROR', + 'ER_TOO_MANY_PARTITIONS_ERROR', + 'ER_SUBPARTITION_ERROR', + 'ER_CANT_CREATE_HANDLER_FILE', + 'ER_BLOB_FIELD_IN_PART_FUNC_ERROR', + 'ER_UNIQUE_KEY_NEED_ALL_FIELDS_IN_PF', + 'ER_NO_PARTS_ERROR', + 'ER_PARTITION_MGMT_ON_NONPARTITIONED', + 'ER_FOREIGN_KEY_ON_PARTITIONED', + 'ER_DROP_PARTITION_NON_EXISTENT', + 'ER_DROP_LAST_PARTITION', + 'ER_COALESCE_ONLY_ON_HASH_PARTITION', + 'ER_REORG_HASH_ONLY_ON_SAME_NO', + 'ER_REORG_NO_PARAM_ERROR', + 'ER_ONLY_ON_RANGE_LIST_PARTITION', + 'ER_ADD_PARTITION_SUBPART_ERROR', + 'ER_ADD_PARTITION_NO_NEW_PARTITION', + 'ER_COALESCE_PARTITION_NO_PARTITION', + 'ER_REORG_PARTITION_NOT_EXIST', + 'ER_SAME_NAME_PARTITION', + 'ER_NO_BINLOG_ERROR', + 'ER_CONSECUTIVE_REORG_PARTITIONS', + 'ER_REORG_OUTSIDE_RANGE', + 'ER_PARTITION_FUNCTION_FAILURE', + 'ER_PART_STATE_ERROR', + 'ER_LIMITED_PART_RANGE', + 'ER_PLUGIN_IS_NOT_LOADED', + 'ER_WRONG_VALUE', + 'ER_NO_PARTITION_FOR_GIVEN_VALUE', + 'ER_FILEGROUP_OPTION_ONLY_ONCE', + 'ER_CREATE_FILEGROUP_FAILED', + 'ER_DROP_FILEGROUP_FAILED', + 'ER_TABLESPACE_AUTO_EXTEND_ERROR', + 'ER_WRONG_SIZE_NUMBER', + 'ER_SIZE_OVERFLOW_ERROR', + 'ER_ALTER_FILEGROUP_FAILED', + 'ER_BINLOG_ROW_LOGGING_FAILED', + 'ER_BINLOG_ROW_WRONG_TABLE_DEF', + 'ER_BINLOG_ROW_RBR_TO_SBR', + 'ER_EVENT_ALREADY_EXISTS', + 'ER_EVENT_STORE_FAILED', + 'ER_EVENT_DOES_NOT_EXIST', + 'ER_EVENT_CANT_ALTER', + 'ER_EVENT_DROP_FAILED', + 'ER_EVENT_INTERVAL_NOT_POSITIVE_OR_TOO_BIG', + 'ER_EVENT_ENDS_BEFORE_STARTS', + 'ER_EVENT_EXEC_TIME_IN_THE_PAST', + 'ER_EVENT_OPEN_TABLE_FAILED', + 'ER_EVENT_NEITHER_M_EXPR_NOR_M_AT', + 'ER_COL_COUNT_DOESNT_MATCH_CORRUPTED', + 'ER_CANNOT_LOAD_FROM_TABLE', + 'ER_EVENT_CANNOT_DELETE', + 'ER_EVENT_COMPILE_ERROR', + 'ER_EVENT_SAME_NAME', + 'ER_EVENT_DATA_TOO_LONG', + 'ER_DROP_INDEX_FK', + 'ER_WARN_DEPRECATED_SYNTAX_WITH_VER', + 'ER_CANT_WRITE_LOCK_LOG_TABLE', + 'ER_CANT_LOCK_LOG_TABLE', + 'ER_FOREIGN_DUPLICATE_KEY', + 'ER_COL_COUNT_DOESNT_MATCH_PLEASE_UPDATE', + 'ER_TEMP_TABLE_PREVENTS_SWITCH_OUT_OF_RBR', + 'ER_STORED_FUNCTION_PREVENTS_SWITCH_BINLOG_FORMAT', + 'ER_NDB_CANT_SWITCH_BINLOG_FORMAT', + 'ER_PARTITION_NO_TEMPORARY', + 'ER_PARTITION_CONST_DOMAIN_ERROR', + 'ER_PARTITION_FUNCTION_IS_NOT_ALLOWED', + 'ER_DDL_LOG_ERROR', + 'ER_NULL_IN_VALUES_LESS_THAN', + 'ER_WRONG_PARTITION_NAME', + 'ER_CANT_CHANGE_TX_ISOLATION', + 'ER_DUP_ENTRY_AUTOINCREMENT_CASE', + 'ER_EVENT_MODIFY_QUEUE_ERROR', + 'ER_EVENT_SET_VAR_ERROR', + 'ER_PARTITION_MERGE_ERROR', + 'ER_CANT_ACTIVATE_LOG', + 'ER_RBR_NOT_AVAILABLE', + 'ER_BASE64_DECODE_ERROR', + 'ER_EVENT_RECURSION_FORBIDDEN', + 'ER_EVENTS_DB_ERROR', + 'ER_ONLY_INTEGERS_ALLOWED', + 'ER_UNSUPORTED_LOG_ENGINE', + 'ER_BAD_LOG_STATEMENT', + 'ER_CANT_RENAME_LOG_TABLE', + 'ER_WRONG_PARAMCOUNT_TO_NATIVE_FCT', + 'ER_WRONG_PARAMETERS_TO_NATIVE_FCT', + 'ER_WRONG_PARAMETERS_TO_STORED_FCT', + 'ER_NATIVE_FCT_NAME_COLLISION', + 'ER_DUP_ENTRY_WITH_KEY_NAME', + 'ER_DUP_ENTRY', + 'ER_BINLOG_PURGE_EMFILE', + 'ER_EVENT_CANNOT_CREATE_IN_THE_PAST', + 'ER_EVENT_CANNOT_ALTER_IN_THE_PAST', + 'ER_SLAVE_INCIDENT', + 'ER_NO_PARTITION_FOR_GIVEN_VALUE_SILENT', + 'ER_BINLOG_UNSAFE_STATEMENT', + 'ER_SLAVE_FATAL_ERROR', + 'ER_SLAVE_RELAY_LOG_READ_FAILURE', + 'ER_SLAVE_RELAY_LOG_WRITE_FAILURE', + 'ER_SLAVE_CREATE_EVENT_FAILURE', + 'ER_SLAVE_MASTER_COM_FAILURE', + 'ER_BINLOG_LOGGING_IMPOSSIBLE', + 'ER_VIEW_NO_CREATION_CTX', + 'ER_VIEW_INVALID_CREATION_CTX', + 'ER_SR_INVALID_CREATION_CTX', + 'ER_TRG_CORRUPTED_FILE', + 'ER_TRG_NO_CREATION_CTX', + 'ER_TRG_INVALID_CREATION_CTX', + 'ER_EVENT_INVALID_CREATION_CTX', + 'ER_TRG_CANT_OPEN_TABLE', + 'ER_CANT_CREATE_SROUTINE', + 'ER_SLAVE_AMBIGOUS_EXEC_MODE', + 'ER_SLAVE_AMBIGOUS_EXEC_MODE', + 'ER_NEVER_USED', + 'ER_SLAVE_AMBIGOUS_EXEC_MODE', + 'ER_NEVER_USED', + 'ER_SLAVE_AMBIGOUS_EXEC_MODE', + 'ER_NEVER_USED', + 'ER_NEVER_USED', + 'ER_NO_FORMAT_DESCRIPTION_EVENT_BEFORE_BINLOG_STATEMENT', + 'ER_SLAVE_CORRUPT_EVENT', + 'ER_LOAD_DATA_INVALID_COLUMN', + 'ER_LOG_PURGE_NO_FILE', + 'ER_XA_RBTIMEOUT', + 'ER_XA_RBDEADLOCK', + 'ER_NEED_REPREPARE', + 'ER_DELAYED_NOT_SUPPORTED', + 'ER_VARIABLE_IS_READONLY', + 'ER_WARN_ENGINE_TRANSACTION_ROLLBACK', + 'ER_SLAVE_HEARTBEAT_FAILURE', + 'ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE', + 'ER_NDB_REPLICATION_SCHEMA_ERROR', + 'ER_CONFLICT_FN_PARSE_ERROR', + 'ER_EXCEPTIONS_WRITE_ERROR', + 'ER_TOO_LONG_TABLE_COMMENT', + 'ER_TOO_LONG_FIELD_COMMENT', + 'ER_FUNC_INEXISTENT_NAME_COLLISION', + 'ER_DATABASE_NAME', + 'ER_TABLE_NAME', + 'ER_PARTITION_NAME', + 'ER_SUBPARTITION_NAME', + 'ER_TEMPORARY_NAME', + 'ER_RENAMED_NAME', + 'ER_TOO_MANY_CONCURRENT_TRXS', + 'ER_DEBUG_SYNC_TIMEOUT', + 'ER_DEBUG_SYNC_HIT_LIMIT', + 'ER_DUP_SIGNAL_SET', + 'ER_SIGNAL_WARN', + 'ER_SIGNAL_NOT_FOUND', + 'ER_SIGNAL_EXCEPTION', + 'ER_RESIGNAL_WITHOUT_ACTIVE_HANDLER', + 'ER_SIGNAL_BAD_CONDITION_TYPE', + 'ER_COND_ITEM_TOO_LONG', + 'ER_UNKNOWN_LOCALE', + 'ER_SLAVE_IGNORE_SERVER_IDS', + 'ER_QUERY_CACHE_DISABLED', + 'ER_SAME_NAME_PARTITION_FIELD', + 'ER_PARTITION_COLUMN_LIST_ERROR', + 'ER_WRONG_TYPE_COLUMN_VALUE_ERROR', + 'ER_TOO_MANY_PARTITION_FUNC_FIELDS_ERROR', + 'ER_MAXVALUE_IN_VALUES_IN', + 'ER_TOO_MANY_VALUES_ERROR', + 'ER_ROW_SINGLE_PARTITION_FIELD_ERROR', + 'ER_FIELD_TYPE_NOT_ALLOWED_AS_PARTITION_FIELD', + 'ER_PARTITION_FIELDS_TOO_LONG', + 'ER_BINLOG_ROW_ENGINE_AND_STMT_ENGINE', + 'ER_BINLOG_ROW_ENGINE_AND_STMT_ENGINE', + 'ER_BINLOG_ROW_MODE_AND_STMT_ENGINE', + 'ER_BINLOG_ROW_MODE_AND_STMT_ENGINE', + 'ER_BINLOG_UNSAFE_AND_STMT_ENGINE', + 'ER_BINLOG_UNSAFE_AND_STMT_ENGINE', + 'ER_BINLOG_ROW_INJECTION_AND_STMT_ENGINE', + 'ER_BINLOG_ROW_INJECTION_AND_STMT_ENGINE', + 'ER_BINLOG_STMT_MODE_AND_ROW_ENGINE', + 'ER_BINLOG_STMT_MODE_AND_ROW_ENGINE', + 'ER_BINLOG_ROW_INJECTION_AND_STMT_MODE', + 'ER_BINLOG_ROW_INJECTION_AND_STMT_MODE', + 'ER_BINLOG_MULTIPLE_ENGINES_AND_SELF_LOGGING_ENGINE', + 'ER_BINLOG_MULTIPLE_ENGINES_AND_SELF_LOGGING_ENGINE', + 'ER_BINLOG_UNSAFE_LIMIT', + 'ER_BINLOG_UNSAFE_LIMIT', + 'ER_BINLOG_UNSAFE_INSERT_DELAYED', + 'ER_BINLOG_UNSAFE_INSERT_DELAYED', + 'ER_BINLOG_UNSAFE_SYSTEM_TABLE', + 'ER_BINLOG_UNSAFE_SYSTEM_TABLE', + 'ER_BINLOG_UNSAFE_AUTOINC_COLUMNS', + 'ER_BINLOG_UNSAFE_AUTOINC_COLUMNS', + 'ER_BINLOG_UNSAFE_UDF', + 'ER_BINLOG_UNSAFE_UDF', + 'ER_BINLOG_UNSAFE_SYSTEM_VARIABLE', + 'ER_BINLOG_UNSAFE_SYSTEM_VARIABLE', + 'ER_BINLOG_UNSAFE_SYSTEM_FUNCTION', + 'ER_BINLOG_UNSAFE_SYSTEM_FUNCTION', + 'ER_BINLOG_UNSAFE_NONTRANS_AFTER_TRANS', + 'ER_BINLOG_UNSAFE_NONTRANS_AFTER_TRANS', + 'ER_MESSAGE_AND_STATEMENT', + 'ER_MESSAGE_AND_STATEMENT', + 'ER_SLAVE_CONVERSION_FAILED', + 'ER_SLAVE_CONVERSION_FAILED', + 'ER_SLAVE_CANT_CREATE_CONVERSION', + 'ER_SLAVE_CANT_CREATE_CONVERSION', + 'ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_BINLOG_FORMAT', + 'ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_BINLOG_FORMAT', + 'ER_PATH_LENGTH', + 'ER_PATH_LENGTH', + 'ER_WARN_DEPRECATED_SYNTAX_NO_REPLACEMENT', + 'ER_WARN_DEPRECATED_SYNTAX_NO_REPLACEMENT', + 'ER_WRONG_NATIVE_TABLE_STRUCTURE', + 'ER_WRONG_NATIVE_TABLE_STRUCTURE', + 'ER_WRONG_PERFSCHEMA_USAGE', + 'ER_WRONG_PERFSCHEMA_USAGE', + 'ER_WARN_I_S_SKIPPED_TABLE', + 'ER_WARN_I_S_SKIPPED_TABLE', + 'ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_BINLOG_DIRECT', + 'ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_BINLOG_DIRECT', + 'ER_STORED_FUNCTION_PREVENTS_SWITCH_BINLOG_DIRECT', + 'ER_STORED_FUNCTION_PREVENTS_SWITCH_BINLOG_DIRECT', + 'ER_SPATIAL_MUST_HAVE_GEOM_COL', + 'ER_SPATIAL_MUST_HAVE_GEOM_COL', + 'ER_TOO_LONG_INDEX_COMMENT', + 'ER_TOO_LONG_INDEX_COMMENT', + 'ER_LOCK_ABORTED', + 'ER_LOCK_ABORTED', + 'ER_DATA_OUT_OF_RANGE', + 'ER_DATA_OUT_OF_RANGE', + 'ER_WRONG_SPVAR_TYPE_IN_LIMIT', + 'ER_WRONG_SPVAR_TYPE_IN_LIMIT', + 'ER_BINLOG_UNSAFE_MULTIPLE_ENGINES_AND_SELF_LOGGING_ENGINE', + 'ER_BINLOG_UNSAFE_MULTIPLE_ENGINES_AND_SELF_LOGGING_ENGINE', + 'ER_BINLOG_UNSAFE_MIXED_STATEMENT', + 'ER_BINLOG_UNSAFE_MIXED_STATEMENT', + 'ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_SQL_LOG_BIN', + 'ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_SQL_LOG_BIN', + 'ER_STORED_FUNCTION_PREVENTS_SWITCH_SQL_LOG_BIN', + 'ER_STORED_FUNCTION_PREVENTS_SWITCH_SQL_LOG_BIN', + 'ER_FAILED_READ_FROM_PAR_FILE', + 'ER_FAILED_READ_FROM_PAR_FILE', + 'ER_VALUES_IS_NOT_INT_TYPE_ERROR', + 'ER_VALUES_IS_NOT_INT_TYPE_ERROR', + 'ER_ACCESS_DENIED_NO_PASSWORD_ERROR', + 'ER_ACCESS_DENIED_NO_PASSWORD_ERROR', + 'ER_SET_PASSWORD_AUTH_PLUGIN', + 'ER_SET_PASSWORD_AUTH_PLUGIN', + 'ER_GRANT_PLUGIN_USER_EXISTS', + 'ER_GRANT_PLUGIN_USER_EXISTS', + 'ER_TRUNCATE_ILLEGAL_FK', + 'ER_TRUNCATE_ILLEGAL_FK', + 'ER_PLUGIN_IS_PERMANENT', + 'ER_PLUGIN_IS_PERMANENT', + 'ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE_MIN', + 'ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE_MIN', + 'ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE_MAX', + 'ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE_MAX', + 'ER_STMT_CACHE_FULL', + 'ER_STMT_CACHE_FULL', + 'ER_MULTI_UPDATE_KEY_CONFLICT', + 'ER_MULTI_UPDATE_KEY_CONFLICT', + 'ER_TABLE_NEEDS_REBUILD', + 'ER_TABLE_NEEDS_REBUILD', + 'ER_INDEX_COLUMN_TOO_LONG', + 'ER_INDEX_COLUMN_TOO_LONG', + 'ER_ERROR_IN_TRIGGER_BODY', + 'ER_ERROR_IN_TRIGGER_BODY', + 'ER_ERROR_IN_UNKNOWN_TRIGGER_BODY', + 'ER_ERROR_IN_UNKNOWN_TRIGGER_BODY', + 'ER_INDEX_CORRUPT', + 'ER_INDEX_CORRUPT', + 'ER_UNDO_RECORD_TOO_BIG', + 'ER_UNDO_RECORD_TOO_BIG', + 'ER_BINLOG_UNSAFE_INSERT_IGNORE_SELECT', + 'ER_BINLOG_UNSAFE_INSERT_IGNORE_SELECT', + 'ER_BINLOG_UNSAFE_INSERT_SELECT_UPDATE', + 'ER_BINLOG_UNSAFE_INSERT_SELECT_UPDATE', + 'ER_BINLOG_UNSAFE_REPLACE_SELECT', + 'ER_BINLOG_UNSAFE_REPLACE_SELECT', + 'ER_BINLOG_UNSAFE_CREATE_IGNORE_SELECT', + 'ER_BINLOG_UNSAFE_CREATE_IGNORE_SELECT', + 'ER_BINLOG_UNSAFE_CREATE_REPLACE_SELECT', + 'ER_BINLOG_UNSAFE_CREATE_REPLACE_SELECT', + 'ER_BINLOG_UNSAFE_UPDATE_IGNORE', + 'ER_BINLOG_UNSAFE_UPDATE_IGNORE', + 'ER_PLUGIN_NO_UNINSTALL', + 'ER_PLUGIN_NO_UNINSTALL', + 'ER_PLUGIN_NO_INSTALL', + 'ER_PLUGIN_NO_INSTALL', + 'ER_BINLOG_UNSAFE_WRITE_AUTOINC_SELECT', + 'ER_BINLOG_UNSAFE_WRITE_AUTOINC_SELECT', + 'ER_BINLOG_UNSAFE_CREATE_SELECT_AUTOINC', + 'ER_BINLOG_UNSAFE_CREATE_SELECT_AUTOINC', + 'ER_BINLOG_UNSAFE_INSERT_TWO_KEYS', + 'ER_BINLOG_UNSAFE_INSERT_TWO_KEYS', + 'ER_TABLE_IN_FK_CHECK', + 'ER_TABLE_IN_FK_CHECK', + 'ER_UNSUPPORTED_ENGINE', + 'ER_UNSUPPORTED_ENGINE', + 'ER_BINLOG_UNSAFE_AUTOINC_NOT_FIRST', + 'ER_BINLOG_UNSAFE_AUTOINC_NOT_FIRST', +]); diff --git a/lib/dbErrors/errorCodes/postgres.js b/lib/dbErrors/errorCodes/postgres.js new file mode 100644 index 000000000..0f3c61c12 --- /dev/null +++ b/lib/dbErrors/errorCodes/postgres.js @@ -0,0 +1,241 @@ +export const postgresErrorCodes = new Set([ + '000', + '00000', + '01000', + '0100C', + '01008', + '01003', + '01007', + '01006', + '01004', + '01P01', + '02000', + '02001', + '03000', + '08000', + '08003', + '08006', + '08001', + '08004', + '08007', + '08P01', + '09000', + '0A000', + '0B000', + '0F000', + '0F001', + '0L000', + '0LP01', + '0P000', + '0Z000', + '0Z002', + '20000', + '21000', + '22000', + '2202E', + '22021', + '22008', + '22012', + '22005', + '2200B', + '22022', + '22015', + '2201E', + '22014', + '22016', + '2201F', + '2201G', + '22018', + '22007', + '22019', + '2200D', + '22025', + '22P06', + '22010', + '22023', + '2201B', + '2201W', + '2201X', + '2202H', + '2202G', + '22009', + '2200C', + '2200G', + '22004', + '22002', + '22003', + '22026', + '22001', + '22011', + '22027', + '22024', + '2200F', + '22P01', + '22P02', + '22P03', + '22P04', + '22P05', + '2200L', + '2200M', + '2200N', + '2200S', + '2200T', + '23000', + '23001', + '23502', + '23503', + '23505', + '23514', + '23P01', + '24000', + '25000', + '25001', + '25002', + '25008', + '25003', + '25004', + '25005', + '25006', + '25007', + '25P01', + '25P02', + '25P03', + '26000', + '27000', + '28000', + '28P01', + '2B000', + '2BP01', + '2D000', + '2F000', + '2F005', + '2F002', + '2F003', + '2F004', + '34000', + '38000', + '38001', + '38002', + '38003', + '38004', + '39000', + '39001', + '39004', + '39P01', + '39P02', + '39P03', + '3B000', + '3B001', + '3D000', + '3F000', + '40000', + '40002', + '40001', + '40003', + '40P01', + '42000', + '42601', + '42501', + '42846', + '42803', + '42P20', + '42P19', + '42830', + '42602', + '42622', + '42939', + '42804', + '42P18', + '42P21', + '42P22', + '42809', + '42703', + '42883', + '42P01', + '42P02', + '42704', + '42701', + '42P03', + '42P04', + '42723', + '42P05', + '42P06', + '42P07', + '42712', + '42710', + '42702', + '42725', + '42P08', + '42P09', + '42P10', + '42611', + '42P11', + '42P12', + '42P13', + '42P14', + '42P15', + '42P16', + '42P17', + '44000', + '53000', + '53100', + '53200', + '53300', + '53400', + '54000', + '54001', + '54011', + '54023', + '55000', + '55006', + '55P02', + '55P03', + '57000', + '57014', + '57P01', + '57P02', + '57P03', + '57P04', + '58000', + '58030', + '58P01', + '58P02', + '72000', + 'F0000', + 'F0001', + 'HV000', + 'HV005', + 'HV002', + 'HV010', + 'HV021', + 'HV024', + 'HV007', + 'HV008', + 'HV004', + 'HV006', + 'HV091', + 'HV00B', + 'HV00C', + 'HV00D', + 'HV090', + 'HV00A', + 'HV009', + 'HV014', + 'HV001', + 'HV00P', + 'HV00J', + 'HV00K', + 'HV00Q', + 'HV00R', + 'HV00L', + 'HV00M', + 'HV00N', + 'P0000', + 'P0001', + 'P0002', + 'P0003', + 'P0004', + 'XX000', + 'XX001', + 'XX002', +]); diff --git a/lib/dbErrors/errors/CheckViolationError.js b/lib/dbErrors/errors/CheckViolationError.js new file mode 100644 index 000000000..af57a635a --- /dev/null +++ b/lib/dbErrors/errors/CheckViolationError.js @@ -0,0 +1,10 @@ +import { ConstraintViolationError } from './ConstraintViolationError.js'; + +export class CheckViolationError extends ConstraintViolationError { + constructor(args) { + super(args); + + this.table = args.table; + this.constraint = args.constraint; + } +} diff --git a/lib/dbErrors/errors/ConstraintViolationError.js b/lib/dbErrors/errors/ConstraintViolationError.js new file mode 100644 index 000000000..5253b4908 --- /dev/null +++ b/lib/dbErrors/errors/ConstraintViolationError.js @@ -0,0 +1,3 @@ +import { DBError } from './DBError.js'; + +export class ConstraintViolationError extends DBError {} diff --git a/lib/dbErrors/errors/DBError.js b/lib/dbErrors/errors/DBError.js new file mode 100644 index 000000000..811032fd0 --- /dev/null +++ b/lib/dbErrors/errors/DBError.js @@ -0,0 +1,9 @@ +export class DBError extends Error { + constructor(args) { + super(args.message ?? args.nativeError.message); + + this.name = this.constructor.name; + this.nativeError = args.nativeError; + this.client = args.client; + } +} diff --git a/lib/dbErrors/errors/DataError.js b/lib/dbErrors/errors/DataError.js new file mode 100644 index 000000000..e4717e706 --- /dev/null +++ b/lib/dbErrors/errors/DataError.js @@ -0,0 +1,3 @@ +import { DBError } from './DBError.js'; + +export class DataError extends DBError {} diff --git a/lib/dbErrors/errors/ForeignKeyViolationError.js b/lib/dbErrors/errors/ForeignKeyViolationError.js new file mode 100644 index 000000000..5d0b402d2 --- /dev/null +++ b/lib/dbErrors/errors/ForeignKeyViolationError.js @@ -0,0 +1,11 @@ +import { ConstraintViolationError } from './ConstraintViolationError.js'; + +export class ForeignKeyViolationError extends ConstraintViolationError { + constructor(args) { + super(args); + + this.table = args.table; + this.constraint = args.constraint; + this.schema = args.schema; + } +} diff --git a/lib/dbErrors/errors/NotNullViolationError.js b/lib/dbErrors/errors/NotNullViolationError.js new file mode 100644 index 000000000..3c354304c --- /dev/null +++ b/lib/dbErrors/errors/NotNullViolationError.js @@ -0,0 +1,12 @@ +import { ConstraintViolationError } from './ConstraintViolationError.js'; + +export class NotNullViolationError extends ConstraintViolationError { + constructor(args) { + super(args); + + this.table = args.table; + this.column = args.column; + this.schema = args.schema; + this.database = args.database; + } +} diff --git a/lib/dbErrors/errors/UniqueViolationError.js b/lib/dbErrors/errors/UniqueViolationError.js new file mode 100644 index 000000000..76a95bc09 --- /dev/null +++ b/lib/dbErrors/errors/UniqueViolationError.js @@ -0,0 +1,12 @@ +import { ConstraintViolationError } from './ConstraintViolationError.js'; + +export class UniqueViolationError extends ConstraintViolationError { + constructor(args) { + super(args); + + this.table = args.table; + this.columns = args.columns; + this.constraint = args.constraint; + this.schema = args.schema; + } +} diff --git a/lib/dbErrors/index.js b/lib/dbErrors/index.js new file mode 100644 index 000000000..5e64ddfda --- /dev/null +++ b/lib/dbErrors/index.js @@ -0,0 +1,59 @@ +/** + * Vendored from db-errors 0.2.3 (https://github.com/Vincit/db-errors), + * converted to ES modules, with support for the mssql errors of tedious and + * for mssql primary key violations. + * + * Copyright (c) 2017 Vincit + * Released under the MIT license, see ./LICENSE + */ + +import { dbErrorParser as postgresParser } from './parsers/postgres/DBError/parser.js'; +import { dbErrorParser as sqliteParser } from './parsers/sqlite/DBError/parser.js'; +import { dbErrorParser as mysqlParser } from './parsers/mysql/DBError/parser.js'; +import { dbErrorParser as mssqlParser } from './parsers/mssql/DBError/parser.js'; + +export { DBError } from './errors/DBError.js'; +export { ConstraintViolationError } from './errors/ConstraintViolationError.js'; +export { ForeignKeyViolationError } from './errors/ForeignKeyViolationError.js'; +export { NotNullViolationError } from './errors/NotNullViolationError.js'; +export { UniqueViolationError } from './errors/UniqueViolationError.js'; +export { CheckViolationError } from './errors/CheckViolationError.js'; +export { DataError } from './errors/DataError.js'; + +// The parsers are tried in this order. +const parsers = [postgresParser, sqliteParser, mysqlParser, mssqlParser]; + +export function wrapError(err) { + for (let i = 0, l = parsers.length; i < l; ++i) { + const result = parse(parsers[i], err, null); + + if (result !== null) { + return new result.node.error(result.args); + } + } + + return err; +} + +function parse(node, err, parentResult) { + const args = node.parse(err); + + if (args === null) { + return null; + } + + const result = { + node, + args: Object.assign({}, parentResult && parentResult.args, args), + }; + + for (let i = 0; i < node.subclassParsers.length; ++i) { + const subResult = parse(node.subclassParsers[i], err, result); + + if (subResult !== null) { + return subResult; + } + } + + return result; +} diff --git a/lib/dbErrors/parsers/mssql/DBError/CheckViolationError/parser.js b/lib/dbErrors/parsers/mssql/DBError/CheckViolationError/parser.js new file mode 100644 index 000000000..652b0472e --- /dev/null +++ b/lib/dbErrors/parsers/mssql/DBError/CheckViolationError/parser.js @@ -0,0 +1,28 @@ +import { CheckViolationError } from '../../../../errors/CheckViolationError.js'; +import { isCode } from '../util.js'; + +const REGEX = + /The (INSERT|UPDATE) statement conflicted with the CHECK constraint "(.+)". The conflict occurred in database "(.+)", table "(.+)", column '(.+)'./; + +export const checkViolationErrorParser = { + error: CheckViolationError, + + parse: (err) => { + if (isCode(err, 16, 547)) { + const match = REGEX.exec(err.message); + + if (!match) { + return null; + } + + return { + table: match[3], + constraint: match[1], + }; + } else { + return null; + } + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/mssql/DBError/DataError/parser.js b/lib/dbErrors/parsers/mssql/DBError/DataError/parser.js new file mode 100644 index 000000000..9333182f4 --- /dev/null +++ b/lib/dbErrors/parsers/mssql/DBError/DataError/parser.js @@ -0,0 +1,25 @@ +import { DataError } from '../../../../errors/DataError.js'; +import { isCode } from '../util.js'; + +export const dataErrorParser = { + error: DataError, + + parse: (err) => { + // 241 - Conversion failed when converting date and/or time from character string. + // 242 - The conversion of a nvarchar data type to a datetime data type resulted in an out-of-range value. + // 245 - Conversion failed when converting the nvarchar value 'lol' to data type int. + // 8152 - String or binary data would be truncated. + if ( + isCode(err, 16, 241) || + isCode(err, 16, 242) || + isCode(err, 16, 245) || + isCode(err, 16, 8152) + ) { + return {}; + } else { + return null; + } + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/mssql/DBError/ForeignKeyViolationError/parser.js b/lib/dbErrors/parsers/mssql/DBError/ForeignKeyViolationError/parser.js new file mode 100644 index 000000000..6c73c1179 --- /dev/null +++ b/lib/dbErrors/parsers/mssql/DBError/ForeignKeyViolationError/parser.js @@ -0,0 +1,39 @@ +import { ForeignKeyViolationError } from '../../../../errors/ForeignKeyViolationError.js'; +import { isCode } from '../util.js'; + +const INSERT_UPDATE_REGEX = + /The (?:INSERT|UPDATE) statement conflicted with the FOREIGN KEY constraint "(.+)". The conflict occurred in database "(.+)", table "(.+)\.(.+)", column '(.+)'./; +const DELETE_REGEX = + /The DELETE statement conflicted with the REFERENCE constraint "(.+)". The conflict occurred in database "(.+)", table "(.+)\.(.+)", column '(.+)'./; + +export const foreignKeyViolationErrorParser = { + error: ForeignKeyViolationError, + + parse: (err) => { + if (isCode(err, 16, 547)) { + const insertUpdateMatch = INSERT_UPDATE_REGEX.exec(err.message); + + if (insertUpdateMatch) { + return { + table: insertUpdateMatch[4], + schema: insertUpdateMatch[3], + constraint: insertUpdateMatch[1], + }; + } + + const deleteMatch = DELETE_REGEX.exec(err.message); + + if (deleteMatch) { + return { + table: deleteMatch[4], + schema: deleteMatch[3], + constraint: deleteMatch[1], + }; + } + } + + return null; + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/mssql/DBError/NotNullViolationError/parser.js b/lib/dbErrors/parsers/mssql/DBError/NotNullViolationError/parser.js new file mode 100644 index 000000000..eebeca3ef --- /dev/null +++ b/lib/dbErrors/parsers/mssql/DBError/NotNullViolationError/parser.js @@ -0,0 +1,30 @@ +import { NotNullViolationError } from '../../../../errors/NotNullViolationError.js'; +import { isCode } from '../util.js'; + +const REGEX = + /Cannot insert the value NULL into column '(.+)', table '(.+)\.(.+)\.(.+)'; column does not allow nulls. (?:INSERT|UPDATE) fails./; + +export const notNullViolationErrorParser = { + error: NotNullViolationError, + + parse: (err) => { + if (isCode(err, 16, 515) || isCode(err, 16, 50000)) { + const match = REGEX.exec(err.message); + + if (!match) { + return null; + } + + return { + column: match[1], + database: match[2], + schema: match[3], + table: match[4], + }; + } else { + return null; + } + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/mssql/DBError/UniqueViolationError/parser.js b/lib/dbErrors/parsers/mssql/DBError/UniqueViolationError/parser.js new file mode 100644 index 000000000..4451f442b --- /dev/null +++ b/lib/dbErrors/parsers/mssql/DBError/UniqueViolationError/parser.js @@ -0,0 +1,53 @@ +import { UniqueViolationError } from '../../../../errors/UniqueViolationError.js'; +import { isCode } from '../util.js'; + +const UNIQUE_INDEX_REGEX = + /Cannot insert duplicate key row in object '(.+)\.(.+)' with unique index '(.+)'. The duplicate key value is (.+)./; +const UNIQUE_CONSTRAINT_REGEX = + /Violation of UNIQUE KEY constraint '(.+)'. Cannot insert duplicate key in object '(.+)\.(.+)'. The duplicate key value is \((.+)\)/; + +// The same error number (2627) is also used for primary key violations (#2688). +const PRIMARY_KEY_REGEX = + /Violation of PRIMARY KEY constraint '(.+)'. Cannot insert duplicate key in object '(.+)\.(.+)'. The duplicate key value is \((.+)\)/; + +// 2601 - Violation in unique index +// 2627 - Violation in unique or primary key constraint (although it is implemented using unique index) + +export const uniqueViolationErrorParser = { + error: UniqueViolationError, + + parse: (err) => { + if (isCode(err, 14, 2627)) { + const constraintMatch = + UNIQUE_CONSTRAINT_REGEX.exec(err.message) || PRIMARY_KEY_REGEX.exec(err.message); + + if (!constraintMatch) { + return null; + } + + return { + table: constraintMatch[3], + schema: constraintMatch[2], + constraint: constraintMatch[1], + }; + } + + if (isCode(err, 14, 2601)) { + const indexMatch = UNIQUE_INDEX_REGEX.exec(err.message); + + if (!indexMatch) { + return null; + } + + return { + table: indexMatch[2], + constraint: indexMatch[3], + schema: indexMatch[1], + }; + } + + return null; + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/mssql/DBError/parser.js b/lib/dbErrors/parsers/mssql/DBError/parser.js new file mode 100644 index 000000000..684378c51 --- /dev/null +++ b/lib/dbErrors/parsers/mssql/DBError/parser.js @@ -0,0 +1,51 @@ +import { DBError } from '../../../errors/DBError.js'; +import { mssqlErrorCodes } from '../../../errorCodes/mssql.js'; +import { getCode } from './util.js'; +import { checkViolationErrorParser } from './CheckViolationError/parser.js'; +import { dataErrorParser } from './DataError/parser.js'; +import { foreignKeyViolationErrorParser } from './ForeignKeyViolationError/parser.js'; +import { notNullViolationErrorParser } from './NotNullViolationError/parser.js'; +import { uniqueViolationErrorParser } from './UniqueViolationError/parser.js'; + +export const dbErrorParser = { + error: DBError, + + parse: (err) => { + // db-errors was written for the `mssql` package, which wraps the errors of + // tedious in `originalError`. knex uses tedious directly, so we parse those. + if (err.code === 'EREQUEST' && mssqlErrorCodes.has(getCode(err))) { + return { + nativeError: err, + client: 'mssql', + }; + } + + // tedious throws an AggregateError with an empty message when SQL Server + // reports several errors for one statement, and knex only prepends the SQL + // to it. Add the messages of its errors, to give the reasons. + if (isTediousAggregateError(err)) { + return { + nativeError: err, + client: 'mssql', + message: err.message + err.errors.map((error) => error.message).join(' '), + }; + } + + return null; + }, + + subclassParsers: [ + checkViolationErrorParser, + dataErrorParser, + foreignKeyViolationErrorParser, + notNullViolationErrorParser, + uniqueViolationErrorParser, + ], +}; + +function isTediousAggregateError(err) { + return ( + err instanceof AggregateError && + err.errors.every((error) => error?.code === 'EREQUEST' && Number.isInteger(error.number)) + ); +} diff --git a/lib/dbErrors/parsers/mssql/DBError/util.js b/lib/dbErrors/parsers/mssql/DBError/util.js new file mode 100644 index 000000000..3c5087945 --- /dev/null +++ b/lib/dbErrors/parsers/mssql/DBError/util.js @@ -0,0 +1,16 @@ +export function getCode(error) { + return { + errorCode: error.number, + severity: error.class, + }; +} + +export function isCode(error, severity, errorCode) { + const code = getCode(error); + + if (code) { + return code.errorCode === errorCode && code.severity === severity; + } + + return false; +} diff --git a/lib/dbErrors/parsers/mysql/DBError/ConstraintViolationError/ForeignKeyViolationError/parser.js b/lib/dbErrors/parsers/mysql/DBError/ConstraintViolationError/ForeignKeyViolationError/parser.js new file mode 100644 index 000000000..3e74271df --- /dev/null +++ b/lib/dbErrors/parsers/mysql/DBError/ConstraintViolationError/ForeignKeyViolationError/parser.js @@ -0,0 +1,43 @@ +import { ForeignKeyViolationError } from '../../../../../errors/ForeignKeyViolationError.js'; + +const NO_REFERENCED_REGEX = + /a foreign key constraint fails \(`(.+)`\.`(.+)`, CONSTRAINT `(.+)` FOREIGN KEY \(`(.+)`\) REFERENCES `(.+)` \(`(.+)`\)/; +const ROW_IS_REFERENCED_REGEX = + /Cannot delete or update a parent row: a foreign key constraint fails \(`(.+)`\.`(.+)`, CONSTRAINT `(.+)` FOREIGN KEY \(`(.+)`\) REFERENCES `(.+)` \(`(.+)`\)/; + +export const foreignKeyViolationErrorParser = { + error: ForeignKeyViolationError, + + parse: (err) => { + let match = null; + + if (err.code === 'ER_NO_REFERENCED_ROW' || err.code === 'ER_ROW_IS_REFERENCED') { + // For these variants, there is no table or constraint information available. + // These seem to be mostly thrown on mysql 8 when the db user doesn't have + // priviledges to the parent table. There seems to be a bug however that + // causes these generic errors to be thrown even in some cases where the + // user DOES have priviledges. + return { + table: null, + constraint: null, + }; + } + + if (err.code === 'ER_NO_REFERENCED_ROW_2') { + match = NO_REFERENCED_REGEX.exec(err.sqlMessage); + } else if (err.code === 'ER_ROW_IS_REFERENCED_2') { + match = ROW_IS_REFERENCED_REGEX.exec(err.sqlMessage); + } + + if (!match) { + return null; + } + + return { + table: match[2], + constraint: match[3], + }; + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/mysql/DBError/ConstraintViolationError/NotNullViolationError/parser.js b/lib/dbErrors/parsers/mysql/DBError/ConstraintViolationError/NotNullViolationError/parser.js new file mode 100644 index 000000000..988f02ffe --- /dev/null +++ b/lib/dbErrors/parsers/mysql/DBError/ConstraintViolationError/NotNullViolationError/parser.js @@ -0,0 +1,29 @@ +import { NotNullViolationError } from '../../../../../errors/NotNullViolationError.js'; + +const COLUMN_REGEX_BAD_NULL_ERROR = /Column '(.+)' cannot be null/; +const COLUMN_REGEX_NO_DEFAULT_FOR_FIELD = /Field '(.+)' doesn't have a default value/; + +export const notNullViolationErrorParser = { + error: NotNullViolationError, + + parse: (err) => { + let columnMatch = null; + + if (err.code === 'ER_BAD_NULL_ERROR') { + columnMatch = COLUMN_REGEX_BAD_NULL_ERROR.exec(err.sqlMessage); + } else if (err.code === 'ER_NO_DEFAULT_FOR_FIELD') { + columnMatch = COLUMN_REGEX_NO_DEFAULT_FOR_FIELD.exec(err.sqlMessage); + } + + if (!columnMatch) { + return null; + } + + // No way to reliably get `table` from mysql error. + return { + column: columnMatch[1], + }; + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/mysql/DBError/ConstraintViolationError/UniqueViolationError/parser.js b/lib/dbErrors/parsers/mysql/DBError/ConstraintViolationError/UniqueViolationError/parser.js new file mode 100644 index 000000000..d6bfe133c --- /dev/null +++ b/lib/dbErrors/parsers/mysql/DBError/ConstraintViolationError/UniqueViolationError/parser.js @@ -0,0 +1,27 @@ +import { UniqueViolationError } from '../../../../../errors/UniqueViolationError.js'; + +const CODES = ['ER_DUP_ENTRY', 'ER_DUP_ENTRY_WITH_KEY_NAME']; +const CONSTRAINT_REGEX = /Duplicate entry '(.+)' for key '(.+)'/; + +export const uniqueViolationErrorParser = { + error: UniqueViolationError, + + parse: (err) => { + if (CODES.indexOf(err.code) === -1) { + return null; + } + + const constraintMatch = CONSTRAINT_REGEX.exec(err.sqlMessage); + + if (!constraintMatch) { + return null; + } + + // No way to reliably get `table` and `columns` from mysql error. + return { + constraint: constraintMatch[2], + }; + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/mysql/DBError/ConstraintViolationError/parser.js b/lib/dbErrors/parsers/mysql/DBError/ConstraintViolationError/parser.js new file mode 100644 index 000000000..35e4b9da9 --- /dev/null +++ b/lib/dbErrors/parsers/mysql/DBError/ConstraintViolationError/parser.js @@ -0,0 +1,23 @@ +import { ConstraintViolationError } from '../../../../errors/ConstraintViolationError.js'; +import { getSqlStateClass } from '../../../../utils/sqlState.js'; +import { uniqueViolationErrorParser } from './UniqueViolationError/parser.js'; +import { notNullViolationErrorParser } from './NotNullViolationError/parser.js'; +import { foreignKeyViolationErrorParser } from './ForeignKeyViolationError/parser.js'; + +export const constraintViolationErrorParser = { + error: ConstraintViolationError, + + parse: (err) => { + if (getSqlStateClass(err.sqlState) === '23' || err.code === 'ER_NO_DEFAULT_FOR_FIELD') { + return {}; + } else { + return null; + } + }, + + subclassParsers: [ + uniqueViolationErrorParser, + notNullViolationErrorParser, + foreignKeyViolationErrorParser, + ], +}; diff --git a/lib/dbErrors/parsers/mysql/DBError/DataError/parser.js b/lib/dbErrors/parsers/mysql/DBError/DataError/parser.js new file mode 100644 index 000000000..799e1bf08 --- /dev/null +++ b/lib/dbErrors/parsers/mysql/DBError/DataError/parser.js @@ -0,0 +1,24 @@ +import { DataError } from '../../../../errors/DataError.js'; +import { getSqlStateClass } from '../../../../utils/sqlState.js'; + +const ERROR_CODES = [ + 'ER_DATA_TOO_LONG', + 'ER_TRUNCATED_WRONG_VALUE', + 'ER_TRUNCATED_WRONG_VALUE_FOR_FIELD', +]; + +export const dataErrorParser = { + error: DataError, + + parse: (err) => { + // MySQL mainly uses the SQLSTATE codes, but some errors don't have + // an SQLSTATE equivalent. + if (getSqlStateClass(err.sqlState) === '22' || ERROR_CODES.includes(err.code)) { + return {}; + } else { + return null; + } + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/mysql/DBError/parser.js b/lib/dbErrors/parsers/mysql/DBError/parser.js new file mode 100644 index 000000000..62a35128e --- /dev/null +++ b/lib/dbErrors/parsers/mysql/DBError/parser.js @@ -0,0 +1,28 @@ +import { DBError } from '../../../errors/DBError.js'; +import { mysqlErrorCodes } from '../../../errorCodes/mysql.js'; +import { constraintViolationErrorParser } from './ConstraintViolationError/parser.js'; +import { dataErrorParser } from './DataError/parser.js'; + +export const dbErrorParser = { + error: DBError, + + parse: (err) => { + if ( + typeof err.code === 'string' && + 'sqlMessage' in err && + 'sqlState' in err && + err.sqlState.length === 5 && + mysqlErrorCodes.has(err.code) && + typeof err.errno === 'number' + ) { + return { + nativeError: err, + client: 'mysql', + }; + } + + return null; + }, + + subclassParsers: [constraintViolationErrorParser, dataErrorParser], +}; diff --git a/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/CheckViolationError/parser.js b/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/CheckViolationError/parser.js new file mode 100644 index 000000000..5111441ce --- /dev/null +++ b/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/CheckViolationError/parser.js @@ -0,0 +1,18 @@ +import { CheckViolationError } from '../../../../../errors/CheckViolationError.js'; + +export const checkViolationErrorParser = { + error: CheckViolationError, + + parse: (err) => { + if (err.code === '23514') { + return { + table: err.table, + constraint: err.constraint, + }; + } else { + return null; + } + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/ForeignKeyViolationError/parser.js b/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/ForeignKeyViolationError/parser.js new file mode 100644 index 000000000..ed20c1779 --- /dev/null +++ b/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/ForeignKeyViolationError/parser.js @@ -0,0 +1,18 @@ +import { ForeignKeyViolationError } from '../../../../../errors/ForeignKeyViolationError.js'; + +export const foreignKeyViolationErrorParser = { + error: ForeignKeyViolationError, + + parse: (err) => { + if (err.code === '23503') { + return { + table: err.table, + constraint: err.constraint, + }; + } else { + return null; + } + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/NotNullViolationError/parser.js b/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/NotNullViolationError/parser.js new file mode 100644 index 000000000..fe26ff519 --- /dev/null +++ b/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/NotNullViolationError/parser.js @@ -0,0 +1,18 @@ +import { NotNullViolationError } from '../../../../../errors/NotNullViolationError.js'; + +export const notNullViolationErrorParser = { + error: NotNullViolationError, + + parse: (err) => { + if (err.code === '23502') { + return { + table: err.table, + column: err.column, + }; + } else { + return null; + } + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/UniqueViolationError/parser.js b/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/UniqueViolationError/parser.js new file mode 100644 index 000000000..5775866b3 --- /dev/null +++ b/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/UniqueViolationError/parser.js @@ -0,0 +1,40 @@ +import { UniqueViolationError } from '../../../../../errors/UniqueViolationError.js'; + +const UNIQUE_COLUMNS_REGEX = /Key \((.+)\)=\(.+\) already exists/; + +export const uniqueViolationErrorParser = { + error: UniqueViolationError, + + parse: (err) => { + if (err.code !== '23505') { + return null; + } + + const colsMatch = UNIQUE_COLUMNS_REGEX.exec(err.detail); + + if (!colsMatch) { + return null; + } + + const cols = colsMatch[1] + .split(',') + .map((it) => it.trim()) + .map((it) => stripQuotes(it)); + + return { + table: err.table, + columns: cols, + constraint: err.constraint, + }; + }, + + subclassParsers: [], +}; + +function stripQuotes(it) { + if (it[0] === '"' && it[it.length - 1] === '"') { + return it.substr(1, it.length - 2); + } else { + return it; + } +} diff --git a/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/parser.js b/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/parser.js new file mode 100644 index 000000000..e5aedcaa1 --- /dev/null +++ b/lib/dbErrors/parsers/postgres/DBError/ConstraintViolationError/parser.js @@ -0,0 +1,25 @@ +import { ConstraintViolationError } from '../../../../errors/ConstraintViolationError.js'; +import { getSqlStateClass } from '../../../../utils/sqlState.js'; +import { uniqueViolationErrorParser } from './UniqueViolationError/parser.js'; +import { notNullViolationErrorParser } from './NotNullViolationError/parser.js'; +import { foreignKeyViolationErrorParser } from './ForeignKeyViolationError/parser.js'; +import { checkViolationErrorParser } from './CheckViolationError/parser.js'; + +export const constraintViolationErrorParser = { + error: ConstraintViolationError, + + parse: (err) => { + if (getSqlStateClass(err.code) === '23') { + return {}; + } else { + return null; + } + }, + + subclassParsers: [ + uniqueViolationErrorParser, + notNullViolationErrorParser, + foreignKeyViolationErrorParser, + checkViolationErrorParser, + ], +}; diff --git a/lib/dbErrors/parsers/postgres/DBError/DataError/parser.js b/lib/dbErrors/parsers/postgres/DBError/DataError/parser.js new file mode 100644 index 000000000..91e09cd63 --- /dev/null +++ b/lib/dbErrors/parsers/postgres/DBError/DataError/parser.js @@ -0,0 +1,16 @@ +import { DataError } from '../../../../errors/DataError.js'; +import { getSqlStateClass } from '../../../../utils/sqlState.js'; + +export const dataErrorParser = { + error: DataError, + + parse: (err) => { + if (getSqlStateClass(err.code) === '22') { + return {}; + } else { + return null; + } + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/postgres/DBError/parser.js b/lib/dbErrors/parsers/postgres/DBError/parser.js new file mode 100644 index 000000000..0833a38d2 --- /dev/null +++ b/lib/dbErrors/parsers/postgres/DBError/parser.js @@ -0,0 +1,27 @@ +import { DBError } from '../../../errors/DBError.js'; +import { postgresErrorCodes } from '../../../errorCodes/postgres.js'; +import { constraintViolationErrorParser } from './ConstraintViolationError/parser.js'; +import { dataErrorParser } from './DataError/parser.js'; + +export const dbErrorParser = { + error: DBError, + + parse: (err) => { + if ( + typeof err.code === 'string' && + err.code.length === 5 && + postgresErrorCodes.has(err.code) && + 'internalQuery' in err && + 'table' in err + ) { + return { + nativeError: err, + client: 'postgres', + }; + } + + return null; + }, + + subclassParsers: [constraintViolationErrorParser, dataErrorParser], +}; diff --git a/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/CheckViolationError/parser.js b/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/CheckViolationError/parser.js new file mode 100644 index 000000000..b6ca9898b --- /dev/null +++ b/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/CheckViolationError/parser.js @@ -0,0 +1,20 @@ +import { CheckViolationError } from '../../../../../errors/CheckViolationError.js'; + +const REGEX = /SQLITE_CONSTRAINT: CHECK constraint failed/; + +export const checkViolationErrorParser = { + error: CheckViolationError, + + parse: (err) => { + const match = REGEX.exec(err.message); + + if (!match) { + return null; + } + + // No way to extract anything reliably. + return {}; + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/ForeignKeyViolationError/parser.js b/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/ForeignKeyViolationError/parser.js new file mode 100644 index 000000000..67d0ba7fa --- /dev/null +++ b/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/ForeignKeyViolationError/parser.js @@ -0,0 +1,20 @@ +import { ForeignKeyViolationError } from '../../../../../errors/ForeignKeyViolationError.js'; + +const REGEX = /SQLITE_CONSTRAINT: FOREIGN KEY constraint failed/; + +export const foreignKeyViolationErrorParser = { + error: ForeignKeyViolationError, + + parse: (err) => { + const match = REGEX.exec(err.message); + + if (!match) { + return null; + } + + // No way to extract anything reliably. + return {}; + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/NotNullViolationError/parser.js b/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/NotNullViolationError/parser.js new file mode 100644 index 000000000..6a7e2cb0b --- /dev/null +++ b/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/NotNullViolationError/parser.js @@ -0,0 +1,27 @@ +import { NotNullViolationError } from '../../../../../errors/NotNullViolationError.js'; + +const NOT_COLUMN_REGEX = /SQLITE_CONSTRAINT: NOT NULL constraint failed: (.+)$/; + +export const notNullViolationErrorParser = { + error: NotNullViolationError, + + parse: (err) => { + const colMatch = NOT_COLUMN_REGEX.exec(err.message); + + if (!colMatch) { + return null; + } + + const parts = colMatch[1] + .trim() + .split('.') + .map((it) => it.trim()); + + return { + table: parts[0], + column: parts[1], + }; + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/UniqueViolationError/parser.js b/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/UniqueViolationError/parser.js new file mode 100644 index 000000000..6612119dd --- /dev/null +++ b/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/UniqueViolationError/parser.js @@ -0,0 +1,28 @@ +import { UniqueViolationError } from '../../../../../errors/UniqueViolationError.js'; + +const UNIQUE_COLUMNS_REGEX = /SQLITE_CONSTRAINT: UNIQUE constraint failed: (.+)$/; + +export const uniqueViolationErrorParser = { + error: UniqueViolationError, + + parse: (err) => { + const colsMatch = UNIQUE_COLUMNS_REGEX.exec(err.message); + + if (!colsMatch) { + return null; + } + + const cols = colsMatch[1] + .split(',') + .map((it) => it.trim()) + .map((it) => it.split('.')) + .map((it) => it.map((it) => it.trim())); + + return { + table: cols[0][0], + columns: cols.map((it) => it[1]), + }; + }, + + subclassParsers: [], +}; diff --git a/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/parser.js b/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/parser.js new file mode 100644 index 000000000..fba42142e --- /dev/null +++ b/lib/dbErrors/parsers/sqlite/DBError/ConstraintViolationError/parser.js @@ -0,0 +1,24 @@ +import { ConstraintViolationError } from '../../../../errors/ConstraintViolationError.js'; +import { uniqueViolationErrorParser } from './UniqueViolationError/parser.js'; +import { notNullViolationErrorParser } from './NotNullViolationError/parser.js'; +import { foreignKeyViolationErrorParser } from './ForeignKeyViolationError/parser.js'; +import { checkViolationErrorParser } from './CheckViolationError/parser.js'; + +export const constraintViolationErrorParser = { + error: ConstraintViolationError, + + parse: (err) => { + if (err.code === 'SQLITE_CONSTRAINT') { + return {}; + } else { + return null; + } + }, + + subclassParsers: [ + uniqueViolationErrorParser, + notNullViolationErrorParser, + foreignKeyViolationErrorParser, + checkViolationErrorParser, + ], +}; diff --git a/lib/dbErrors/parsers/sqlite/DBError/parser.js b/lib/dbErrors/parsers/sqlite/DBError/parser.js new file mode 100644 index 000000000..8e5a109d5 --- /dev/null +++ b/lib/dbErrors/parsers/sqlite/DBError/parser.js @@ -0,0 +1,23 @@ +import { DBError } from '../../../errors/DBError.js'; +import { constraintViolationErrorParser } from './ConstraintViolationError/parser.js'; + +export const dbErrorParser = { + error: DBError, + + parse: (err) => { + if ( + typeof err.code === 'string' && + err.code.startsWith('SQLITE_') && + typeof err.errno === 'number' + ) { + return { + nativeError: err, + client: 'sqlite', + }; + } + + return null; + }, + + subclassParsers: [constraintViolationErrorParser], +}; diff --git a/lib/dbErrors/utils/sqlState.js b/lib/dbErrors/utils/sqlState.js new file mode 100644 index 000000000..9e6a6bcd3 --- /dev/null +++ b/lib/dbErrors/utils/sqlState.js @@ -0,0 +1,3 @@ +export function getSqlStateClass(sqlState) { + return sqlState.substr(0, 2); +} diff --git a/lib/initialize.js b/lib/initialize.js index 38525a9fe..969d660e9 100644 --- a/lib/initialize.js +++ b/lib/initialize.js @@ -1,6 +1,4 @@ -'use strict'; - -async function initialize(knex, modelClasses) { +export async function initialize(knex, modelClasses) { if (!modelClasses) { modelClasses = knex; knex = modelClasses[0].knex(); @@ -8,7 +6,3 @@ async function initialize(knex, modelClasses) { await Promise.all(modelClasses.map((modelClass) => modelClass.fetchTableMetadata({ knex }))); } - -module.exports = { - initialize, -}; diff --git a/lib/model/AjvValidator.js b/lib/model/AjvValidator.js index e02d0ecbf..3840d9808 100644 --- a/lib/model/AjvValidator.js +++ b/lib/model/AjvValidator.js @@ -1,20 +1,18 @@ -'use strict'; - -const Ajv = require('ajv'); -const addFormats = require('ajv-formats'); -const { Validator } = require('./Validator'); -const { ValidationErrorType } = require('../model/ValidationError'); -const { +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import { Validator } from './Validator.js'; +import { ValidationErrorType } from '../model/ValidationError.js'; +import { isObject, hasOwn, once, - cloneDeep: lodashCloneDeep, + cloneDeep as lodashCloneDeep, omit, -} = require('../utils/objectUtils'); -const { parseFieldExpression, isFieldExpressionKey } = require('../utils/parseFieldExpression'); -const { getQueryPropKeys } = require('./modelQueryProps'); +} from '../utils/objectUtils.js'; +import { parseFieldExpression, isFieldExpressionKey } from '../utils/parseFieldExpression.js'; +import { getQueryPropKeys } from './modelQueryProps.js'; -class AjvValidator extends Validator { +export class AjvValidator extends Validator { static init(self, conf) { super.init(self, conf); @@ -85,60 +83,27 @@ class AjvValidator extends Validator { return json; } + const modelClass = model.constructor; + const validator = this.getValidator( + modelClass, + ctx.jsonSchema, + !!options.patch, + options.patch ? null : getMissingQueryPropKeys(model, json, ctx.jsonSchema), + ); + // We need to clone the input json if we are about to set default values. if (!options.mutable && !options.patch && setsDefaultValues(ctx.jsonSchema)) { json = cloneDeep(json); } - // Patch and update objects can contain field expression keys like `'meta:b'` - // that update a single property inside a json column. Validate them against - // the nested schema by converting them into nested objects like - // `{ meta: { b: value } }`. - const queryPropKeys = getQueryPropKeys(model); - const fieldExpressions = nestFieldExpressions(json, queryPropKeys, ctx.jsonSchema); - let validationJson = fieldExpressions ? fieldExpressions.json : json; + // Patch objects can contain field expression keys like `'meta:b'` that update + // a single property inside a json column. Validate them against the nested + // schema by converting them into nested objects like `{ meta: { b: value } }`. + const fieldExpressions = options.patch ? nestFieldExpressions(json, ctx.jsonSchema) : null; + const validationJson = fieldExpressions ? fieldExpressions.json : json; - const modelClass = model.constructor; - const validate = (json, isPatchObject, givenKeys = []) => { - const validator = this.getValidator( - modelClass, - ctx.jsonSchema, - isPatchObject, - isPatchObject ? null : getRequiredKeysGivenOutside(json, ctx.jsonSchema, givenKeys), - ); - validator.call(model, json); - return validator.errors || []; - }; - - let errors; - - if (options.patch) { - errors = validate(validationJson, true); - } else if (fieldExpressions && fieldExpressions.columns.length > 0) { - // Field expressions only write parts of their json columns. Validate these - // parts like patches, and the rest normally, with the columns counted as - // present like the query properties. - const { columns } = fieldExpressions; - const columnsJson = Object.fromEntries( - columns - .filter((column) => hasOwn(validationJson, column)) - .map((column) => [column, validationJson[column]]), - ); - const restJson = omit(validationJson, columns); - - errors = [ - ...validate(columnsJson, true), - ...validate(restJson, false, [...queryPropKeys, ...columns]), - ]; - - // Combine the parts again, without the defaults that Ajv set for the - // columns in `restJson`. - validationJson = Object.assign(restJson, columnsJson); - } else { - errors = validate(validationJson, false, queryPropKeys); - } - - const error = parseValidationError(errors, modelClass, options, this.ajvOptions); + validator.call(model, validationJson); + const error = parseValidationError(validator.errors, modelClass, options, this.ajvOptions); if (error) { throw error; @@ -146,7 +111,7 @@ class AjvValidator extends Validator { if (fieldExpressions) { // Copy back the changes made by Ajv options like `coerceTypes` and `removeAdditional`. - unnestFieldExpressions(json, validationJson, fieldExpressions); + unnestFieldExpressions(json, fieldExpressions); } return json; @@ -187,8 +152,7 @@ class AjvValidator extends Validator { normalValidator: null, // Validators created for the schema object without the `required` - // properties that are given outside the json, as query properties or - // json columns written by field expressions, keyed by their names. + // properties that are given as query properties, keyed by their names. queryPropValidators: null, }; @@ -318,7 +282,7 @@ function parseValidationError(errors, modelClass, options, ajvOptions) { } } - const key = `${options.dataPath || ''}${path}`.substring(1); + const key = `${options.dataPath || ''}${path}`.replace(/^\./, ''); // More than one error can occur for the same key in Ajv, merge them in the array: const array = errorHash[key] || (errorHash[key] = []); @@ -352,11 +316,11 @@ function parseValidationError(errors, modelClass, options, ajvOptions) { }); } -function nestFieldExpressions(json, queryPropKeys, jsonSchema) { +function nestFieldExpressions(json, jsonSchema) { const isFieldExpression = (key) => isFieldExpressionKey(key, jsonSchema.properties); const keys = Object.keys(json); - if (!keys.some(isFieldExpression) && !queryPropKeys.some(isFieldExpression)) { + if (!keys.some(isFieldExpression)) { return null; } @@ -365,15 +329,6 @@ function nestFieldExpressions(json, queryPropKeys, jsonSchema) { // The objects created here for the nested paths. Other objects are values // of the input json and must not be modified. const createdObjects = new Set(); - // The json columns that are only written partially, by field expressions in - // the json or the query properties. - const columns = new Set(); - - const addColumn = (expression) => { - if (expression && !hasOwn(json, expression.column)) { - columns.add(expression.column); - } - }; for (const key of keys) { if (!isFieldExpression(key)) { @@ -384,10 +339,7 @@ function nestFieldExpressions(json, queryPropKeys, jsonSchema) { for (const key of keys) { if (isFieldExpression(key)) { - const expression = parseFieldExpressionKey(key); - // Not a valid field expression, validate it as a top-level property: - const path = expression ? expression.path : [key]; - addColumn(expression); + const path = fieldExpressionPath(key); // Field expressions with array access, and those that conflict with other // keys of the patch, can't be validated without the current value of the @@ -398,16 +350,10 @@ function nestFieldExpressions(json, queryPropKeys, jsonSchema) { } } - for (const key of queryPropKeys) { - if (isFieldExpression(key)) { - addColumn(parseFieldExpressionKey(key)); - } - } - - return { json: nested, paths, columns: [...columns] }; + return { json: nested, paths }; } -function unnestFieldExpressions(json, nested, { paths, columns }) { +function unnestFieldExpressions(json, { json: nested, paths }) { for (const [key, path] of paths) { let value = nested; let found = true; @@ -429,35 +375,25 @@ function unnestFieldExpressions(json, nested, { paths, columns }) { json[key] = value; } } - - // Copy over the default values that Ajv set for missing properties, but not - // the json columns written by the field expressions. - for (const key of Object.keys(nested)) { - if (!paths.has(key) && !columns.includes(key)) { - json[key] = nested[key]; - } - } } -// Parse a field expression key into its json column and nested path, with the -// path set to `null` if the key accesses array elements. -function parseFieldExpressionKey(key) { +function fieldExpressionPath(key) { let parsed; try { parsed = parseFieldExpression(key); } catch (err) { - return null; + // Not a valid field expression, validate it as a top-level property. + return [key]; } - const { column, access } = parsed; // Numeric keys like the `0` in `tags:0` also access array elements when the // json value is an array, so treat them like `[0]`. - const isArrayAccess = access.some(({ type, ref }) => type !== 'object' || /^-?\d+$/.test(ref)); - return { - column, - path: isArrayAccess ? null : [column, ...access.map(({ ref }) => String(ref))], - }; + if (parsed.access.some((access) => access.type !== 'object' || /^-?\d+$/.test(access.ref))) { + return null; + } + + return [parsed.column, ...parsed.access.map((access) => String(access.ref))]; } function setPath(obj, path, value, createdObjects) { @@ -537,20 +473,22 @@ function objectHasDefaults(obj) { return false; } -// Returns the `keys` that are required by the schema but given outside of -// `json`, sorted, or `null` if there are none. Query properties (`raw()`, -// `val()`, `ref()`, subqueries...) are split from the json before validation, -// but they do provide a value for the property, and so do field expressions -// for their json columns. -function getRequiredKeysGivenOutside(json, jsonSchema, keys) { +// Returns the keys of the query properties of `model` (`raw()`, `val()`, +// `ref()`, subqueries...) that are required by the schema but missing from +// `json`, sorted, or `null` if there are none. Query properties are split from +// the json before validation, but they do provide a value for the property. +function getMissingQueryPropKeys(model, json, jsonSchema) { const required = jsonSchema.required; if (!Array.isArray(required) || required.length === 0) { return null; } - const givenKeys = required.filter((key) => keys.includes(key) && !hasOwn(json, key)); - return givenKeys.length > 0 ? givenKeys.sort() : null; + const keys = getQueryPropKeys(model).filter( + (key) => required.includes(key) && !hasOwn(json, key), + ); + + return keys.length > 0 ? keys.sort() : null; } function jsonSchemaWithoutRequiredKeys(jsonSchema, keys) { @@ -647,7 +585,3 @@ function subSchemaWithoutRequired(jsonSchema, prop) { function matchesAnything(schema) { return schema === true || (isObject(schema) && Object.keys(schema).length === 0); } - -module.exports = { - AjvValidator, -}; diff --git a/lib/model/Model.js b/lib/model/Model.js index b559369bd..7bf4177bd 100644 --- a/lib/model/Model.js +++ b/lib/model/Model.js @@ -1,51 +1,54 @@ -'use strict'; - -const { clone } = require('./modelClone'); -const { bindKnex } = require('./modelBindKnex'); -const { validate } = require('./modelValidate'); -const { isMsSql } = require('../utils/knexUtils'); -const { visitModels } = require('./modelVisitor'); -const { hasId, getSetId } = require('./modelId'); -const { map: promiseMap } = require('../utils/promiseUtils'); -const { toJson, toDatabaseJson } = require('./modelToJson'); -const { values, propKey, hasProps } = require('./modelValues'); -const { defineNonEnumerableProperty } = require('./modelUtils'); -const { parseRelationsIntoModelInstances } = require('./modelParseRelations'); -const { removeOverriddenQueryProps } = require('./modelQueryProps'); -const { fetchTableMetadata, tableMetadata } = require('./modelTableMetadata'); -const { asArray, isFunction, isString, asSingle } = require('../utils/objectUtils'); -const { setJson, setFast, setRelated, appendRelated, setDatabaseJson } = require('./modelSet'); -const { +import { clone } from './modelClone.js'; +import { bindKnex } from './modelBindKnex.js'; +import { validate } from './modelValidate.js'; +import { isMsSql } from '../utils/knexUtils.js'; +import { visitModels } from './modelVisitor.js'; +import { hasId, getSetId } from './modelId.js'; +import { map as promiseMap } from '../utils/promiseUtils/index.js'; +import { toJson, toDatabaseJson } from './modelToJson.js'; +import { values, propKey, hasProps } from './modelValues.js'; +import { defineNonEnumerableProperty } from './modelUtils.js'; +import { parseRelationsIntoModelInstances } from './modelParseRelations.js'; +import { removeOverriddenQueryProps } from './modelQueryProps.js'; +import { isFieldExpressionKey } from '../utils/parseFieldExpression.js'; +import { fetchTableMetadata, tableMetadata } from './modelTableMetadata.js'; +import { asArray, isFunction, isString, asSingle } from '../utils/objectUtils.js'; +import { setJson, setFast, setRelated, appendRelated, setDatabaseJson } from './modelSet.js'; +import { getJsonAttributes, parseJsonAttributes, formatJsonAttributes, -} = require('./modelJsonAttributes'); -const { columnNameToPropertyName, propertyNameToColumnName } = require('./modelColPropMap'); - -const { raw } = require('../queryBuilder/RawBuilder'); -const { ref } = require('../queryBuilder/ReferenceBuilder'); -const { fn } = require('../queryBuilder/FunctionBuilder'); - -const { AjvValidator } = require('./AjvValidator'); -const { QueryBuilder } = require('../queryBuilder/QueryBuilder'); -const { NotFoundError } = require('./NotFoundError'); -const { ValidationError } = require('./ValidationError'); -const { ModifierNotFoundError } = require('./ModifierNotFoundError'); -const { RelationProperty } = require('../relations/RelationProperty'); -const { RelationOwner } = require('../relations/RelationOwner'); - -const { HasOneRelation } = require('../relations/hasOne/HasOneRelation'); -const { HasManyRelation } = require('../relations/hasMany/HasManyRelation'); -const { ManyToManyRelation } = require('../relations/manyToMany/ManyToManyRelation'); -const { BelongsToOneRelation } = require('../relations/belongsToOne/BelongsToOneRelation'); -const { HasOneThroughRelation } = require('../relations/hasOneThrough/HasOneThroughRelation'); - -const { InstanceFindOperation } = require('../queryBuilder/operations/InstanceFindOperation'); -const { InstanceInsertOperation } = require('../queryBuilder/operations/InstanceInsertOperation'); -const { InstanceUpdateOperation } = require('../queryBuilder/operations/InstanceUpdateOperation'); -const { InstanceDeleteOperation } = require('../queryBuilder/operations/InstanceDeleteOperation'); - -class Model { +} from './modelJsonAttributes.js'; +import { columnNameToPropertyName, propertyNameToColumnName } from './modelColPropMap.js'; + +import { raw } from '../queryBuilder/RawBuilder.js'; +import { ref } from '../queryBuilder/ReferenceBuilder.js'; +import { fn } from '../queryBuilder/FunctionBuilder.js'; + +import { AjvValidator } from './AjvValidator.js'; +import { QueryBuilder } from '../queryBuilder/QueryBuilder.js'; +import { NotFoundError } from './NotFoundError.js'; +import { ValidationError } from './ValidationError.js'; +import { ModifierNotFoundError } from './ModifierNotFoundError.js'; +import { RelationProperty } from '../relations/RelationProperty.js'; +import { RelationOwner } from '../relations/RelationOwner.js'; +import { RelationWriteResultOperation } from '../relations/RelationWriteResultOperation.js'; + +import { HasOneRelation } from '../relations/hasOne/HasOneRelation.js'; +import { HasManyRelation } from '../relations/hasMany/HasManyRelation.js'; +import { ManyToManyRelation } from '../relations/manyToMany/ManyToManyRelation.js'; +import { BelongsToOneRelation } from '../relations/belongsToOne/BelongsToOneRelation.js'; +import { HasOneThroughRelation } from '../relations/hasOneThrough/HasOneThroughRelation.js'; +import { BelongsToManyRelation } from '../relations/belongsToMany/BelongsToManyRelation.js'; + +import { InstanceFindOperation } from '../queryBuilder/operations/InstanceFindOperation.js'; +import { InstanceInsertOperation } from '../queryBuilder/operations/InstanceInsertOperation.js'; +import { InstanceUpdateOperation } from '../queryBuilder/operations/InstanceUpdateOperation.js'; +import { InstanceDeleteOperation } from '../queryBuilder/operations/InstanceDeleteOperation.js'; +import { transaction } from '../transaction.js'; +import { setModel } from './getModel.js'; + +export class Model { get $modelClass() { return this.constructor; } @@ -95,23 +98,27 @@ class Model { // Do nothing by default. } - $parseDatabaseJson(json) { + // Queries pass their `knex` in `options`, to parse JSON attributes depending on + // the driver. Without it, the source of `json` is unknown. + $parseDatabaseJson(json, options) { const columnNameMappers = this.constructor.getColumnNameMappers(); if (columnNameMappers) { json = columnNameMappers.parse(json); } - return parseJsonAttributes(json, this.constructor); + return parseJsonAttributes(json, this.constructor, options?.knex); } $formatDatabaseJson(json) { - const columnNameMappers = this.constructor.getColumnNameMappers(); + const modelClass = this.constructor; + const columnNameMappers = modelClass.getColumnNameMappers(); - json = formatJsonAttributes(json, this.constructor); + json = formatJsonAttributes(json, modelClass); if (columnNameMappers) { - json = columnNameMappers.format(json); + const properties = modelClass.getJsonSchema()?.properties; + json = columnNameMappers.format(json, (key) => isFieldExpressionKey(key, properties)); } return json; @@ -129,8 +136,8 @@ class Model { return setJson(this, json, options); } - $setDatabaseJson(json) { - return setDatabaseJson(this, json); + $setDatabaseJson(json, options) { + return setDatabaseJson(this, json, options); } $set(obj) { @@ -266,9 +273,12 @@ class Model { return model; } - static fromDatabaseJson(json) { + static fromDatabaseJson(json, options) { const model = new this(); - model.$setDatabaseJson(json || {}); + model.$setDatabaseJson(json || {}, options); + // Mark models loaded from the database, as their missing properties weren't + // selected, e.g. the join properties of relations (#1832). + defineNonEnumerableProperty(model, '$$isFromDatabase', true); return model; } @@ -531,7 +541,6 @@ class Model { } static startTransaction(knexOrTrx) { - const { transaction } = require('../transaction'); return transaction.start(knexOrTrx || this.knex()); } @@ -702,11 +711,14 @@ Object.defineProperties(Model.prototype, { Model.QueryBuilder = QueryBuilder; +setModel(Model); + Model.HasOneRelation = HasOneRelation; Model.HasManyRelation = HasManyRelation; Model.ManyToManyRelation = ManyToManyRelation; Model.BelongsToOneRelation = BelongsToOneRelation; Model.HasOneThroughRelation = HasOneThroughRelation; +Model.BelongsToManyRelation = BelongsToManyRelation; Model.JoinEagerAlgorithm = 'JoinEagerAlgorithm'; Model.NaiveEagerAlgorithm = 'NaiveEagerAlgorithm'; @@ -735,6 +747,7 @@ Model.defaultGraphOptions = null; Model.defaultFindOptions = Object.freeze({}); Model.modifiers = null; Model.useLimitInFirst = false; +Model.jsonColumnFormat = 'auto'; Model.columnNameMappers = null; Model.relatedFindQueryMutates = false; Model.relatedInsertQueryMutates = false; @@ -774,6 +787,13 @@ function instanceQuery({ instance, transaction }) { function relatedQuery({ modelClass, relationName, transaction, alwaysReturnArray } = {}) { const relation = modelClass.getRelation(relationName); const relatedModelClass = relation.relatedModelClass; + // Writes to a to-one relation of one owner resolve to one model with + // `returning()`, like its finds: + const addWriteResult = (builder) => { + if (!alwaysReturnArray && relation.isOneToOne()) { + builder.addOperation(new RelationWriteResultOperation('relationWriteResult'), []); + } + }; return relatedModelClass .query(transaction) @@ -789,6 +809,12 @@ function relatedQuery({ modelClass, relationName, transaction, alwaysReturnArray operation.alwaysReturnArray = alwaysReturnArray; operation.alias = isSubQuery ? relation.name : null; + if (operation.assignResultToOwner) { + // Assign what the query returns last, after all other `runAfter()` + // callbacks of the query. + builder.runAfter((result, builder) => operation.assignResult(builder, result)); + } + return operation; }) .insertOperationFactory((builder) => { @@ -800,14 +826,17 @@ function relatedQuery({ modelClass, relationName, transaction, alwaysReturnArray }) .updateOperationFactory((builder) => { const owner = RelationOwner.create(builder.for()); + addWriteResult(builder); return relation.update(builder, owner); }) .patchOperationFactory((builder) => { const owner = RelationOwner.create(builder.for()); + addWriteResult(builder); return relation.patch(builder, owner); }) .deleteOperationFactory((builder) => { const owner = RelationOwner.create(builder.for()); + addWriteResult(builder); return relation.delete(builder, owner); }) .relateOperationFactory((builder) => { @@ -923,7 +952,3 @@ function asPropsArray(props) { } return props; } - -module.exports = { - Model, -}; diff --git a/lib/model/ModifierNotFoundError.js b/lib/model/ModifierNotFoundError.js index f2f4fef70..ef3576d8e 100644 --- a/lib/model/ModifierNotFoundError.js +++ b/lib/model/ModifierNotFoundError.js @@ -1,12 +1,6 @@ -'use strict'; - -class ModifierNotFoundError extends Error { +export class ModifierNotFoundError extends Error { constructor(modifierName) { super(`Unable to determine modify function from provided value: "${modifierName}".`); this.modifierName = modifierName; } } - -module.exports = { - ModifierNotFoundError, -}; diff --git a/lib/model/NotFoundError.js b/lib/model/NotFoundError.js index 8b2735b69..0c9216b9a 100644 --- a/lib/model/NotFoundError.js +++ b/lib/model/NotFoundError.js @@ -1,6 +1,4 @@ -'use strict'; - -class NotFoundError extends Error { +export class NotFoundError extends Error { constructor({ modelClass, data = {}, statusCode = 404, ...rest } = {}) { super(rest.message || 'NotFoundError'); @@ -17,7 +15,3 @@ class NotFoundError extends Error { }); } } - -module.exports = { - NotFoundError, -}; diff --git a/lib/model/RelationDoesNotExistError.js b/lib/model/RelationDoesNotExistError.js index 61b3aa481..842d15aca 100644 --- a/lib/model/RelationDoesNotExistError.js +++ b/lib/model/RelationDoesNotExistError.js @@ -1,6 +1,4 @@ -'use strict'; - -class RelationDoesNotExistError extends Error { +export class RelationDoesNotExistError extends Error { constructor(relationName) { super(`unknown relation "${relationName}" in a relation expression`); @@ -8,7 +6,3 @@ class RelationDoesNotExistError extends Error { this.relationName = relationName; } } - -module.exports = { - RelationDoesNotExistError, -}; diff --git a/lib/model/ValidationError.js b/lib/model/ValidationError.js index 69e9b0dc7..a9cf4a51e 100644 --- a/lib/model/ValidationError.js +++ b/lib/model/ValidationError.js @@ -1,15 +1,13 @@ -'use strict'; +import { asArray, isString } from '../utils/objectUtils.js'; -const { asArray, isString } = require('../utils/objectUtils'); - -const ValidationErrorType = { +export const ValidationErrorType = { ModelValidation: 'ModelValidation', RelationExpression: 'RelationExpression', UnallowedRelation: 'UnallowedRelation', InvalidGraph: 'InvalidGraph', }; -class ValidationError extends Error { +export class ValidationError extends Error { static get Type() { return ValidationErrorType; } @@ -47,8 +45,3 @@ function message(it) { return it.message; } } - -module.exports = { - ValidationError, - ValidationErrorType, -}; diff --git a/lib/model/Validator.js b/lib/model/Validator.js index c46827ba7..6e711ff6f 100644 --- a/lib/model/Validator.js +++ b/lib/model/Validator.js @@ -1,6 +1,4 @@ -'use strict'; - -class Validator { +export class Validator { constructor(...args) { this.constructor.init(this, ...args); } @@ -20,7 +18,3 @@ class Validator { model.$afterValidate(json, options); } } - -module.exports = { - Validator, -}; diff --git a/lib/model/getModel.js b/lib/model/getModel.js index e0a7f116c..4066a67b8 100644 --- a/lib/model/getModel.js +++ b/lib/model/getModel.js @@ -1,9 +1,10 @@ -'use strict'; - -// A small helper method for cached lazy-importing of the Model class. +// A small helper for lazy access to the Model class, for modules that the Model +// module imports itself. Importing Model.js there would create a circular import, +// so Model.js registers itself here instead. let Model; -const getModel = () => Model || (Model = require('./Model').Model); -module.exports = { - getModel, +export const getModel = () => Model; + +export const setModel = (model) => { + Model = model; }; diff --git a/lib/model/graph/ModelGraph.js b/lib/model/graph/ModelGraph.js deleted file mode 100644 index 60b4805a4..000000000 --- a/lib/model/graph/ModelGraph.js +++ /dev/null @@ -1,80 +0,0 @@ -'use strict'; - -const { ModelGraphBuilder } = require('./ModelGraphBuilder'); -const NOT_CALCULATED = {}; - -class ModelGraph { - constructor(nodes, edges) { - this.nodes = nodes; - this.edges = edges; - - // These are calculated lazily. - this._nodesByObjects = NOT_CALCULATED; - this._nodesByIdPathKeys = NOT_CALCULATED; - } - - static create(rootModelClass, roots) { - const builder = ModelGraphBuilder.buildGraph(rootModelClass, roots); - return new ModelGraph(builder.nodes, builder.edges); - } - - static createEmpty() { - return new ModelGraph([], []); - } - - get rootObjects() { - return this.nodes.filter((node) => !node.parentEdge).map((node) => node.obj); - } - - nodeForObject(obj) { - if (!obj) { - return null; - } - - if (this._nodesByObjects === NOT_CALCULATED) { - this._nodesByObjects = createNodesByObjectsMap(this.nodes); - } - - return this._nodesByObjects.get(obj) || null; - } - - nodeForNode(node) { - if (!node) { - return null; - } - - if (this._nodesByIdPathKeys === NOT_CALCULATED) { - this._nodesByIdPathKeys = createNodesByIdPathKeysMap(this.nodes); - } - - return this._nodesByIdPathKeys.get(node.idPathKey) || null; - } -} - -function createNodesByObjectsMap(nodes) { - const nodesByObjects = new Map(); - - for (const node of nodes) { - nodesByObjects.set(node.obj, node); - } - - return nodesByObjects; -} - -function createNodesByIdPathKeysMap(nodes) { - const nodesByIdPathKeys = new Map(); - - for (const node of nodes) { - const idPathKey = node.idPathKey; - - if (idPathKey !== null) { - nodesByIdPathKeys.set(idPathKey, node); - } - } - - return nodesByIdPathKeys; -} - -module.exports = { - ModelGraph, -}; diff --git a/lib/model/graph/ModelGraphBuilder.js b/lib/model/graph/ModelGraphBuilder.js deleted file mode 100644 index 63b6b011b..000000000 --- a/lib/model/graph/ModelGraphBuilder.js +++ /dev/null @@ -1,241 +0,0 @@ -'use strict'; - -const { isObject, isString, asArray, asSingle } = require('../../utils/objectUtils'); -const { ValidationErrorType } = require('../../model/ValidationError'); -const { ModelGraphNode } = require('./ModelGraphNode'); -const { ModelGraphEdge } = require('./ModelGraphEdge'); - -class ModelGraphBuilder { - constructor() { - this.nodes = []; - this.edges = []; - } - - static buildGraph(rootModelClass, roots) { - const builder = new this(); - builder._buildGraph(rootModelClass, roots); - - return builder; - } - - _buildGraph(rootModelClass, roots) { - if (roots) { - if (Array.isArray(roots)) { - this._buildNodes(rootModelClass, roots); - } else if (isObject(roots)) { - this._buildNode(rootModelClass, roots); - } else { - throw createNotModelError(rootModelClass, roots); - } - } - - this._buildReferences(); - } - - _buildNodes(modelClass, objs, parentNode = null, relation = null) { - objs = asArray(objs); - - objs.forEach((obj, index) => { - this._buildNode(modelClass, obj, parentNode, relation, index); - }); - } - - _buildNode(modelClass, obj, parentNode = null, relation = null, index = null) { - obj = asSingle(obj); - - if (!isObject(obj) || !obj.$isObjectionModel) { - throw createNotModelError(modelClass, obj); - } - - const node = new ModelGraphNode(modelClass, obj); - this.nodes.push(node); - - if (parentNode) { - const edge = new ModelGraphEdge( - ModelGraphEdge.Type.Relation, - parentNode, - node, - relation, - index, - ); - - node.parentEdge = edge; - this._addEdge(parentNode, node, edge); - } - - this._buildRelationNodes(node); - } - - _buildRelationNodes(node) { - for (const relationName of node.modelClass.getRelationNames()) { - const relatedObjects = node.obj[relationName]; - - if (!relatedObjects) { - continue; - } - - const relation = node.modelClass.getRelation(relationName); - - if (relation.isOneToOne()) { - this._buildNode(relation.relatedModelClass, relatedObjects, node, relation); - } else { - this._buildNodes(relation.relatedModelClass, relatedObjects, node, relation); - } - } - } - - _buildReferences() { - const nodesByUid = this._nodesByUid(); - - this._buildObjectReferences(nodesByUid); - this._buildPropertyReferences(nodesByUid); - } - - _nodesByUid() { - const nodesByUid = new Map(); - - for (const node of this.nodes) { - const uid = node.uid; - - if (uid === undefined) { - continue; - } - - nodesByUid.set(uid, node); - } - - return nodesByUid; - } - - _buildObjectReferences(nodesByUid) { - for (const node of this.nodes) { - const ref = node.reference; - - if (ref === undefined) { - continue; - } - - const refNode = nodesByUid.get(ref); - - if (!refNode) { - throw createReferenceNotFoundError(ref); - } - - const edge = new ModelGraphEdge(ModelGraphEdge.Type.Reference, node, refNode); - edge.refType = ModelGraphEdge.ReferenceType.Object; - - this._addEdge(node, refNode, edge); - } - } - - _buildPropertyReferences(nodesByUid) { - for (const node of this.nodes) { - forEachPropertyReference(node.obj, ({ path, refMatch, ref, refPath }) => { - const refNode = nodesByUid.get(ref); - - if (!refNode) { - throw createReferenceNotFoundError(ref); - } - - const edge = new ModelGraphEdge(ModelGraphEdge.Type.Reference, node, refNode); - - edge.refType = ModelGraphEdge.ReferenceType.Property; - edge.refMatch = refMatch; - edge.refOwnerDataPath = path.slice(); - edge.refRelatedDataPath = refPath; - - this._addEdge(node, refNode, edge); - }); - } - } - - _addEdge(ownerNode, relatedNode, edge) { - this.edges.push(edge); - - ownerNode.edges.push(edge); - relatedNode.edges.push(edge); - - if (edge.type === ModelGraphEdge.Type.Reference) { - ownerNode.refEdges.push(edge); - relatedNode.refEdges.push(edge); - } - } -} - -function forEachPropertyReference(obj, callback) { - const modelClass = obj.constructor; - const relationNames = modelClass.getRelationNames(); - - for (const prop of Object.keys(obj)) { - if (relationNames.includes(prop)) { - continue; - } - - visitStrings(obj[prop], [prop], (str, path) => { - forEachMatch(modelClass.propRefRegex, str, (match) => { - const [refMatch, ref, refPath] = match; - callback({ path, refMatch, ref, refPath: refPath.trim().split('.') }); - }); - }); - } -} - -function visitStrings(value, path, visit) { - if (Array.isArray(value)) { - visitStringsInArray(value, path, visit); - } else if (isObject(value) && !Buffer.isBuffer(value)) { - visitStringsInObject(value, path, visit); - } else if (isString(value)) { - visit(value, path); - } -} - -function visitStringsInArray(value, path, visit) { - for (let i = 0; i < value.length; ++i) { - path.push(i); - visitStrings(value[i], path, visit); - path.pop(); - } -} - -function visitStringsInObject(value, path, visit) { - for (const prop of Object.keys(value)) { - path.push(prop); - visitStrings(value[prop], path, visit); - path.pop(); - } -} - -function forEachMatch(regex, str, cb) { - let matchResult = regex.exec(str); - - while (matchResult) { - cb(matchResult); - matchResult = regex.exec(str); - } -} - -function createReferenceNotFoundError(ref) { - return new Error( - [ - `could not resolve reference ${ref} in a graph.`, - `If you are sure the #id exist in the same graph,`, - `this may be due to a limitation that a subgraph under a related item`, - `cannot reference an item in a subgraph under another related item.`, - `If you run into this limitation, please open an issue in objection github.`, - ].join(' '), - ); -} - -function createNotModelError(modelClass, value) { - throw modelClass.createValidationError({ - type: ValidationErrorType.InvalidGraph, - message: `expected value "${value}" to be an instance of ${modelClass.name}`, - }); -} - -module.exports = { - ModelGraphBuilder, - createNotModelError, - forEachPropertyReference, -}; diff --git a/lib/model/graph/ModelGraphEdge.js b/lib/model/graph/ModelGraphEdge.js deleted file mode 100644 index 036bd1c91..000000000 --- a/lib/model/graph/ModelGraphEdge.js +++ /dev/null @@ -1,52 +0,0 @@ -'use strict'; - -const Type = { - Relation: 'Relation', - Reference: 'Reference', -}; - -const ReferenceType = { - Object: 'Object', - Property: 'Property', -}; - -class ModelGraphEdge { - constructor(type, ownerNode, relatedNode, relation = null, relationIndex = null) { - this.type = type; - - this.ownerNode = ownerNode; - this.relatedNode = relatedNode; - this.relation = relation; - this.relationIndex = relationIndex; - - this.refType = null; - this.refMatch = null; - - this.refOwnerDataPath = null; - this.refRelatedDataPath = null; - } - - static get Type() { - return Type; - } - - static get ReferenceType() { - return ReferenceType; - } - - getOtherNode(node) { - return this.isOwnerNode(node) ? this.relatedNode : this.ownerNode; - } - - isOwnerNode(node) { - return node === this.ownerNode; - } - - isRelatedNode(node) { - return node === this.relatedNode; - } -} - -module.exports = { - ModelGraphEdge, -}; diff --git a/lib/model/graph/ModelGraphNode.js b/lib/model/graph/ModelGraphNode.js deleted file mode 100644 index b29d934cb..000000000 --- a/lib/model/graph/ModelGraphNode.js +++ /dev/null @@ -1,253 +0,0 @@ -'use strict'; - -const { ModelGraphEdge } = require('./ModelGraphEdge'); -const { isNumber } = require('../../utils/objectUtils'); - -const NOT_CALCULATED = {}; - -class ModelGraphNode { - constructor(modelClass, obj) { - this.modelClass = modelClass; - this.obj = obj; - this.edges = []; - this.userData = {}; - this.hasId = obj.$hasId(); - this.uid = obj[modelClass.uidProp]; - - // These are also included in `edges`. These are simply - // shortcuts for commonly used edges. - this.refEdges = []; - this.parentEdge = null; - - // These are calculated lazily. - this._relationPath = NOT_CALCULATED; - this._relationPathKey = NOT_CALCULATED; - - this._dataPath = NOT_CALCULATED; - this._dataPathKey = NOT_CALCULATED; - - this._idPath = NOT_CALCULATED; - this._idPathKey = NOT_CALCULATED; - } - - get isReference() { - return this.reference !== undefined; - } - - get isDbReference() { - return this.dbReference !== undefined; - } - - get reference() { - return this.obj[this.modelClass.uidRefProp]; - } - - get dbReference() { - return this.obj[this.modelClass.dbRefProp]; - } - - get toBeUnrelated() { - return this.obj[this.modelClass.graphUnrelateProp] === true; - } - - get toBeDeleted() { - return this.obj[this.modelClass.graphDeleteProp] === true; - } - - get toBeUnrelatedOrDeleted() { - return this.toBeUnrelated || this.toBeDeleted; - } - - get parentNode() { - if (this.parentEdge) { - return this.parentEdge.ownerNode; - } else { - return null; - } - } - - get indexInRelation() { - if (this.parentEdge) { - return this.parentEdge.relationIndex; - } else { - return null; - } - } - - get relationName() { - if (this.parentEdge) { - return this.parentEdge.relation.name; - } else { - return null; - } - } - - get relationPath() { - if (this._relationPath === NOT_CALCULATED) { - this._relationPath = this._createRelationPath(); - } - - return this._relationPath; - } - - get relationPathKey() { - if (this._relationPathKey === NOT_CALCULATED) { - this._relationPathKey = this._createRelationPathKey(); - } - - return this._relationPathKey; - } - - get dataPath() { - if (this._dataPath === NOT_CALCULATED) { - this._dataPath = this._createDataPath(); - } - - return this._dataPath; - } - - get dataPathKey() { - if (this._dataPathKey === NOT_CALCULATED) { - this._dataPathKey = this._createDataPathKey(); - } - - return this._dataPathKey; - } - - get idPath() { - if (this._idPath === NOT_CALCULATED) { - this._idPath = this._createIdPath(); - } - - return this._idPath; - } - - get idPathKey() { - if (this._idPathKey === NOT_CALCULATED) { - this._idPathKey = this._createIdPathKey(); - } - - return this._idPathKey; - } - - /** - * If this node is a reference, returns the referred node. - */ - get referencedNode() { - for (const edge of this.refEdges) { - if (edge.refType === ModelGraphEdge.ReferenceType.Object && edge.isOwnerNode(this)) { - return edge.relatedNode; - } - } - - return null; - } - - /** - * Returns all nodes that are references to this node. - */ - get referencingNodes() { - const nodes = []; - - for (const edge of this.refEdges) { - if (edge.refType === ModelGraphEdge.ReferenceType.Object && edge.isRelatedNode(this)) { - nodes.push(edge.ownerNode); - } - } - - return nodes; - } - - get descendantRelationNodes() { - return this._collectDescendantRelationNodes([]); - } - - removeEdge(edge) { - // Don't allow removing parent edges for now. It would - // cause all kinds of cache invalidation. - if (edge === this.parentEdge) { - throw new Error('cannot remove parent edge'); - } - - this.edges = this.edges.filter((it) => it !== edge); - this.refEdges = this.refEdges.filter((it) => it !== edge); - } - - _collectDescendantRelationNodes(nodes) { - for (const edge of this.edges) { - if (edge.type === ModelGraphEdge.Type.Relation && edge.isOwnerNode(this)) { - nodes.push(edge.relatedNode); - edge.relatedNode._collectDescendantRelationNodes(nodes); - } - } - - return nodes; - } - - _createRelationPath() { - if (this.parentNode === null) { - return []; - } else { - return [...this.parentNode.relationPath, this.relationName]; - } - } - - _createRelationPathKey() { - return this.relationPath.join('.'); - } - - _createDataPath() { - if (this.parentEdge === null) { - return []; - } else if (this.parentEdge.relation.isOneToOne()) { - return [...this.parentNode.dataPath, this.relationName]; - } else { - return [...this.parentNode.dataPath, this.relationName, this.indexInRelation]; - } - } - - _createDataPathKey() { - const dataPathKey = this.dataPath.reduce((key, it) => { - if (isNumber(it)) { - return `${key}[${it}]`; - } else { - return key ? `${key}.${it}` : it; - } - }, ''); - - return dataPathKey ? '.' + dataPathKey : dataPathKey; - } - - _createIdPath() { - if (!this.obj.$hasId()) { - return null; - } - - if (this.parentEdge === null) { - return [this.obj.$idKey()]; - } else { - const path = this.parentNode.idPath; - - if (path === null) { - return null; - } - - return [...path, this.relationName, this.obj.$idKey()]; - } - } - - _createIdPathKey() { - const idPath = this.idPath; - - if (idPath) { - // JSON keeps the ids and relation names apart, whatever they contain. - return JSON.stringify(idPath); - } else { - return null; - } - } -} - -module.exports = { - ModelGraphNode, -}; diff --git a/lib/model/inheritModel.js b/lib/model/inheritModel.js index f9bb2ce17..00de6bd95 100644 --- a/lib/model/inheritModel.js +++ b/lib/model/inheritModel.js @@ -1,8 +1,6 @@ -'use strict'; - const cache = new Map(); -function inheritModel(modelClass) { +export function inheritModel(modelClass) { let inherit = cache.get(modelClass.name); if (!inherit) { @@ -22,7 +20,3 @@ function createClassInheritor(className) { `, ); } - -module.exports = { - inheritModel, -}; diff --git a/lib/model/modelBindKnex.js b/lib/model/modelBindKnex.js index 6e10e886a..458ab30a3 100644 --- a/lib/model/modelBindKnex.js +++ b/lib/model/modelBindKnex.js @@ -1,10 +1,8 @@ -'use strict'; +import { inheritModel } from './inheritModel.js'; +import { staticHiddenProps } from './modelUtils.js'; +import { defineNonEnumerableProperty } from './modelUtils.js'; -const { inheritModel } = require('./inheritModel'); -const { staticHiddenProps } = require('./modelUtils'); -const { defineNonEnumerableProperty } = require('./modelUtils'); - -function bindKnex(modelClass, knex) { +export function bindKnex(modelClass, knex) { let BoundModelClass = getBoundModelFromCache(modelClass, knex); if (BoundModelClass === null) { @@ -77,7 +75,3 @@ function bindRelations(modelClass, BoundModelClass, knex) { return BoundModelClass; } - -module.exports = { - bindKnex, -}; diff --git a/lib/model/modelClone.js b/lib/model/modelClone.js index 90b0fa621..a1900a34b 100644 --- a/lib/model/modelClone.js +++ b/lib/model/modelClone.js @@ -1,11 +1,9 @@ -'use strict'; +import { isObject, cloneDeep } from '../utils/objectUtils.js'; +import { hiddenProps } from './modelUtils.js'; +import { defineNonEnumerableProperty } from './modelUtils.js'; +import { isInternalProp } from '../utils/internalPropUtils.js'; -const { isObject, cloneDeep } = require('../utils/objectUtils'); -const { hiddenProps } = require('./modelUtils'); -const { defineNonEnumerableProperty } = require('./modelUtils'); -const { isInternalProp } = require('../utils/internalPropUtils'); - -function clone(model, shallow, stripInternal) { +export function clone(model, shallow, stripInternal) { let clone = null; const omitFromJson = model.$omitFromJson(); @@ -113,7 +111,3 @@ function copyHiddenProps(model, clone) { return clone; } - -module.exports = { - clone, -}; diff --git a/lib/model/modelColPropMap.js b/lib/model/modelColPropMap.js index 389b46e73..332e3709c 100644 --- a/lib/model/modelColPropMap.js +++ b/lib/model/modelColPropMap.js @@ -1,8 +1,6 @@ -'use strict'; +import { difference } from '../utils/objectUtils.js'; -const { difference } = require('../utils/objectUtils'); - -function columnNameToPropertyName(modelClass, columnName) { +export function columnNameToPropertyName(modelClass, columnName) { const model = new modelClass(); const addedProps = Object.keys(model.$parseDatabaseJson({})); @@ -15,7 +13,7 @@ function columnNameToPropertyName(modelClass, columnName) { return propertyName || columnName; } -function propertyNameToColumnName(modelClass, propertyName) { +export function propertyNameToColumnName(modelClass, propertyName) { const model = new modelClass(); const addedCols = Object.keys(model.$formatDatabaseJson({})); @@ -27,8 +25,3 @@ function propertyNameToColumnName(modelClass, propertyName) { return columnName || propertyName; } - -module.exports = { - columnNameToPropertyName, - propertyNameToColumnName, -}; diff --git a/lib/model/modelId.js b/lib/model/modelId.js index ba235f281..4137c2557 100644 --- a/lib/model/modelId.js +++ b/lib/model/modelId.js @@ -1,8 +1,6 @@ -'use strict'; +import { assertHasIdColumn } from '../utils/assert.js'; -const { assertHasIdColumn } = require('../utils/assert'); - -function getSetId(model, maybeId) { +export function getSetId(model, maybeId) { if (maybeId !== undefined) { return setId(model, maybeId); } else { @@ -10,7 +8,7 @@ function getSetId(model, maybeId) { } } -function hasId(model) { +export function hasId(model) { const idProps = model.constructor.getIdPropertyArray(); // Models without an `idColumn` never have an id. return idProps.length > 0 && model.$hasProps(idProps); @@ -63,8 +61,3 @@ function getId(model) { return model[idProp]; } } - -module.exports = { - getSetId, - hasId, -}; diff --git a/lib/model/modelJsonAttributes.js b/lib/model/modelJsonAttributes.js index c1dbb9f23..bd8e86591 100644 --- a/lib/model/modelJsonAttributes.js +++ b/lib/model/modelJsonAttributes.js @@ -1,12 +1,12 @@ -'use strict'; +import { asArray, isObject, flatten, isString } from '../utils/objectUtils.js'; +import { isQueryProp } from './modelQueryProps.js'; +import { isJsonParsedByDriver } from '../utils/knexUtils.js'; -const { asArray, isObject, flatten, isString } = require('../utils/objectUtils'); -const { isQueryProp } = require('./modelQueryProps'); - -function parseJsonAttributes(json, modelClass) { +export function parseJsonAttributes(json, modelClass, knex) { const jsonAttr = modelClass.getJsonAttributes(); + const format = getJsonColumnFormat(modelClass, knex); - if (jsonAttr.length) { + if (jsonAttr.length && format !== 'parsed') { // JSON attributes may be returned as strings depending on the database and // the database client. Convert them to objects here. for (let i = 0, l = jsonAttr.length; i < l; ++i) { @@ -14,7 +14,7 @@ function parseJsonAttributes(json, modelClass) { const value = json[attr]; if (isString(value)) { - const parsed = tryParseJson(value); + const parsed = format === 'text' ? parseJson(value, attr, modelClass) : tryParseJson(value); // tryParseJson returns undefined if parsing failed. if (parsed !== undefined) { @@ -27,7 +27,7 @@ function parseJsonAttributes(json, modelClass) { return json; } -function formatJsonAttributes(json, modelClass) { +export function formatJsonAttributes(json, modelClass) { const jsonAttr = modelClass.getJsonAttributes(); if (jsonAttr.length) { @@ -46,7 +46,7 @@ function formatJsonAttributes(json, modelClass) { return json; } -function getJsonAttributes(modelClass) { +export function getJsonAttributes(modelClass) { let jsonAttributes = modelClass.jsonAttributes; if (Array.isArray(jsonAttributes)) { @@ -85,6 +85,32 @@ function getPropertyTypes(prop) { return types; } +// Resolve 'auto' to 'parsed' for drivers that return JSON columns parsed, or +// else to `null`: JSON attributes may be text then, and are parsed leniently. +function getJsonColumnFormat(modelClass, knex) { + const { jsonColumnFormat } = modelClass; + + if (jsonColumnFormat === 'auto') { + return knex && isJsonParsedByDriver(knex) ? 'parsed' : null; + } else if (jsonColumnFormat === 'text' || jsonColumnFormat === 'parsed') { + return jsonColumnFormat; + } else { + throw new Error( + `Invalid jsonColumnFormat of model '${modelClass.name}': '${jsonColumnFormat}'.`, + ); + } +} + +function parseJson(str, attr, modelClass) { + try { + return JSON.parse(str); + } catch (err) { + throw new Error( + `Invalid JSON in JSON attribute '${attr}' of model '${modelClass.name}': ${err.message}`, + ); + } +} + function tryParseJson(maybeJsonStr) { try { return JSON.parse(maybeJsonStr); @@ -92,10 +118,3 @@ function tryParseJson(maybeJsonStr) { return undefined; } } - -module.exports = { - parseJsonAttributes, - formatJsonAttributes, - getJsonAttributes, - getPropertyTypes, -}; diff --git a/lib/model/modelParseRelations.js b/lib/model/modelParseRelations.js index 5a89171e0..b28cae824 100644 --- a/lib/model/modelParseRelations.js +++ b/lib/model/modelParseRelations.js @@ -1,8 +1,6 @@ -'use strict'; +import { isObject } from '../utils/objectUtils.js'; -const { isObject } = require('../utils/objectUtils'); - -function parseRelationsIntoModelInstances(model, json, options = {}) { +export function parseRelationsIntoModelInstances(model, json, options = {}) { if (!options.cache) { options = Object.assign({}, options, { cache: new Map(), @@ -76,7 +74,3 @@ function parseRelationObject(json, relation, options) { return json; } } - -module.exports = { - parseRelationsIntoModelInstances, -}; diff --git a/lib/model/modelQueryProps.js b/lib/model/modelQueryProps.js index 68b1cd2a8..cce0c921c 100644 --- a/lib/model/modelQueryProps.js +++ b/lib/model/modelQueryProps.js @@ -1,9 +1,7 @@ -'use strict'; - -const { isObject, isFunction, omit } = require('../utils/objectUtils'); -const { defineNonEnumerableProperty } = require('./modelUtils'); -const { isKnexRaw, isKnexQueryBuilder } = require('../utils/knexUtils'); -const { getFieldExpressionColumn } = require('../utils/parseFieldExpression'); +import { isObject, isFunction, omit } from '../utils/objectUtils.js'; +import { defineNonEnumerableProperty } from './modelUtils.js'; +import { isKnexRaw, isKnexQueryBuilder } from '../utils/knexUtils.js'; +import { getFieldExpressionColumn } from '../utils/parseFieldExpression.js'; const QUERY_PROPS_PROPERTY = '$$queryProps'; // The keys of the field expression query properties like `'data:a'` that were @@ -14,7 +12,7 @@ const COMBINED_QUERY_PROPS_PROPERTY = '$$combinedQueryProps'; // that they can be later merged back to its database json. A key holds either // a value or a query property, so the keys given in `json` replace the ones // that the model holds as the other kind. -function splitQueryProps(model, json) { +export function splitQueryProps(model, json) { const keys = Object.keys(json); const oldQueryProps = model[QUERY_PROPS_PROPERTY]; @@ -61,7 +59,7 @@ function splitQueryProps(model, json) { // Omits the values that parsing and validation add to `json` for the keys that // `model` holds query properties for, like Ajv defaults. -function omitQueryProps(model, json) { +export function omitQueryProps(model, json) { const keys = getQueryPropKeys(model); return keys.length > 0 ? omit(json, keys) : json; } @@ -70,7 +68,7 @@ function omitQueryProps(model, json) { // assignment, `$set()` or a write that fetched the value from the database. We // do this when the model is passed to a write, and not when its database json // is built, so that the values set by its hooks don't override them. -function removeOverriddenQueryProps(model) { +export function removeOverriddenQueryProps(model) { const queryProps = model[QUERY_PROPS_PROPERTY]; if (queryProps) { @@ -96,7 +94,7 @@ function removeOverriddenQueryProps(model) { // Removes the values that the hooks of a write set for the keys that `model` // holds query properties for. The query properties are written instead, so the // values must not count as given in later writes. -function removeOverriddenValues(model) { +export function removeOverriddenValues(model) { for (const key of getQueryPropKeys(model)) { delete model[key]; } @@ -112,7 +110,7 @@ function hasQueryProps(json, keys) { return false; } -function isQueryProp(value) { +export function isQueryProp(value) { if (!isObject(value)) { return false; } @@ -126,7 +124,7 @@ function isQueryProp(value) { } // Merges and converts `model`'s query properties into `json`. -function mergeQueryProps(model, json, omitProps, builder) { +export function mergeQueryProps(model, json, omitProps, builder) { json = convertExistingQueryProps(json, builder); json = convertAndMergeHiddenQueryProps(model, json, omitProps, builder); @@ -177,7 +175,7 @@ function convertAndMergeHiddenQueryProps(model, json, omitProps, builder) { } // Returns the keys of the query properties that were split from `model`. -function getQueryPropKeys(model) { +export function getQueryPropKeys(model) { const queryProps = model[QUERY_PROPS_PROPERTY]; return queryProps ? Object.keys(queryProps) : []; } @@ -214,13 +212,3 @@ function buildKnexRawConvertable(convertable, builder) { function isKnexRawConvertable(queryProp) { return isFunction(queryProp.toKnexRaw); } - -module.exports = { - splitQueryProps, - omitQueryProps, - removeOverriddenQueryProps, - removeOverriddenValues, - mergeQueryProps, - getQueryPropKeys, - isQueryProp, -}; diff --git a/lib/model/modelSet.js b/lib/model/modelSet.js index 70ca685ce..40d38556e 100644 --- a/lib/model/modelSet.js +++ b/lib/model/modelSet.js @@ -1,11 +1,9 @@ -'use strict'; +import { isInternalProp } from '../utils/internalPropUtils.js'; +import { splitQueryProps, omitQueryProps } from './modelQueryProps.js'; +import { asArray, isFunction, isString, isPlainObject } from '../utils/objectUtils.js'; +import { parseRelationsIntoModelInstances } from './modelParseRelations.js'; -const { isInternalProp } = require('../utils/internalPropUtils'); -const { splitQueryProps, omitQueryProps } = require('./modelQueryProps'); -const { isFunction, isString, isPlainObject } = require('../utils/objectUtils'); -const { parseRelationsIntoModelInstances } = require('./modelParseRelations'); - -function setJson(model, json, options) { +export function setJson(model, json, options) { json = json || {}; options = options || {}; @@ -34,8 +32,8 @@ function setJson(model, json, options) { return model; } -function setDatabaseJson(model, json) { - json = model.$parseDatabaseJson(json); +export function setDatabaseJson(model, json, options) { + json = model.$parseDatabaseJson(json, options); if (json) { const keys = Object.keys(json); @@ -49,7 +47,7 @@ function setDatabaseJson(model, json) { return model; } -function setFast(model, obj) { +export function setFast(model, obj) { if (obj) { // Don't try to set read-only properties. They can easily get here // through `fromJson` when parsing an object that was previously @@ -75,16 +73,39 @@ function setFast(model, obj) { return model; } -function setRelated(model, relation, models) { +export function setRelated(model, relation, models) { + relation = ensureRelation(model, relation); + const previous = model[relation.name]; + assignRelated(model, relation, models); + syncForeignKeys(model, relation, model[relation.name], { previous }); + return model; +} + +export function appendRelated(model, relation, models) { relation = ensureRelation(model, relation); if (relation.isOneToOne()) { + return model.$setRelated(relation, models); + } else { + assignRelated(model, relation, models, { append: true }); + syncForeignKeys(model, relation, models, { append: true }); + } + + return model; +} + +// Assigns related models without syncing foreign keys, for the relation queries +// that set the keys themselves: +export function assignRelated(model, relation, models, { append = false } = {}) { + if (append && model[relation.name] && !relation.isOneToOne()) { if (Array.isArray(models)) { - if (models.length === 0) { - model[relation.name] = null; - } else { - model[relation.name] = models[0] || null; - } + models.forEach((it) => model[relation.name].push(it)); + } else if (models) { + model[relation.name].push(models); + } + } else if (relation.isOneToOne()) { + if (Array.isArray(models)) { + model[relation.name] = models[0] || null; } else { model[relation.name] = models || null; } @@ -97,24 +118,6 @@ function setRelated(model, relation, models) { model[relation.name] = [models]; } } - - return model; -} - -function appendRelated(model, relation, models) { - relation = ensureRelation(model, relation); - - if (!model[relation.name] || relation.isOneToOne()) { - return model.$setRelated(relation, models); - } else { - if (Array.isArray(models)) { - models.forEach((it) => model[relation.name].push(it)); - } else if (models) { - model[relation.name].push(models); - } - } - - return model; } function ensureRelation(model, relation) { @@ -125,10 +128,55 @@ function ensureRelation(model, relation) { } } -module.exports = { - setFast, - setJson, - setDatabaseJson, - setRelated, - appendRelated, -}; +// Let the foreign keys follow the relation: a `BelongsToOneRelation` points the +// owner to the related model, a `BelongsToManyRelation` to the related models, +// `HasOneRelation` and `HasManyRelation` point the related models to the owner, +// and the models that leave them to nothing. The keys of `ManyToManyRelation` +// and `HasOneThroughRelation` live in the join table. +function syncForeignKeys(model, relation, related, { previous = null, append = false } = {}) { + const { ownerProp, relatedProp } = relation; + + if (relation.isObjectionBelongsToOneRelation) { + copyKey(relatedProp, related, ownerProp, model); + } else if (relation.isObjectionBelongsToManyRelation) { + // Models without a key are left out of the array: + const keys = asModels(related).map((it) => relatedProp.getProp(it, 0)); + + ownerProp.setProp( + model, + 0, + relation.changeKeys(append ? relation.getKeys(model) : [], { add: keys }), + ); + } else if (relation.isObjectionHasManyRelation) { + const models = asModels(related); + + for (const it of models) { + copyKey(ownerProp, model, relatedProp, it); + } + + if (ownerProp.hasProps(model)) { + const key = ownerProp.propKey(model); + + for (const it of asModels(previous)) { + // Only clear the keys that still point to the owner, not the keys of + // models that moved to another owner in the meantime: + if (!models.includes(it) && relatedProp.propKey(it) === key) { + copyKey(ownerProp, null, relatedProp, it); + } + } + } + } +} + +// Copies the key of `source` to `target`, or clears it if there's none to copy: +function copyKey(sourceProp, source, targetProp, target) { + const hasKey = !!source && sourceProp.hasProps(source); + + targetProp.forEach((i) => { + targetProp.setProp(target, i, hasKey ? sourceProp.getProp(source, i) : null); + }); +} + +function asModels(value) { + return asArray(value).filter(Boolean); +} diff --git a/lib/model/modelTableMetadata.js b/lib/model/modelTableMetadata.js index cad14f418..ec70959db 100644 --- a/lib/model/modelTableMetadata.js +++ b/lib/model/modelTableMetadata.js @@ -1,11 +1,9 @@ -'use strict'; - -const { defineNonEnumerableProperty } = require('./modelUtils'); -const { isPromise } = require('../utils/promiseUtils'); +import { defineNonEnumerableProperty } from './modelUtils.js'; +import { isPromise } from '../utils/promiseUtils/index.js'; const TABLE_METADATA = '$$tableMetadata'; -function fetchTableMetadata( +export function fetchTableMetadata( modelClass, { parentBuilder = null, knex = null, force = false, table = null } = {}, ) { @@ -63,7 +61,7 @@ function fetchTableMetadata( } } -function tableMetadata(modelClass, { table } = {}) { +export function tableMetadata(modelClass, { table } = {}) { if (modelClass.hasOwnProperty(TABLE_METADATA)) { const metadata = modelClass[TABLE_METADATA].get(table || modelClass.getTableName()); @@ -76,8 +74,3 @@ function tableMetadata(modelClass, { table } = {}) { return null; } } - -module.exports = { - fetchTableMetadata, - tableMetadata, -}; diff --git a/lib/model/modelToJson.js b/lib/model/modelToJson.js index 7df72939a..aef49cceb 100644 --- a/lib/model/modelToJson.js +++ b/lib/model/modelToJson.js @@ -1,12 +1,10 @@ -'use strict'; - -const { isInternalProp } = require('../utils/internalPropUtils'); -const { mergeQueryProps } = require('./modelQueryProps'); -const { isObject, hasOwn, cloneDeep, isFunction } = require('../utils/objectUtils'); -const { getFieldExpressionColumn } = require('../utils/parseFieldExpression'); +import { isInternalProp } from '../utils/internalPropUtils.js'; +import { mergeQueryProps } from './modelQueryProps.js'; +import { isObject, hasOwn, cloneDeep, isFunction } from '../utils/objectUtils.js'; +import { getFieldExpressionColumn } from '../utils/parseFieldExpression.js'; const EMPTY_ARRAY = []; -function toJson(model, optIn) { +export function toJson(model, optIn) { const modelClass = model.constructor; const opt = { @@ -25,7 +23,7 @@ function toJson(model, optIn) { return json; } -function toDatabaseJson(model, builder) { +export function toDatabaseJson(model, builder) { const modelClass = model.constructor; const opt = { @@ -188,8 +186,3 @@ function toJsonArray(value, opt) { return ret; } - -module.exports = { - toJson, - toDatabaseJson, -}; diff --git a/lib/model/modelUtils.js b/lib/model/modelUtils.js index 40f5842d3..0d8be9798 100644 --- a/lib/model/modelUtils.js +++ b/lib/model/modelUtils.js @@ -1,8 +1,6 @@ -'use strict'; +export const hiddenProps = ['$$queryProps', '$$combinedQueryProps', '$$isFromDatabase']; -const hiddenProps = ['$$queryProps', '$$combinedQueryProps']; - -const staticHiddenProps = [ +export const staticHiddenProps = [ '$$knex', '$$validator', '$$jsonSchema', @@ -19,7 +17,7 @@ const staticHiddenProps = [ '$$virtualAttributes', ]; -function defineNonEnumerableProperty(obj, prop, value) { +export function defineNonEnumerableProperty(obj, prop, value) { Object.defineProperty(obj, prop, { enumerable: false, writable: true, @@ -28,7 +26,17 @@ function defineNonEnumerableProperty(obj, prop, value) { }); } -function keyByProps(models, props) { +// Give `model` back the props that it left out of its database JSON, as +// returned by `$omitFromDatabaseJson()` before more were left out. +export function restoreOmitFromDatabaseJson(model, props) { + if (props) { + defineNonEnumerableProperty(model, '$$omitFromDatabaseJson', props); + } else { + delete model.$$omitFromDatabaseJson; + } +} + +export function keyByProps(models, props) { const map = new Map(); for (let i = 0, l = models.length; i < l; ++i) { @@ -38,10 +46,3 @@ function keyByProps(models, props) { return map; } - -module.exports = { - hiddenProps, - staticHiddenProps, - defineNonEnumerableProperty, - keyByProps, -}; diff --git a/lib/model/modelValidate.js b/lib/model/modelValidate.js index bf9888c08..6481facdd 100644 --- a/lib/model/modelValidate.js +++ b/lib/model/modelValidate.js @@ -1,9 +1,7 @@ -'use strict'; +import { clone } from './modelClone.js'; +import { omitQueryProps } from './modelQueryProps.js'; -const { clone } = require('./modelClone'); -const { omitQueryProps } = require('./modelQueryProps'); - -function validate(model, json, options = {}) { +export function validate(model, json, options = {}) { json = json || model; const inputJson = json; @@ -43,7 +41,3 @@ function validate(model, json, options = {}) { return json; } } - -module.exports = { - validate, -}; diff --git a/lib/model/modelValues.js b/lib/model/modelValues.js index 02567bd68..31060d666 100644 --- a/lib/model/modelValues.js +++ b/lib/model/modelValues.js @@ -1,12 +1,10 @@ -'use strict'; - -const { isObject } = require('../utils/objectUtils'); +import { isObject } from '../utils/objectUtils.js'; // Property keys needs to be prefixed with a non-numeric character so that // they are not considered indexes when used as object keys. const PROP_KEY_PREFIX = 'k_'; -function values(model, args) { +export function values(model, args) { switch (args.length) { case 1: return values1(model, args); @@ -20,7 +18,7 @@ function values(model, args) { } // Turn the props of a model into a key to look up models by. -function propKey(model, props) { +export function propKey(model, props) { if (props.length === 1) { return PROP_KEY_PREFIX + propToStr(model[props[0]]); } else { @@ -35,7 +33,7 @@ function propKey(model, props) { } // Same as `propKey()`, for values that were already collected. -function valuesKey(values) { +export function valuesKey(values) { if (values.length === 1) { return PROP_KEY_PREFIX + propToStr(values[0]); } else { @@ -49,7 +47,7 @@ function valuesKey(values) { } } -function hasProps(model, props) { +export function hasProps(model, props) { for (let i = 0; i < props.length; ++i) { const value = model[props[i]]; @@ -110,10 +108,3 @@ function compositePropToStr(value) { function isNullOrUndefined(val) { return val === null || val === undefined; } - -module.exports = { - values, - hasProps, - propKey, - valuesKey, -}; diff --git a/lib/model/modelVisitor.js b/lib/model/modelVisitor.js index 15bfdc101..a69363ce9 100644 --- a/lib/model/modelVisitor.js +++ b/lib/model/modelVisitor.js @@ -1,6 +1,4 @@ -'use strict'; - -function visitModels(models, modelClass, visitor) { +export function visitModels(models, modelClass, visitor) { doVisit(models, modelClass, null, null, visitor); } @@ -35,7 +33,3 @@ function visitOne(model, modelClass, parent, rel, visitor) { } } } - -module.exports = { - visitModels, -}; diff --git a/lib/objection.js b/lib/objection.js index 5ed2fa8fa..c6140dfa9 100644 --- a/lib/objection.js +++ b/lib/objection.js @@ -1,6 +1,10 @@ -'use strict'; +import { Model as NativeModel } from './model/Model.js'; +import { QueryBuilder as NativeQueryBuilder } from './queryBuilder/QueryBuilder.js'; +import { AjvValidator as NativeAjvValidator } from './model/AjvValidator.js'; +import { Validator as NativeValidator } from './model/Validator.js'; +import { inherit } from './utils/classUtils.js'; -const { +export { DBError, UniqueViolationError, NotNullViolationError, @@ -8,55 +12,51 @@ const { ConstraintViolationError, CheckViolationError, DataError, -} = require('db-errors'); -const { Model: NativeModel } = require('./model/Model'); -const { QueryBuilder: NativeQueryBuilder } = require('./queryBuilder/QueryBuilder'); -const { QueryBuilderBase } = require('./queryBuilder/QueryBuilderBase'); -const { QueryBuilderOperation } = require('./queryBuilder/operations/QueryBuilderOperation'); -const { RelationExpression } = require('./queryBuilder/RelationExpression'); -const { ValidationError } = require('./model/ValidationError'); -const { NotFoundError } = require('./model/NotFoundError'); -const { AjvValidator: NativeAjvValidator } = require('./model/AjvValidator'); -const { Validator: NativeValidator } = require('./model/Validator'); -const { Relation } = require('./relations/Relation'); -const { HasOneRelation } = require('./relations/hasOne/HasOneRelation'); -const { HasManyRelation } = require('./relations/hasMany/HasManyRelation'); -const { BelongsToOneRelation } = require('./relations/belongsToOne/BelongsToOneRelation'); -const { HasOneThroughRelation } = require('./relations/hasOneThrough/HasOneThroughRelation'); -const { ManyToManyRelation } = require('./relations/manyToMany/ManyToManyRelation'); -const { transaction } = require('./transaction'); -const { initialize } = require('./initialize'); - -const { +} from './dbErrors/index.js'; +export { QueryBuilderBase } from './queryBuilder/QueryBuilderBase.js'; +export { QueryBuilderOperation } from './queryBuilder/operations/QueryBuilderOperation.js'; +export { RelationExpression } from './queryBuilder/RelationExpression.js'; +export { ValidationError } from './model/ValidationError.js'; +export { NotFoundError } from './model/NotFoundError.js'; +export { Relation } from './relations/Relation.js'; +export { HasOneRelation } from './relations/hasOne/HasOneRelation.js'; +export { HasManyRelation } from './relations/hasMany/HasManyRelation.js'; +export { BelongsToOneRelation } from './relations/belongsToOne/BelongsToOneRelation.js'; +export { HasOneThroughRelation } from './relations/hasOneThrough/HasOneThroughRelation.js'; +export { ManyToManyRelation } from './relations/manyToMany/ManyToManyRelation.js'; +export { BelongsToManyRelation } from './relations/belongsToMany/BelongsToManyRelation.js'; +export { transaction } from './transaction.js'; +export { initialize } from './initialize.js'; +export { snakeCaseMappers, knexSnakeCaseMappers, knexIdentifierMapping, -} = require('./utils/identifierMapping'); -const { compose, mixin } = require('./utils/mixin'); -const { ref } = require('./queryBuilder/ReferenceBuilder'); -const { val } = require('./queryBuilder/ValueBuilder'); -const { raw } = require('./queryBuilder/RawBuilder'); -const { fn } = require('./queryBuilder/FunctionBuilder'); -const { inherit } = require('../lib/utils/classUtils'); +} from './utils/identifierMapping.js'; +export { compose, mixin } from './utils/mixin.js'; +export { asArray } from './utils/objectUtils.js'; +export { ref } from './queryBuilder/ReferenceBuilder.js'; +export { val } from './queryBuilder/ValueBuilder.js'; +export { raw } from './queryBuilder/RawBuilder.js'; +export { fn } from './queryBuilder/FunctionBuilder.js'; // We need to wrap the classes, that people can inherit, with ES5 classes // so that babel is able to use ES5 inheritance. sigh... Maybe people // should stop transpiling node apps to ES5 in the year 2019? Node 6 // with full class support was released three years ago. -function Model() { +export function Model() { // Nothing to do here. } -function QueryBuilder(...args) { +export function QueryBuilder(...args) { NativeQueryBuilder.init(this, ...args); } -function Validator(...args) { +export function Validator(...args) { NativeValidator.init(this, ...args); } -function AjvValidator(...args) { +export function AjvValidator(...args) { NativeAjvValidator.init(this, ...args); } @@ -66,41 +66,3 @@ inherit(Validator, NativeValidator); inherit(AjvValidator, NativeAjvValidator); Model.QueryBuilder = QueryBuilder; - -module.exports = { - Model, - QueryBuilder, - QueryBuilderBase, - QueryBuilderOperation, - RelationExpression, - ValidationError, - NotFoundError, - AjvValidator, - Validator, - Relation, - HasOneRelation, - HasManyRelation, - BelongsToOneRelation, - HasOneThroughRelation, - ManyToManyRelation, - - transaction, - initialize, - compose, - mixin, - ref, - val, - raw, - fn, - snakeCaseMappers, - knexSnakeCaseMappers, - knexIdentifierMapping, - - DBError, - UniqueViolationError, - NotNullViolationError, - ForeignKeyViolationError, - ConstraintViolationError, - CheckViolationError, - DataError, -}; diff --git a/lib/queryBuilder/FunctionBuilder.js b/lib/queryBuilder/FunctionBuilder.js index a281856db..daec91fd5 100644 --- a/lib/queryBuilder/FunctionBuilder.js +++ b/lib/queryBuilder/FunctionBuilder.js @@ -1,11 +1,9 @@ -'use strict'; +import { RawBuilder, normalizeRawArgs } from './RawBuilder.js'; +import { asSingle, isNumber } from '../utils/objectUtils.js'; -const { RawBuilder, normalizeRawArgs } = require('./RawBuilder'); -const { asSingle, isNumber } = require('../utils/objectUtils'); +export class FunctionBuilder extends RawBuilder {} -class FunctionBuilder extends RawBuilder {} - -function fn(...argsIn) { +export function fn(...argsIn) { const { sql, args } = normalizeRawArgs(argsIn); return new FunctionBuilder(`${sql}(${args.map(() => '?').join(', ')})`, args); } @@ -27,8 +25,3 @@ fn.now = (precision) => { // injection here. return new FunctionBuilder(`CURRENT_TIMESTAMP(${precision})`, []); }; - -module.exports = { - FunctionBuilder, - fn, -}; diff --git a/lib/queryBuilder/InternalOptions.js b/lib/queryBuilder/InternalOptions.js index 2204a4058..395f7d559 100644 --- a/lib/queryBuilder/InternalOptions.js +++ b/lib/queryBuilder/InternalOptions.js @@ -1,12 +1,11 @@ -'use strict'; - -class InternalOptions { +export class InternalOptions { constructor() { - this.skipUndefined = false; this.keepImplicitJoinProps = false; this.returnImmediatelyValue = undefined; this.isInternalQuery = false; this.skipRelationFilter = false; + // The join table extras that related updates write, all of them if null: + this.joinTableExtras = null; this.debug = false; this.schema = undefined; } @@ -14,18 +13,14 @@ class InternalOptions { clone() { const copy = new this.constructor(); - copy.skipUndefined = this.skipUndefined; copy.keepImplicitJoinProps = this.keepImplicitJoinProps; copy.returnImmediatelyValue = this.returnImmediatelyValue; copy.isInternalQuery = this.isInternalQuery; copy.skipRelationFilter = this.skipRelationFilter; + copy.joinTableExtras = this.joinTableExtras; copy.debug = this.debug; copy.schema = this.schema; return copy; } } - -module.exports = { - InternalOptions, -}; diff --git a/lib/queryBuilder/JoinBuilder.js b/lib/queryBuilder/JoinBuilder.js index 1410a977a..a53bcd698 100644 --- a/lib/queryBuilder/JoinBuilder.js +++ b/lib/queryBuilder/JoinBuilder.js @@ -1,9 +1,8 @@ -'use strict'; +import { QueryBuilderOperationSupport } from './QueryBuilderOperationSupport.js'; +import { KnexOperation } from './operations/KnexOperation.js'; +import { setJoinBuilder } from './getJoinBuilder.js'; -const { QueryBuilderOperationSupport } = require('./QueryBuilderOperationSupport'); -const { KnexOperation } = require('./operations/KnexOperation'); - -class JoinBuilder extends QueryBuilderOperationSupport { +export class JoinBuilder extends QueryBuilderOperationSupport { using(...args) { return this.addOperation(new KnexOperation('using'), args); } @@ -137,6 +136,4 @@ class JoinBuilder extends QueryBuilderOperationSupport { } } -module.exports = { - JoinBuilder, -}; +setJoinBuilder(JoinBuilder); diff --git a/lib/queryBuilder/QueryBuilder.js b/lib/queryBuilder/QueryBuilder.js index 2e7f65d12..0ea913f26 100644 --- a/lib/queryBuilder/QueryBuilder.js +++ b/lib/queryBuilder/QueryBuilder.js @@ -1,58 +1,63 @@ -'use strict'; - -const { wrapDbError } = require('../utils/wrapDbError'); - -const { raw } = require('./RawBuilder'); -const { createModifier } = require('../utils/createModifier'); -const { ValidationErrorType } = require('../model/ValidationError'); -const { isObject, isString, isFunction, last, flatten } = require('../utils/objectUtils'); -const { RelationExpression, DuplicateRelationError } = require('./RelationExpression'); -const { assertIdNotUndefined, assertHasIdColumn } = require('../utils/assert'); -const { warnOnce } = require('../utils/warnings'); -const { isMsSql } = require('../utils/knexUtils'); - -const { Selection, isSameIdentifier } = require('./operations/select/Selection'); - -const { QueryBuilderContext } = require('./QueryBuilderContext'); -const { QueryBuilderBase } = require('./QueryBuilderBase'); - -const { FindOperation } = require('./operations/FindOperation'); -const { DeleteOperation } = require('./operations/DeleteOperation'); -const { UpdateOperation } = require('./operations/UpdateOperation'); -const { InsertOperation } = require('./operations/InsertOperation'); -const { RelateOperation } = require('./operations/RelateOperation'); -const { UnrelateOperation } = require('./operations/UnrelateOperation'); - -const { JoinEagerOperation } = require('./operations/eager/JoinEagerOperation'); -const { NaiveEagerOperation } = require('./operations/eager/NaiveEagerOperation'); -const { WhereInEagerOperation } = require('./operations/eager/WhereInEagerOperation'); - -const { InsertGraphAndFetchOperation } = require('./operations/InsertGraphAndFetchOperation'); -const { UpsertGraphAndFetchOperation } = require('./operations/UpsertGraphAndFetchOperation'); -const { InsertAndFetchOperation } = require('./operations/InsertAndFetchOperation'); -const { UpdateAndFetchOperation } = require('./operations/UpdateAndFetchOperation'); -const { JoinRelatedOperation } = require('./operations/JoinRelatedOperation'); -const { OnBuildKnexOperation } = require('./operations/OnBuildKnexOperation'); -const { InsertGraphOperation } = require('./operations/InsertGraphOperation'); -const { UpsertGraphOperation } = require('./operations/UpsertGraphOperation'); -const { RunBeforeOperation } = require('./operations/RunBeforeOperation'); -const { RunAfterOperation } = require('./operations/RunAfterOperation'); -const { FindByIdOperation } = require('./operations/FindByIdOperation'); -const { FindByIdsOperation } = require('./operations/FindByIdsOperation'); -const { OnBuildOperation } = require('./operations/OnBuildOperation'); -const { OnErrorOperation } = require('./operations/OnErrorOperation'); -const { SelectOperation } = require('./operations/select/SelectOperation'); -const { EagerOperation } = require('./operations/eager/EagerOperation'); -const { RangeOperation } = require('./operations/RangeOperation'); -const { FirstOperation } = require('./operations/FirstOperation'); -const { NoneOperation } = require('./operations/NoneOperation'); -const { LimitPerParentOperation } = require('./operations/LimitPerParentOperation'); -const { FromOperation } = require('./operations/FromOperation'); -const { KnexOperation } = require('./operations/KnexOperation'); -const { clearUnsupportedOrder } = require('./withoutOrder'); -const { addWhereGroup } = require('./whereGroups'); - -class QueryBuilder extends QueryBuilderBase { +import { wrapError } from '../dbErrors/index.js'; + +import { raw } from './RawBuilder.js'; +import { createModifier } from '../utils/createModifier.js'; +import { ValidationErrorType } from '../model/ValidationError.js'; +import { isObject, isString, isFunction, last, flatten } from '../utils/objectUtils.js'; +import { + RelationExpression, + DuplicateRelationError, + MisplacedTokenError, +} from './RelationExpression.js'; +import { assertIdNotUndefined, assertHasIdColumn } from '../utils/assert.js'; +import { warnOnce } from '../utils/warnings.js'; +import { isMsSql } from '../utils/knexUtils.js'; + +import { Selection, isSameIdentifier } from './operations/select/Selection.js'; + +import { QueryBuilderContext } from './QueryBuilderContext.js'; +import { QueryBuilderBase } from './QueryBuilderBase.js'; +import { StaticHookArguments, isGraphQuery } from './StaticHookArguments.js'; + +import { FindOperation } from './operations/FindOperation.js'; +import { DeleteOperation } from './operations/DeleteOperation.js'; +import { UpdateOperation } from './operations/UpdateOperation.js'; +import { InsertOperation } from './operations/InsertOperation.js'; +import { RelateOperation } from './operations/RelateOperation.js'; +import { UnrelateOperation } from './operations/UnrelateOperation.js'; + +import { JoinEagerOperation } from './operations/eager/JoinEagerOperation.js'; +import { NaiveEagerOperation } from './operations/eager/NaiveEagerOperation.js'; +import { WhereInEagerOperation } from './operations/eager/WhereInEagerOperation.js'; + +import { InsertGraphAndFetchOperation } from './operations/InsertGraphAndFetchOperation.js'; +import { UpsertGraphAndFetchOperation } from './operations/UpsertGraphAndFetchOperation.js'; +import { InsertAndFetchOperation } from './operations/InsertAndFetchOperation.js'; +import { UpdateAndFetchOperation } from './operations/UpdateAndFetchOperation.js'; +import { JoinRelatedOperation } from './operations/JoinRelatedOperation.js'; +import { OnBuildKnexOperation } from './operations/OnBuildKnexOperation.js'; +import { InsertGraphOperation } from './operations/InsertGraphOperation.js'; +import { UpsertGraphOperation } from './operations/UpsertGraphOperation.js'; +import { RunBeforeOperation } from './operations/RunBeforeOperation.js'; +import { RunAfterOperation } from './operations/RunAfterOperation.js'; +import { FindByIdOperation } from './operations/FindByIdOperation.js'; +import { FindByIdsOperation } from './operations/FindByIdsOperation.js'; +import { OnBuildOperation } from './operations/OnBuildOperation.js'; +import { OnErrorOperation } from './operations/OnErrorOperation.js'; +import { SelectOperation } from './operations/select/SelectOperation.js'; +import { EagerOperation } from './operations/eager/EagerOperation.js'; +import { RangeOperation } from './operations/RangeOperation.js'; +import { FirstOperation } from './operations/FirstOperation.js'; +import { NoneOperation } from './operations/NoneOperation.js'; +import { LimitPerParentOperation } from './operations/LimitPerParentOperation.js'; +import { FromOperation } from './operations/FromOperation.js'; +import { KnexOperation } from './operations/KnexOperation.js'; +import { clearUnsupportedOrder } from './withoutOrder.js'; +// Loaded for its side effect: it registers itself for `getJoinBuilder()`, which +// the operations use to convert join callbacks. +import './JoinBuilder.js'; + +export class QueryBuilder extends QueryBuilderBase { static init(self, modelClass) { super.init(self, modelClass); @@ -64,6 +69,7 @@ class QueryBuilder extends QueryBuilderBase { self._allowedGraphExpression = null; self._graphModifiersAtPath = []; + self._joinModifiersAtPath = []; // The eager algorithm that was most recently used explicitly, see `withGraph()`. self._graphAlgorithm = null; self._isJoinChildQuery = false; @@ -332,6 +338,15 @@ class QueryBuilder extends QueryBuilderBase { return this; } + joinModifiersAtPath() { + return this._joinModifiersAtPath; + } + + modifyJoin(path, modifier) { + this._joinModifiersAtPath.push({ path: RelationExpression.create(path), modifier }); + return this; + } + findOptions(opt) { if (arguments.length !== 0) { this._findOperationOptions = Object.assign({}, this._findOperationOptions, opt); @@ -434,6 +449,7 @@ class QueryBuilder extends QueryBuilderBase { builder._allowedGraphExpression = this._allowedGraphExpression; builder._graphModifiersAtPath = this._graphModifiersAtPath.slice(); + builder._joinModifiersAtPath = this._joinModifiersAtPath.slice(); builder._graphAlgorithm = this._graphAlgorithm; builder._isJoinChildQuery = this._isJoinChildQuery; builder._findOperationOptions = this._findOperationOptions; @@ -1023,35 +1039,21 @@ class QueryBuilder extends QueryBuilderBase { } updateById(id, modelOrObject) { - // Keep the id condition in its own where group, so that a user's `orWhere()` - // can't get around it. See `whereGroups.js`. - return addWhereGroup(this, () => this.findById(id)) + return this.findById(id) .update(modelOrObject) - .runBefore((_, builder) => { - if (!builder.internalOptions().skipUndefined) { - assertIdNotUndefined(id, `undefined was passed to updateById`); - } - }); + .runBefore(() => assertIdNotUndefined(id, `undefined was passed to updateById`)); } patchById(id, modelOrObject) { - return addWhereGroup(this, () => this.findById(id)) + return this.findById(id) .patch(modelOrObject) - .runBefore((_, builder) => { - if (!builder.internalOptions().skipUndefined) { - assertIdNotUndefined(id, `undefined was passed to patchById`); - } - }); + .runBefore(() => assertIdNotUndefined(id, `undefined was passed to patchById`)); } deleteById(id) { - return addWhereGroup(this, () => this.findById(id)) + return this.findById(id) .delete() - .runBefore((_, builder) => { - if (!builder.internalOptions().skipUndefined) { - assertIdNotUndefined(id, `undefined was passed to deleteById`); - } - }); + .runBefore(() => assertIdNotUndefined(id, `undefined was passed to deleteById`)); } findById(...args) { @@ -1318,17 +1320,13 @@ function parseRelationExpression(modelClass, exp) { try { return RelationExpression.create(exp); } catch (err) { - if (err instanceof DuplicateRelationError) { - throw modelClass.createValidationError({ - type: ValidationErrorType.RelationExpression, - message: `Duplicate relation name "${err.relationName}" in relation expression "${exp}". For example, use "${err.relationName}.[foo, bar]" instead of "[${err.relationName}.foo, ${err.relationName}.bar]".`, - }); - } else { - throw modelClass.createValidationError({ - type: ValidationErrorType.RelationExpression, - message: `Invalid relation expression "${exp}"`, - }); - } + throw modelClass.createValidationError({ + type: ValidationErrorType.RelationExpression, + message: + err instanceof DuplicateRelationError || err instanceof MisplacedTokenError + ? `Invalid relation expression "${exp}": ${err.message}` + : `Invalid relation expression "${exp}"`, + }); } } @@ -1465,15 +1463,51 @@ function afterExecute(builder, result) { promise = chainOperationHooks(promise, builder, 'onAfter1'); promise = chainOperationHooks(promise, builder, 'onAfter2'); promise = promise.then((result) => omitInternalSelections(builder, result)); + promise = chainOperationHooks(promise, builder, 'onAfter3'); + promise = promise.then((result) => callStaticAfterHook(builder, result)); promise = chainHooks(promise, builder, builder.context().runAfter); promise = chainHooks(promise, builder, builder.internalContext().runAfter); - - promise = chainOperationHooks(promise, builder, 'onAfter3'); + promise = chainHooks(promise, builder, getRunAfterFuncs(builder)); return promise; } +// Call the static after hook of the model class with the value that the query +// returns, once its shape is final. +async function callStaticAfterHook(builder, result) { + const modelClass = builder.modelClass(); + let afterHook = null; + + if (isGraphQuery(builder)) { + // Leave the hooks to the queries that the graph runs. + } else if (builder.isInsert()) { + afterHook = modelClass.afterInsert; + } else if (builder.isUpdate()) { + afterHook = modelClass.afterUpdate; + } else if (builder.isDelete()) { + afterHook = modelClass.afterDelete; + } else if (builder.isFind() && !builder.findOptions().dontCallFindHooks) { + afterHook = modelClass.afterFind; + } + + if (afterHook) { + const args = StaticHookArguments.create({ builder, result }); + const maybeResult = await afterHook.call(modelClass, args); + return maybeResult === undefined ? result : maybeResult; + } else { + return result; + } +} + +// The callbacks of `runAfter()`, and the ones that `range()`, `throwIfNotFound()` +// and `traverse()` add, in the order they were added to the query: +function getRunAfterFuncs(builder) { + const funcs = []; + builder.forEachOperation(RunAfterOperation, (op) => funcs.push(op.func)); + return funcs; +} + function omitInternalSelections(builder, result) { const props = builder._internalSelections; @@ -1508,7 +1542,7 @@ function handleExecuteError(builder, err) { return Promise.resolve(err.value); } - let promise = Promise.reject(wrapDbError(err)); + let promise = Promise.reject(wrapError(err)); builder.forEachOperation(true, (op) => { if (op.hasOnError()) { @@ -1714,15 +1748,16 @@ function createModels(result, builder) { } const modelClass = builder.resultModelClass(); + const options = { knex: builder.knex() }; if (Array.isArray(result)) { if (result.length && shouldBeConvertedToModel(result[0], modelClass)) { for (let i = 0, l = result.length; i < l; ++i) { - result[i] = modelClass.fromDatabaseJson(result[i]); + result[i] = modelClass.fromDatabaseJson(result[i], options); } } } else if (shouldBeConvertedToModel(result, modelClass)) { - result = modelClass.fromDatabaseJson(result); + result = modelClass.fromDatabaseJson(result, options); } return result; @@ -1781,7 +1816,3 @@ function unrelateOperationFactory() { function deleteOperationFactory() { return new DeleteOperation('delete'); } - -module.exports = { - QueryBuilder, -}; diff --git a/lib/queryBuilder/QueryBuilderBase.js b/lib/queryBuilder/QueryBuilderBase.js index c4759e4f7..7d02969b4 100644 --- a/lib/queryBuilder/QueryBuilderBase.js +++ b/lib/queryBuilder/QueryBuilderBase.js @@ -1,32 +1,20 @@ -'use strict'; - -const { QueryBuilderOperationSupport } = require('./QueryBuilderOperationSupport'); -const { isSqlite, isMsSql } = require('../utils/knexUtils'); - -const { KnexOperation } = require('./operations/KnexOperation'); -const { MergeOperation } = require('./operations/MergeOperation'); -const { SelectOperation } = require('./operations/select/SelectOperation'); -const { ReturningOperation } = require('./operations/ReturningOperation'); -const { WhereCompositeOperation } = require('./operations/WhereCompositeOperation'); -const { WhereJsonPostgresOperation } = require('./operations/jsonApi/WhereJsonPostgresOperation'); - -const { - WhereInCompositeOperation, -} = require('./operations/whereInComposite/WhereInCompositeOperation'); -const { - WhereInCompositeSqliteOperation, -} = require('./operations/whereInComposite/WhereInCompositeSqliteOperation'); -const { - WhereInCompositeMsSqlOperation, -} = require('./operations/whereInComposite/WhereInCompositeMsSqlOperation'); -const { - WhereJsonHasPostgresOperation, -} = require('./operations/jsonApi/WhereJsonHasPostgresOperation'); -const { - WhereJsonNotObjectPostgresOperation, -} = require('./operations/jsonApi/WhereJsonNotObjectPostgresOperation'); - -class QueryBuilderBase extends QueryBuilderOperationSupport { +import { QueryBuilderOperationSupport } from './QueryBuilderOperationSupport.js'; +import { isSqlite, isMsSql } from '../utils/knexUtils.js'; + +import { KnexOperation } from './operations/KnexOperation.js'; +import { MergeOperation } from './operations/MergeOperation.js'; +import { SelectOperation } from './operations/select/SelectOperation.js'; +import { ReturningOperation } from './operations/ReturningOperation.js'; +import { WhereCompositeOperation } from './operations/WhereCompositeOperation.js'; +import { WhereJsonOperation } from './operations/jsonApi/WhereJsonOperation.js'; + +import { WhereInCompositeOperation } from './operations/whereInComposite/WhereInCompositeOperation.js'; +import { WhereInCompositeSqliteOperation } from './operations/whereInComposite/WhereInCompositeSqliteOperation.js'; +import { WhereInCompositeMsSqlOperation } from './operations/whereInComposite/WhereInCompositeMsSqlOperation.js'; +import { WhereJsonHasOperation } from './operations/jsonApi/WhereJsonHasOperation.js'; +import { WhereJsonTypeOperation } from './operations/jsonApi/WhereJsonTypeOperation.js'; + +export class QueryBuilderBase extends QueryBuilderOperationSupport { modify(...args) { const func = args[0]; @@ -613,19 +601,8 @@ class QueryBuilderBase extends QueryBuilderOperationSupport { return this.addOperation(new KnexOperation('andWhereJsonPath'), args); } - // whereJson(Not)SupersetOf / whereJson(Not)SubsetOf are now supported by knex >= 1.0, but for now - // objection handles them differently and only for postgres. - // Changing them to utilize knex methods directly may require a major version bump and upgrade guide. - // On MySQL, the simple `(column, object | array)` form is passed on to knex (ditojs#113). whereJsonSupersetOf(...args) { - return this.addOperation( - new WhereJsonPostgresOperation('whereJsonSupersetOf', { - operator: '@>', - bool: 'and', - knexMethod: 'whereJsonSupersetOf', - }), - args, - ); + return this.addOperation(new WhereJsonOperation('whereJsonSupersetOf', { bool: 'and' }), args); } andWhereJsonSupersetOf(...args) { @@ -633,24 +610,12 @@ class QueryBuilderBase extends QueryBuilderOperationSupport { } orWhereJsonSupersetOf(...args) { - return this.addOperation( - new WhereJsonPostgresOperation('orWhereJsonSupersetOf', { - operator: '@>', - bool: 'or', - knexMethod: 'whereJsonSupersetOf', - }), - args, - ); + return this.addOperation(new WhereJsonOperation('orWhereJsonSupersetOf', { bool: 'or' }), args); } whereJsonNotSupersetOf(...args) { return this.addOperation( - new WhereJsonPostgresOperation('whereJsonNotSupersetOf', { - operator: '@>', - bool: 'and', - prefix: 'not', - knexMethod: 'whereJsonNotSupersetOf', - }), + new WhereJsonOperation('whereJsonNotSupersetOf', { bool: 'and', not: true }), args, ); } @@ -661,23 +626,14 @@ class QueryBuilderBase extends QueryBuilderOperationSupport { orWhereJsonNotSupersetOf(...args) { return this.addOperation( - new WhereJsonPostgresOperation('orWhereJsonNotSupersetOf', { - operator: '@>', - bool: 'or', - prefix: 'not', - knexMethod: 'whereJsonNotSupersetOf', - }), + new WhereJsonOperation('orWhereJsonNotSupersetOf', { bool: 'or', not: true }), args, ); } whereJsonSubsetOf(...args) { return this.addOperation( - new WhereJsonPostgresOperation('whereJsonSubsetOf', { - operator: '<@', - bool: 'and', - knexMethod: 'whereJsonSubsetOf', - }), + new WhereJsonOperation('whereJsonSubsetOf', { bool: 'and', subset: true }), args, ); } @@ -688,23 +644,14 @@ class QueryBuilderBase extends QueryBuilderOperationSupport { orWhereJsonSubsetOf(...args) { return this.addOperation( - new WhereJsonPostgresOperation('orWhereJsonSubsetOf', { - operator: '<@', - bool: 'or', - knexMethod: 'whereJsonSubsetOf', - }), + new WhereJsonOperation('orWhereJsonSubsetOf', { bool: 'or', subset: true }), args, ); } whereJsonNotSubsetOf(...args) { return this.addOperation( - new WhereJsonPostgresOperation('whereJsonNotSubsetOf', { - operator: '<@', - bool: 'and', - prefix: 'not', - knexMethod: 'whereJsonNotSubsetOf', - }), + new WhereJsonOperation('whereJsonNotSubsetOf', { bool: 'and', subset: true, not: true }), args, ); } @@ -715,111 +662,86 @@ class QueryBuilderBase extends QueryBuilderOperationSupport { orWhereJsonNotSubsetOf(...args) { return this.addOperation( - new WhereJsonPostgresOperation('orWhereJsonNotSubsetOf', { - operator: '<@', - bool: 'or', - prefix: 'not', - knexMethod: 'whereJsonNotSubsetOf', - }), + new WhereJsonOperation('orWhereJsonNotSubsetOf', { bool: 'or', subset: true, not: true }), args, ); } whereJsonNotArray(...args) { return this.addOperation( - new WhereJsonNotObjectPostgresOperation('whereJsonNotArray', { - bool: 'and', - compareValue: [], - }), + new WhereJsonTypeOperation('whereJsonNotArray', { bool: 'and', type: 'array', not: true }), args, ); } orWhereJsonNotArray(...args) { return this.addOperation( - new WhereJsonNotObjectPostgresOperation('orWhereJsonNotArray', { - bool: 'or', - compareValue: [], - }), + new WhereJsonTypeOperation('orWhereJsonNotArray', { bool: 'or', type: 'array', not: true }), args, ); } whereJsonNotObject(...args) { return this.addOperation( - new WhereJsonNotObjectPostgresOperation('whereJsonNotObject', { - bool: 'and', - compareValue: {}, - }), + new WhereJsonTypeOperation('whereJsonNotObject', { bool: 'and', type: 'object', not: true }), args, ); } orWhereJsonNotObject(...args) { return this.addOperation( - new WhereJsonNotObjectPostgresOperation('orWhereJsonNotObject', { - bool: 'or', - compareValue: {}, - }), + new WhereJsonTypeOperation('orWhereJsonNotObject', { bool: 'or', type: 'object', not: true }), args, ); } whereJsonHasAny(...args) { - return this.addOperation( - new WhereJsonHasPostgresOperation('whereJsonHasAny', { bool: 'and', operator: '?|' }), - args, - ); + return this.addOperation(new WhereJsonHasOperation('whereJsonHasAny', { bool: 'and' }), args); } orWhereJsonHasAny(...args) { - return this.addOperation( - new WhereJsonHasPostgresOperation('orWhereJsonHasAny', { bool: 'or', operator: '?|' }), - args, - ); + return this.addOperation(new WhereJsonHasOperation('orWhereJsonHasAny', { bool: 'or' }), args); } whereJsonHasAll(...args) { return this.addOperation( - new WhereJsonHasPostgresOperation('whereJsonHasAll', { bool: 'and', operator: '?&' }), + new WhereJsonHasOperation('whereJsonHasAll', { bool: 'and', all: true }), args, ); } orWhereJsonHasAll(...args) { return this.addOperation( - new WhereJsonHasPostgresOperation('orWhereJsonHasAll', { bool: 'or', operator: '?&' }), + new WhereJsonHasOperation('orWhereJsonHasAll', { bool: 'or', all: true }), args, ); } - // Not passed on to knex like `whereJsonSupersetOf()`, as MySQL's `json_contains()` - // also matches `{}` in arrays of objects. whereJsonIsArray(fieldExpression) { return this.addOperation( - new WhereJsonPostgresOperation('whereJsonSupersetOf', { operator: '@>', bool: 'and' }), - [fieldExpression, []], + new WhereJsonTypeOperation('whereJsonIsArray', { bool: 'and', type: 'array' }), + [fieldExpression], ); } orWhereJsonIsArray(fieldExpression) { return this.addOperation( - new WhereJsonPostgresOperation('orWhereJsonSupersetOf', { operator: '@>', bool: 'or' }), - [fieldExpression, []], + new WhereJsonTypeOperation('orWhereJsonIsArray', { bool: 'or', type: 'array' }), + [fieldExpression], ); } whereJsonIsObject(fieldExpression) { return this.addOperation( - new WhereJsonPostgresOperation('whereJsonSupersetOf', { operator: '@>', bool: 'and' }), - [fieldExpression, {}], + new WhereJsonTypeOperation('whereJsonIsObject', { bool: 'and', type: 'object' }), + [fieldExpression], ); } orWhereJsonIsObject(fieldExpression) { return this.addOperation( - new WhereJsonPostgresOperation('orWhereJsonSupersetOf', { operator: '@>', bool: 'or' }), - [fieldExpression, {}], + new WhereJsonTypeOperation('orWhereJsonIsObject', { bool: 'or', type: 'object' }), + [fieldExpression], ); } @@ -867,7 +789,3 @@ Object.defineProperties(QueryBuilderBase.prototype, { value: true, }, }); - -module.exports = { - QueryBuilderBase, -}; diff --git a/lib/queryBuilder/QueryBuilderContext.js b/lib/queryBuilder/QueryBuilderContext.js index 23a3599e4..d61b1e9b8 100644 --- a/lib/queryBuilder/QueryBuilderContext.js +++ b/lib/queryBuilder/QueryBuilderContext.js @@ -1,8 +1,6 @@ -'use strict'; +import { QueryBuilderContextBase } from './QueryBuilderContextBase.js'; -const { QueryBuilderContextBase } = require('./QueryBuilderContextBase'); - -class QueryBuilderContext extends QueryBuilderContextBase { +export class QueryBuilderContext extends QueryBuilderContextBase { constructor(builder) { super(builder); @@ -21,7 +19,3 @@ class QueryBuilderContext extends QueryBuilderContextBase { return ctx; } } - -module.exports = { - QueryBuilderContext, -}; diff --git a/lib/queryBuilder/QueryBuilderContextBase.js b/lib/queryBuilder/QueryBuilderContextBase.js index 92b830e0c..5a064cb1e 100644 --- a/lib/queryBuilder/QueryBuilderContextBase.js +++ b/lib/queryBuilder/QueryBuilderContextBase.js @@ -1,8 +1,6 @@ -'use strict'; +import { InternalOptions } from './InternalOptions.js'; -const { InternalOptions } = require('./InternalOptions'); - -class QueryBuilderContextBase { +export class QueryBuilderContextBase { constructor(builder) { this.userContext = builder ? new builder.constructor.QueryBuilderUserContext(builder) : null; this.options = builder ? new this.constructor.InternalOptions() : null; @@ -29,7 +27,3 @@ class QueryBuilderContextBase { return ctx; } } - -module.exports = { - QueryBuilderContextBase, -}; diff --git a/lib/queryBuilder/QueryBuilderOperationSupport.js b/lib/queryBuilder/QueryBuilderOperationSupport.js index 4e355c306..0023d5057 100644 --- a/lib/queryBuilder/QueryBuilderOperationSupport.js +++ b/lib/queryBuilder/QueryBuilderOperationSupport.js @@ -1,10 +1,7 @@ -'use strict'; - -const { isString, isFunction, isRegExp, mergeMaps, last } = require('../utils/objectUtils'); -const { QueryBuilderContextBase } = require('./QueryBuilderContextBase'); -const { QueryBuilderUserContext } = require('./QueryBuilderUserContext'); -const { deprecate } = require('../utils/warnings'); -const { getWhereGroupOperations } = require('./whereGroups'); +import { isString, isFunction, isRegExp, mergeMaps, last } from '../utils/objectUtils.js'; +import { QueryBuilderContextBase } from './QueryBuilderContextBase.js'; +import { QueryBuilderUserContext } from './QueryBuilderUserContext.js'; +import { getWhereGroupOperations } from './whereGroups.js'; const AllSelector = () => true; const SelectSelector = @@ -15,7 +12,7 @@ const OrderBySelector = /orderBy/; const JoinSelector = /(join|joinRaw|joinRelated)$/i; const FromSelector = /^(from|fromRaw|into|table)$/; -class QueryBuilderOperationSupport { +export class QueryBuilderOperationSupport { constructor(...args) { this.constructor.init(this, ...args); } @@ -415,9 +412,8 @@ class QueryBuilderOperationSupport { const shouldAdd = this.callOperationMethod(operation, 'onAdd', [this, args]); if (shouldAdd) { - if (this._whereGroup) { - operation.whereGroup = this._whereGroup; - } + // Operations with their own where group, like `findById()`, keep it. + operation.whereGroup ??= this._whereGroup; if (this._activeOperations.length) { const { operation: parentOperation, hookName } = last(this._activeOperations); @@ -520,12 +516,6 @@ class QueryBuilderOperationSupport { toSql() { return this.toString(); } - - skipUndefined() { - deprecate('skipUndefined() is deprecated and will be removed in objection 4.0'); - this.internalOptions().skipUndefined = true; - return this; - } } function buildFunctionForOperationSelector(operationSelector) { @@ -546,7 +536,3 @@ function buildFunctionForOperationSelector(operationSelector) { return () => false; } } - -module.exports = { - QueryBuilderOperationSupport, -}; diff --git a/lib/queryBuilder/QueryBuilderUserContext.js b/lib/queryBuilder/QueryBuilderUserContext.js index 74610967b..9fe6e548d 100644 --- a/lib/queryBuilder/QueryBuilderUserContext.js +++ b/lib/queryBuilder/QueryBuilderUserContext.js @@ -1,8 +1,6 @@ -'use strict'; - const SYMBOL_BUILDER = Symbol(); -class QueryBuilderUserContext { +export class QueryBuilderUserContext { constructor(builder) { // This should never ever be accessed outside this class. We only // store it so that we can access builder.knex() lazily. @@ -25,7 +23,3 @@ class QueryBuilderUserContext { return ctx; } } - -module.exports = { - QueryBuilderUserContext, -}; diff --git a/lib/queryBuilder/RawBuilder.js b/lib/queryBuilder/RawBuilder.js index 57a92d4e3..bf9bb4156 100644 --- a/lib/queryBuilder/RawBuilder.js +++ b/lib/queryBuilder/RawBuilder.js @@ -1,9 +1,7 @@ -'use strict'; +import { isPlainObject } from '../utils/objectUtils.js'; +import { buildArg } from '../utils/buildUtils.js'; -const { isPlainObject } = require('../utils/objectUtils'); -const { buildArg } = require('../utils/buildUtils'); - -class RawBuilder { +export class RawBuilder { constructor(sql, args) { this._sql = `${sql}`; this._args = args; @@ -62,7 +60,7 @@ function buildObject(obj, builder) { }, {}); } -function normalizeRawArgs(argsIn) { +export function normalizeRawArgs(argsIn) { const [sql, ...restArgs] = argsIn; if (restArgs.length === 1 && Array.isArray(restArgs[0])) { @@ -78,13 +76,7 @@ function normalizeRawArgs(argsIn) { } } -function raw(...argsIn) { +export function raw(...argsIn) { const { sql, args } = normalizeRawArgs(argsIn); return new RawBuilder(sql, args); } - -module.exports = { - RawBuilder, - normalizeRawArgs, - raw, -}; diff --git a/lib/queryBuilder/ReferenceBuilder.js b/lib/queryBuilder/ReferenceBuilder.js index ba21fefd7..7acdde6ef 100644 --- a/lib/queryBuilder/ReferenceBuilder.js +++ b/lib/queryBuilder/ReferenceBuilder.js @@ -1,13 +1,8 @@ -'use strict'; +import { parseFieldExpression } from '../utils/parseFieldExpression.js'; +import { isObject } from '../utils/objectUtils.js'; +import { getJsonDialect } from './jsonDialects/index.js'; -const { - parseFieldExpression, - toPostgresJsonPathLiteral, -} = require('../utils/parseFieldExpression'); -const { isObject } = require('../utils/objectUtils'); -const { warnIfNotPostgres } = require('./operations/jsonApi/postgresJsonApi'); - -class ReferenceBuilder { +export class ReferenceBuilder { constructor(expr) { this._expr = expr; this._parsedExpr = null; @@ -164,13 +159,7 @@ class ReferenceBuilder { } toKnexRaw(builder) { - const knex = builder.knex(); - - if (this._parsedExpr.access.length > 0) { - warnIfNotPostgres(knex); - } - - return knex.raw(...this._createRawArgs(builder)); + return builder.knex().raw(...this._createRawArgs(builder)); } _parseExpression(expr) { @@ -180,41 +169,29 @@ class ReferenceBuilder { } _createRawArgs(builder) { - let bindings = []; - let sql = this._createReferenceSql(builder, bindings); - - sql = this._maybeCast(sql, bindings); - sql = this._maybeToJsonb(sql, bindings); - sql = this._maybeAlias(sql, bindings); + const dialect = getJsonDialect(builder.knex()); + const { access } = this._parsedExpr; + const bindings = [this.fullColumn(builder)]; + let sql = '??'; - return [sql, bindings]; - } - - _createReferenceSql(builder, bindings) { - bindings.push(this.fullColumn(builder)); + if (access.length > 0 || this._toJson) { + dialect.checkSupport(); + } - if (this._parsedExpr.access.length > 0) { - const extractor = this._cast ? '#>>' : '#>'; - return `??${extractor}${toPostgresJsonPathLiteral(this._parsedExpr.access)}`; - } else { - return '??'; + if (access.length > 0) { + sql = dialect.extract(sql, access, this._cast); } - } - _maybeCast(sql) { if (this._cast) { - return `CAST(${sql} AS ${this._cast})`; - } else { - return sql; + sql = dialect.cast(sql, this._cast); } - } - _maybeToJsonb(sql) { if (this._toJson) { - return `to_jsonb(${sql})`; - } else { - return sql; + sql = dialect.toJson(sql, access.length === 0 && !this._cast); } + + sql = this._maybeAlias(sql, bindings); + return [sql, bindings]; } _maybeAlias(sql, bindings) { @@ -247,15 +224,10 @@ Object.defineProperties(ReferenceBuilder.prototype, { }, }); -const ref = (reference) => { +export const ref = (reference) => { if (isObject(reference) && reference.isObjectionReferenceBuilder) { return reference; } else { return new ReferenceBuilder(reference); } }; - -module.exports = { - ReferenceBuilder, - ref, -}; diff --git a/lib/queryBuilder/RelationExpression.js b/lib/queryBuilder/RelationExpression.js index a4aa04a5d..a3b8fb47f 100644 --- a/lib/queryBuilder/RelationExpression.js +++ b/lib/queryBuilder/RelationExpression.js @@ -1,18 +1,24 @@ -'use strict'; +import * as parser from './parsers/relationExpressionParser.js'; +import { isObject, isNumber, isString, union } from '../utils/objectUtils.js'; +import { RelationDoesNotExistError } from '../model/RelationDoesNotExistError.js'; -const parser = require('./parsers/relationExpressionParser'); -const { isObject, isNumber, isString, union } = require('../utils/objectUtils'); -const { RelationDoesNotExistError } = require('../model/RelationDoesNotExistError'); - -class RelationExpressionParseError extends Error {} -class DuplicateRelationError extends RelationExpressionParseError { +export class RelationExpressionParseError extends Error {} +export class DuplicateRelationError extends RelationExpressionParseError { constructor(relationName) { - super(); - this.relationName = relationName; + super( + `Duplicate relation name "${relationName}". For example, use "${relationName}.[foo, bar]" ` + + `instead of "[${relationName}.foo, ${relationName}.bar]".`, + ); + } +} + +export class MisplacedTokenError extends RelationExpressionParseError { + constructor(token) { + super(`"${token}" is only supported at the end of a relation path.`); } } -class RelationExpression { +export class RelationExpression { constructor(node = newNode(), recursionDepth = 0) { this.node = node; this.recursionDepth = recursionDepth; @@ -36,6 +42,8 @@ class RelationExpression { } catch (err) { if (err.duplicateRelationName) { throw new DuplicateRelationError(err.duplicateRelationName); + } else if (err.misplacedToken) { + throw new MisplacedTokenError(err.misplacedToken); } else { throw new RelationExpressionParseError(err.message); } @@ -437,9 +445,3 @@ Object.defineProperties(RelationExpression.prototype, { value: true, }, }); - -module.exports = { - RelationExpression, - RelationExpressionParseError, - DuplicateRelationError, -}; diff --git a/lib/queryBuilder/StaticHookArguments.js b/lib/queryBuilder/StaticHookArguments.js index 6d4f49992..866493cbc 100644 --- a/lib/queryBuilder/StaticHookArguments.js +++ b/lib/queryBuilder/StaticHookArguments.js @@ -1,11 +1,9 @@ -'use strict'; - -const { asArray } = require('../utils/objectUtils'); +import { asArray, isObject } from '../utils/objectUtils.js'; const BUILDER_SYMBOL = Symbol(); -class StaticHookArguments { - constructor({ builder, result = null }) { +export class StaticHookArguments { + constructor({ builder, result }) { // The builder should never be accessed through the arguments. // Hide it as well as possible to discourage people from // digging it out. @@ -14,7 +12,7 @@ class StaticHookArguments { }); Object.defineProperty(this, 'result', { - value: asArray(result), + value: result, }); } @@ -33,7 +31,7 @@ class StaticHookArguments { .clear('runAfter') .clearResolve() .clearWithGraphFetched() - .runAfter(toArray); + .runAfter(asArray); }; } @@ -46,7 +44,7 @@ class StaticHookArguments { } get relation() { - const op = this[BUILDER_SYMBOL].findOperation(hasRelation); + const op = findRelationOperation(this[BUILDER_SYMBOL], hasRelation); if (op) { return getRelation(op); @@ -66,7 +64,7 @@ class StaticHookArguments { } get items() { - const op = this[BUILDER_SYMBOL].findOperation(hasItems); + const op = findRelationOperation(this[BUILDER_SYMBOL], hasItems); if (op) { return asArray(getItems(op)); @@ -99,6 +97,9 @@ class StaticHookArguments { } else { cancelValue = 0; } + } else if (builder.isInsert() && isObject(cancelValue)) { + // A single row is merged into the input model, like an array of rows. + cancelValue = asArray(cancelValue); } builder.resolve(cancelValue); @@ -106,9 +107,70 @@ class StaticHookArguments { } } -// Like `asArray()`, but an empty `first()` result becomes an empty array. -function toArray(result) { - return result === undefined ? [] : asArray(result); +// The outer queries of `insertGraph()` and `upsertGraph()` call no static hooks, +// the queries that they run for the graph do. Match their operations by name, +// as importing them here would create an import cycle. +export function isGraphQuery(builder) { + return builder.has(/^(insertGraph|upsertGraph)/); +} + +// The relations that `withGraphJoined()` fetches have no query of their own, so +// the hooks of their model classes get the relation and its owners instead. +export class JoinedStaticHookArguments extends StaticHookArguments { + #relation; + #items; + + constructor({ builder, result, relation, items }) { + super({ builder, result }); + this.#relation = relation; + this.#items = items; + } + + static create(args) { + return new JoinedStaticHookArguments(args); + } + + get asFindQuery() { + return () => { + throw new Error( + 'asFindQuery() is not available for relations fetched with withGraphJoined().', + ); + }; + } + + get relation() { + return this.#relation; + } + + get modelOptions() { + return undefined; + } + + get items() { + return this.#items; + } + + get inputItems() { + return []; + } + + get cancelQuery() { + return () => { + throw new Error( + 'cancelQuery() is not available for relations fetched with withGraphJoined().', + ); + }; + } +} + +// The queries that `relate()` and `unrelate()` run in their place get the +// relation and the owners of the relate or unrelate query. +function findRelationOperation(builder, predicate) { + const parent = builder.parentQuery(); + return ( + builder.findOperation(predicate) || + (parent?.isRelate() || parent?.isUnrelate() ? parent.findOperation(predicate) : null) + ); } function getRelation(op) { @@ -142,7 +204,3 @@ function getInputItems(op) { function hasInputItems(op) { return !!getInputItems(op); } - -module.exports = { - StaticHookArguments, -}; diff --git a/lib/queryBuilder/ValueBuilder.js b/lib/queryBuilder/ValueBuilder.js index 4e95f832a..9daaa7120 100644 --- a/lib/queryBuilder/ValueBuilder.js +++ b/lib/queryBuilder/ValueBuilder.js @@ -1,9 +1,8 @@ -'use strict'; +import { isObject } from '../utils/objectUtils.js'; +import { buildArg } from '../utils/buildUtils.js'; +import { getJsonDialect } from './jsonDialects/index.js'; -const { asArray, isObject } = require('../utils/objectUtils'); -const { buildArg } = require('../utils/buildUtils'); - -class ValueBuilder { +export class ValueBuilder { constructor(value) { this._value = value; this._cast = null; @@ -73,6 +72,7 @@ class ValueBuilder { } _createRawArgs(builder) { + const dialect = getJsonDialect(builder.knex()); let sql = null; let bindings = []; @@ -80,7 +80,8 @@ class ValueBuilder { bindings.push(JSON.stringify(this._value)); sql = '?'; } else if (this._toArray) { - const values = asArray(this._value); + // Wrap `undefined` too, it is a value here: + const values = Array.isArray(this._value) ? this._value : [this._value]; bindings.push(...values.map((it) => buildArg(it, builder))); sql = `ARRAY[${values.map(() => '?').join(', ')}]`; } else { @@ -89,7 +90,9 @@ class ValueBuilder { } if (this._cast) { - sql = `CAST(${sql} AS ${this._cast})`; + sql = dialect.cast(sql, this._cast); + } else if (this._toJson) { + sql = dialect.json(sql); } if (this._alias) { @@ -101,11 +104,6 @@ class ValueBuilder { } } -function val(val) { +export function val(val) { return new ValueBuilder(val); } - -module.exports = { - ValueBuilder, - val, -}; diff --git a/lib/queryBuilder/getJoinBuilder.js b/lib/queryBuilder/getJoinBuilder.js new file mode 100644 index 000000000..789520f46 --- /dev/null +++ b/lib/queryBuilder/getJoinBuilder.js @@ -0,0 +1,10 @@ +// A small helper for lazy access to the JoinBuilder class, for modules that the +// JoinBuilder module imports itself. Importing JoinBuilder.js there would create +// a circular import, so JoinBuilder.js registers itself here instead. +let JoinBuilder; + +export const getJoinBuilder = () => JoinBuilder; + +export const setJoinBuilder = (joinBuilder) => { + JoinBuilder = joinBuilder; +}; diff --git a/lib/queryBuilder/graph/GraphAction.js b/lib/queryBuilder/graph/GraphAction.js deleted file mode 100644 index 7d39d756b..000000000 --- a/lib/queryBuilder/graph/GraphAction.js +++ /dev/null @@ -1,69 +0,0 @@ -'use strict'; - -const { isPostgres } = require('../../utils/knexUtils'); - -const POSTGRES_MAX_INSERT_BATCH_SIZE = 100; -const MAX_CONCURRENCY = 100; - -class GraphAction { - constructor(graphData) { - this.graphData = graphData; - } - - static get ReturningAllSelector() { - return (op) => { - // Only select `returning('*')` operation. - return op.name === 'returning' && op.args.includes('*'); - }; - } - - static getConcurrency(builder, nodes) { - return nodes.reduce((minConcurrency, node) => { - return Math.min(minConcurrency, node.modelClass.getConcurrency(builder.unsafeKnex())); - }, MAX_CONCURRENCY); - } - - get graph() { - return this.graphData.graph; - } - - get currentGraph() { - return this.graphData.currentGraph; - } - - get graphOptions() { - return this.graphData.graphOptions; - } - - run(builder) { - return null; - } - - _getConcurrency(builder, nodes) { - return GraphAction.getConcurrency(builder, nodes); - } - - _getBatchSize(builder) { - return isPostgres(builder.unsafeKnex()) ? POSTGRES_MAX_INSERT_BATCH_SIZE : 1; - } - - _resolveReferences(node) { - if (node.isReference) { - this._resolveReference(node); - } - } - - _resolveReference(node) { - const refNode = node.referencedNode; - - for (const prop of Object.keys(refNode.obj)) { - if (!node.obj.hasOwnProperty(prop)) { - node.obj[prop] = refNode.obj[prop]; - } - } - } -} - -module.exports = { - GraphAction, -}; diff --git a/lib/queryBuilder/graph/GraphData.js b/lib/queryBuilder/graph/GraphData.js deleted file mode 100644 index 80f7604fb..000000000 --- a/lib/queryBuilder/graph/GraphData.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -class GraphData { - constructor({ graph, currentGraph, graphOptions, nodeDbExistence }) { - this.graph = graph; - this.currentGraph = currentGraph; - this.graphOptions = graphOptions; - this.nodeDbExistence = nodeDbExistence; - // Pruning replaces `graph.nodes`, but `keptNodeForRow()` also needs the - // branches below related nodes. - this._unprunedNodes = graph.nodes; - this._keptNodesByRowKey = null; - } - - // Find the node that keeps the row of `currentNode` in another place of the - // graph, by relating or patching it there, or by upserting it below a related - // node. Only call this after pruning. - keptNodeForRow(currentNode) { - if (!this._keptNodesByRowKey) { - this._keptNodesByRowKey = new Map(); - const nodes = new Set(this.graph.nodes); - - // Nested upserts write the pruned branches below related nodes, except - // for models marked with `#unrelate` or `#delete` and their descendants: - const isUpsertedBelowRelatedNode = (node) => - !node.toBeUnrelatedOrDeleted && - (nodes.has(node.parentNode) - ? this.graphOptions.shouldRelate(node.parentNode, this) - : isUpsertedBelowRelatedNode(node.parentNode)); - - for (const node of this._unprunedNodes) { - if ( - node.obj.$hasId() && - (nodes.has(node) - ? this.graphOptions.shouldPatchOrUpdateIgnoreDisable(node, this) - : isUpsertedBelowRelatedNode(node)) - ) { - this._keptNodesByRowKey.set(getRowKey(node), node); - } - } - } - - return this._keptNodesByRowKey.get(getRowKey(currentNode)) || null; - } -} - -function getRowKey(node) { - return `${node.modelClass.getTableName()}:${node.obj.$idKey()}`; -} - -module.exports = { - GraphData, -}; diff --git a/lib/queryBuilder/graph/GraphFetcher.js b/lib/queryBuilder/graph/GraphFetcher.js deleted file mode 100644 index caa1ad98a..000000000 --- a/lib/queryBuilder/graph/GraphFetcher.js +++ /dev/null @@ -1,197 +0,0 @@ -'use strict'; - -const { asArray, groupBy } = require('../../utils/objectUtils'); -const { ModelGraph } = require('../../model/graph/ModelGraph'); -const { FetchStrategy } = require('./GraphOptions'); -const { RelationExpression } = require('../RelationExpression'); -const { getDeclaredColumns } = require('../operations/UpdateOperation'); -const { getFieldExpressionColumn } = require('../../utils/parseFieldExpression'); - -class GraphFetcher { - /** - * Given a graph and options, fetches the current state of that graph - * from the database and returns it as a ModelGraph instance. - */ - static async fetchCurrentGraph({ builder, graph, graphOptions }) { - const { rootObjects } = graph; - - const rootIds = getRootIds(rootObjects); - const modelClass = builder.modelClass(); - - if (rootIds.length === 0) { - return Promise.resolve(ModelGraph.create(modelClass, [])); - } - - const eagerExpr = RelationExpression.fromModelGraph(rootObjects); - const models = await modelClass - .query() - .childQueryOf(builder, childQueryOptions()) - .modify(propagateMethodCallsFromQuery(builder)) - .modify(buildFetchQuerySelects(graph, graphOptions, eagerExpr)) - .findByIds(rootIds) - .withGraphFetched(eagerExpr) - .internalOptions(fetchQueryInternalOptions()); - - return ModelGraph.create(modelClass, models); - } -} - -function getRootIds(rootObjects) { - return asArray(rootObjects) - .filter((it) => it.$hasId()) - .map((root) => root.$id()); -} - -function propagateMethodCallsFromQuery(builder) { - return (fetchBuilder) => { - // Propagate some method calls from the root query. - for (const method of ['forUpdate', 'forShare', 'forNoKeyUpdate', 'forKeyShare']) { - if (builder.has(method)) { - fetchBuilder[method](); - } - } - }; -} - -function buildFetchQuerySelects(graph, graphOptions, eagerExpr) { - return (builder) => { - const nodesByRelationPath = groupNodesByRelationPath(graph, eagerExpr); - - for (const [relationPath, nodes] of nodesByRelationPath.entries()) { - const selectModifier = createFetchSelectModifier(nodes, graphOptions); - - if (!relationPath) { - builder.modify(selectModifier); - } else { - builder.modifyGraph(relationPath, selectModifier); - } - } - }; -} - -function groupNodesByRelationPath(graph, eagerExpr) { - const nodesByRelationPath = groupBy(graph.nodes, (node) => node.relationPathKey); - - // Not all relation paths have nodes. Relations with nulls or empty arrays - // don't have nodes, but will still need to be fetched. Add these to the - // map as empty arrays. - forEachPath(eagerExpr.node, (relationPath) => { - if (!nodesByRelationPath.has(relationPath)) { - nodesByRelationPath.set(relationPath, []); - } - }); - - return nodesByRelationPath; -} - -function createFetchSelectModifier(nodes, graphOptions) { - if (graphOptions.isFetchStrategy(FetchStrategy.OnlyIdentifiers)) { - return createIdentifierSelector(); - } else if (graphOptions.isFetchStrategy(FetchStrategy.OnlyNeeded)) { - return createInputColumnSelector(nodes); - } else { - return () => {}; - } -} - -// Returns a function that only selects the id column. -function createIdentifierSelector() { - return (builder) => { - builder.select(builder.fullIdColumn()); - }; -} - -// Returns a function that only selects the columns that exist in the input. -function createInputColumnSelector(nodes) { - return (builder) => { - const selects = new Map(); - - for (const node of nodes) { - const databaseJson = node.obj.$toDatabaseJson(builder); - const declaredColumns = getDeclaredColumns(node.modelClass); - - for (const key of Object.keys(databaseJson)) { - // Select the json column of field expressions like `'meta:a'`: - const column = getFieldExpressionColumn(key, declaredColumns) ?? key; - - if (!shouldSelectColumn(column, selects, node)) { - continue; - } - - const selection = - createManyToManyExtraSelectionIfNeeded(builder, column, node) || - createSelection(builder, column, node); - - selects.set(column, selection); - } - } - - const selectArr = Array.from(selects.values()); - const idColumns = asArray(builder.fullIdColumn()); - - for (const idColumn of idColumns) { - if (!selectArr.includes(idColumn)) { - // Always select the identifers. - selectArr.push(idColumn); - } - } - - builder.select(selectArr); - }; -} - -function shouldSelectColumn(column, selects, node) { - const modelClass = node.modelClass; - - return ( - !selects.has(column) && - column !== modelClass.propertyNameToColumnName(modelClass.dbRefProp) && - column !== modelClass.propertyNameToColumnName(modelClass.uidRefProp) && - column !== modelClass.propertyNameToColumnName(modelClass.uidProp) && - column !== modelClass.propertyNameToColumnName(modelClass.graphUnrelateProp) && - column !== modelClass.propertyNameToColumnName(modelClass.graphDeleteProp) - ); -} - -function createManyToManyExtraSelectionIfNeeded(builder, column, node) { - if (node.parentEdge && node.parentEdge.relation.isObjectionManyToManyRelation) { - const relation = node.parentEdge.relation; - const extra = relation.joinTableExtras.find((extra) => extra.aliasCol === column); - - if (extra) { - return `${relation.joinTableAliasFor(builder)}.${extra.joinTableCol} as ${extra.aliasCol}`; - } - } - - return null; -} - -function createSelection(builder, column, node) { - return `${builder.tableRefFor(node.modelClass)}.${column}`; -} - -function childQueryOptions() { - return { - fork: true, - isInternalQuery: true, - }; -} - -function fetchQueryInternalOptions() { - return { - keepImplicitJoinProps: true, - }; -} - -function forEachPath(eagerExprNode, cb, path = []) { - for (const relation of eagerExprNode.$childNames) { - path.push(relation); - cb(path.join('.')); - forEachPath(eagerExprNode[relation], cb, path); - path.pop(); - } -} - -module.exports = { - GraphFetcher, -}; diff --git a/lib/queryBuilder/graph/GraphLoader.js b/lib/queryBuilder/graph/GraphLoader.js new file mode 100644 index 000000000..52bb42ad0 --- /dev/null +++ b/lib/queryBuilder/graph/GraphLoader.js @@ -0,0 +1,304 @@ +import { asArray, chunk, groupBy, uniqBy } from '../../utils/objectUtils.js'; +import { getMaxRows } from '../../utils/knexUtils.js'; +import { FetchStrategy } from './GraphOptions.js'; +import { GraphNode } from './GraphNode.js'; +import { RelationExpression } from '../RelationExpression.js'; +import { getDeclaredColumns } from '../operations/fieldExpressions.js'; +import { getFieldExpressionColumn } from '../../utils/parseFieldExpression.js'; + +// Fetch the current state of the graph from the database and pair its rows with +// the models of the graph: First the roots of the pass with their relations, +// then the rows of the models that the graph relates from elsewhere, with the +// relations that the graph gives them, level by level, as the rows of related +// models decide which models below them are related too. Models that +// `insertMissing` inserts if they don't exist are looked up too. The rows that +// a relate moves into a HasManyRelation or HasOneRelation are fetched without +// their relations, which change with the move. Return the ones that the graph +// gives relations, to be upserted in a pass of their own after the move. +export async function loadCurrentGraph(builder, plan) { + const passNodes = new Set(plan.nodes); + const fetchedNodes = new Set(); + const movedNodes = []; + + const fetch = async (nodes, { areRelated }) => { + for (const node of nodes) { + fetchedNodes.add(node); + } + + for (const [isMovedRow, groupNodes] of groupBy(nodes, (node) => areRelated && isMoved(node))) { + for (const nodesOfClass of groupBy(groupNodes, (node) => node.modelClass).values()) { + const models = await fetchModels(builder, plan.graphOptions, nodesOfClass, { + withRelations: !isMovedRow, + }); + const modelsByKey = new Map(models.map((model) => [model.$idKey(), model])); + + for (const node of nodesOfClass) { + const model = modelsByKey.get(node.obj.$idKey()); + + if (model && isMovedRow && hasRelations(node.obj)) { + movedNodes.push(node); + } else if (model) { + const currentNodes = GraphNode.buildGraph(node.modelClass, model, node.relationPath); + const [currentNode] = currentNodes; + plan.currentNodes.push(...currentNodes); + + if (areRelated) { + node.row = currentNode; + currentNode.node = node; + pairChildren(node, currentNode); + } else { + pair(node, currentNode); + } + } else if (areRelated) { + node.isMissing = true; + } + } + } + } + }; + + // Find the models that the graph relates and that aren't fetched yet, for + // which `isFetched(node, rowKeys)` is true. The ones below others wait for + // their rows, and the ones in branches that the graph doesn't write are left + // out. + const findRelatedNodes = (isFetched) => { + const rowKeys = new Set(plan.currentNodes.map((currentNode) => currentNode.rowKey)); + const nodes = plan.nodes.filter( + (node) => + !fetchedNodes.has(node) && + !node.current && + node.obj.$hasId() && + plan.actionFor(node) === 'relate' && + isFetched(node, rowKeys), + ); + const nodeSet = new Set(nodes); + + return nodes.filter((node) => { + for (let parent = node.parent; passNodes.has(parent); parent = parent.parent) { + if (nodeSet.has(parent) || !plan.writesBranchOf(parent)) { + return false; + } + } + + return true; + }); + }; + + await fetch( + plan.nodes.filter((node) => !passNodes.has(node.parent) && node.hasId), + { areRelated: false }, + ); + + // The rows of the related models that the graph gives relations, or inserts + // with `insertMissing` if they don't exist: + const hasRelatedRow = (node, rowKeys) => + hasRelations(node.obj) || + (plan.graphOptions.has(node, 'insertMissing') && !rowKeys.has(node.rowKey)); + + for ( + let nodes = findRelatedNodes(hasRelatedRow); + nodes.length > 0; + nodes = findRelatedNodes(hasRelatedRow) + ) { + await fetch(nodes, { areRelated: true }); + } + + // The other related models only need to exist, except for `#ref` models, + // which are the models that they refer to, and the rows of the current graph + // (#372): + const nodes = findRelatedNodes((node, rowKeys) => !node.isReference && !rowKeys.has(node.rowKey)); + + for (const nodesOfClass of groupBy(nodes, (node) => node.modelClass).values()) { + const models = await fetchModels(builder, plan.graphOptions, nodesOfClass, { + onlyIds: true, + }); + const idKeys = new Set(models.map((model) => model.$idKey())); + + for (const node of nodesOfClass) { + node.isMissing = !idKeys.has(node.obj.$idKey()); + } + } + + return movedNodes; +} + +function pair(node, currentNode) { + node.current ??= currentNode; + currentNode.node = node; + pairChildren(node, currentNode); +} + +// Pair the related models with ids with the current rows in the same relations. +// Rows can be related more than once through duplicate join rows, and all of +// them pair with the same model. +function pairChildren(node, currentNode) { + const childrenByRelation = groupBy(currentNode.children, (child) => child.relation.name); + + for (const child of node.children) { + if (child.obj.$hasId()) { + const key = child.obj.$idKey(); + + for (const currentChild of childrenByRelation.get(child.relation.name) || []) { + if (currentChild.obj.$idKey() === key) { + pair(child, currentChild); + } + } + } + } +} + +function isMoved(node) { + return node.relation.keySide === 'related'; +} + +function hasRelations(obj) { + return obj.constructor.getRelationNames().some((name) => obj[name] !== undefined); +} + +// Fetch the rows of `nodes`, with their relations unless they're moved, or only +// their ids, in as many queries as the number of their ids requires. +async function fetchModels(builder, graphOptions, nodes, options) { + const [{ modelClass }] = nodes; + const uniqueNodes = uniqBy(nodes, (node) => node.obj.$idKey()); + const maxIds = getMaxRows(builder.unsafeKnex(), modelClass.getIdColumnArray().length); + const models = []; + + for (const chunkNodes of chunk(uniqueNodes, maxIds)) { + models.push(...(await fetchModelsOfNodes(builder, graphOptions, chunkNodes, options))); + } + + return models; +} + +function fetchModelsOfNodes( + builder, + graphOptions, + nodes, + { withRelations = false, onlyIds = false }, +) { + const [{ modelClass }] = nodes; + const ids = nodes.map((node) => node.obj.$id()); + const expression = RelationExpression.fromModelGraph( + withRelations ? nodes.map((node) => node.obj) : [], + ); + + return modelClass + .query() + .childQueryOf(builder, { fork: true, isInternalQuery: true }) + .modify((fetchBuilder) => { + // Lock the fetched rows like the upsert query asks for. + for (const method of ['forUpdate', 'forShare', 'forNoKeyUpdate', 'forKeyShare']) { + if (builder.has(method)) { + fetchBuilder[method](); + } + } + }) + .modify((fetchBuilder) => { + const nodesByRelationPath = withRelations + ? getNodesByRelationPath(nodes, expression) + : new Map([['', nodes]]); + + for (const [relationPath, pathNodes] of nodesByRelationPath) { + const select = onlyIds + ? (builder) => builder.select(builder.fullIdColumn()) + : createSelect(pathNodes, graphOptions, !relationPath); + + if (!relationPath) { + fetchBuilder.modify(select); + } else { + fetchBuilder.modifyGraph(relationPath, select); + } + } + }) + .findByIds(ids) + .withGraphFetched(expression) + .internalOptions({ keepImplicitJoinProps: true }); +} + +// Group the fetched nodes and their descendants by their relation paths below +// the fetched nodes. Relations with nulls or empty arrays have no nodes, but +// are fetched too. +function getNodesByRelationPath(nodes, expression) { + const nodesByRelationPath = new Map(); + + for (const node of nodes) { + const depth = node.relationPath.length; + + for (const descendant of [node, ...node.descendants]) { + const relationPath = descendant.relationPath.slice(depth).join('.'); + const pathNodes = nodesByRelationPath.get(relationPath); + + if (pathNodes) { + pathNodes.push(descendant); + } else { + nodesByRelationPath.set(relationPath, [descendant]); + } + } + } + + const addPaths = (expressionNode, path) => { + for (const name of expressionNode.$childNames) { + const relationPath = [...path, name]; + const key = relationPath.join('.'); + + if (!nodesByRelationPath.has(key)) { + nodesByRelationPath.set(key, []); + } + + addPaths(expressionNode[name], relationPath); + } + }; + + addPaths(expression.node, []); + return nodesByRelationPath; +} + +function createSelect(nodes, graphOptions, isFetchRoot) { + if (graphOptions.isFetchStrategy(FetchStrategy.OnlyIdentifiers)) { + return (builder) => builder.select(builder.fullIdColumn()); + } else if (graphOptions.isFetchStrategy(FetchStrategy.OnlyNeeded)) { + return (builder) => builder.select(getInputColumnSelections(builder, nodes, isFetchRoot)); + } else { + return () => {}; + } +} + +// Select only the columns that the graph gives, and always the ids. Related +// models are fetched without the join tables of their relations, so their join +// table extras are left out at the root of the fetch. +function getInputColumnSelections(builder, nodes, isFetchRoot) { + const selections = new Map(); + + for (const node of nodes) { + const declaredColumns = getDeclaredColumns(node.modelClass); + + for (const key of Object.keys(node.obj.$toDatabaseJson(builder))) { + // Select the json column of field expressions like `'meta:a'`: + const column = getFieldExpressionColumn(key, declaredColumns) ?? key; + + if (!selections.has(column)) { + const { relation } = node; + const extra = + relation?.keySide === 'join' && + relation.joinTableExtras.find((extra) => extra.aliasCol === column); + + if (!extra) { + selections.set(column, `${builder.tableRefFor(node.modelClass)}.${column}`); + } else if (!isFetchRoot) { + const alias = relation.joinTableAliasFor(builder); + selections.set(column, `${alias}.${extra.joinTableCol} as ${extra.aliasCol}`); + } + } + } + } + + const values = [...selections.values()]; + + for (const idColumn of asArray(builder.fullIdColumn())) { + if (!values.includes(idColumn)) { + values.push(idColumn); + } + } + + return values; +} diff --git a/lib/queryBuilder/graph/GraphNode.js b/lib/queryBuilder/graph/GraphNode.js new file mode 100644 index 000000000..aca9f4745 --- /dev/null +++ b/lib/queryBuilder/graph/GraphNode.js @@ -0,0 +1,235 @@ +import { asArray, asSingle, isNumber, isObject, isString } from '../../utils/objectUtils.js'; +import { ValidationErrorType } from '../../model/ValidationError.js'; + +// A model in a graph that is inserted or upserted, or in the current graph that +// is fetched from the database for it. +export class GraphNode { + constructor(modelClass, obj, { parent = null, relation = null, index = null, relationPath }) { + this.modelClass = modelClass; + this.obj = obj; + this.parent = parent; + this.relation = relation; + // The index in the array of a to-many relation, or of array input: + this.index = index; + this.children = []; + this.relationPath = parent ? [...parent.relationPath, relation.name] : relationPath; + this.relationPathKey = this.relationPath.join('.'); + // The id state of the input, before the writes complete ids with keys: + this.hasId = obj.$hasId(); + + // Read the markers of the graph, which `removeMarkers()` removes from the + // models once all places of a model are read: + const { uidProp, uidRefProp, dbRefProp, graphUnrelateProp, graphDeleteProp } = modelClass; + this.uid = obj[uidProp]; + this.reference = obj[uidRefProp]; + this.dbReference = obj[dbRefProp]; + this.toBeUnrelated = obj[graphUnrelateProp] === true; + this.toBeDeleted = obj[graphDeleteProp] === true; + + // The node of a `#ref` model, and the `#ref{}` properties of this model: + this.referencedNode = null; + this.propertyReferences = []; + // The nodes whose `#ref` or `#ref{}` point to this node: + this.referencingNodes = []; + this.propertyReferencingNodes = []; + + // How the graph and the current graph pair up, see `GraphLoader`: The + // current node at the place of an input node and the other way around, and + // the current node of a related model's row fetched from elsewhere. + this.current = null; + this.node = null; + this.row = null; + this.isMissing = false; + + // Set by `GraphPlan`: + this.isPruned = false; + } + + get isReference() { + return this.reference !== undefined; + } + + get isDbReference() { + return this.dbReference !== undefined; + } + + get toBeUnrelatedOrDeleted() { + return this.toBeUnrelated || this.toBeDeleted; + } + + get isRoot() { + return !this.parent; + } + + // The current node of the row of this node, at its place or where a relate + // moves it from. + get existing() { + return this.current || this.row; + } + + get dataPath() { + return this.parent + ? [ + ...this.parent.dataPath, + this.relation.name, + ...(this.relation.isOneToOne() ? [] : [this.index]), + ] + : this.index !== null + ? [this.index] + : []; + } + + // The data path in the form of validation errors, like `.pets[0].owner` or + // `[1].pets[0].owner` for array input. + get dataPathKey() { + return this.dataPath.map((key) => (isNumber(key) ? `[${key}]` : `.${key}`)).join(''); + } + + get descendants() { + return this.children.flatMap((child) => [child, ...child.descendants]); + } + + get activeChildren() { + return this.children.filter((child) => !child.isPruned); + } + + get rowKey() { + return `${this.modelClass.getTableName()}:${this.obj.$idKey()}`; + } + + // Build the nodes of `roots` and their related models in depth-first order. + // `relationPath` is the place of the roots in the graph that a current graph + // is fetched for. The roots of array input know their index in it. + static buildGraph(modelClass, roots, relationPath = []) { + const nodes = []; + + const addNode = (modelClass, obj, place) => { + if (!isObject(obj) || !obj.$isObjectionModel) { + throw createNotModelError(modelClass, obj); + } + + const node = new GraphNode(modelClass, obj, place); + nodes.push(node); + place.parent?.children.push(node); + + for (const name of modelClass.getRelationNames()) { + const related = obj[name]; + + if (related) { + const relation = modelClass.getRelation(name); + const items = relation.isOneToOne() ? [asSingle(related)] : asArray(related); + + items.forEach((item, index) => { + addNode(relation.relatedModelClass, item, { parent: node, relation, index }); + }); + } + } + }; + + asArray(roots).forEach((root, index) => { + addNode(modelClass, root, { relationPath, index: Array.isArray(roots) ? index : null }); + }); + + return nodes; + } + + // Remove the markers from the models, as they're neither written nor + // returned, and return a function that puts them back if the writes fail. + static removeMarkers(nodes) { + const markers = []; + + for (const { modelClass, obj } of nodes) { + for (const prop of [ + modelClass.uidProp, + modelClass.uidRefProp, + modelClass.dbRefProp, + modelClass.graphUnrelateProp, + modelClass.graphDeleteProp, + ]) { + if (Object.hasOwn(obj, prop)) { + markers.push([obj, prop, obj[prop]]); + delete obj[prop]; + } + } + } + + return () => { + for (const [obj, prop, value] of markers) { + obj[prop] = value; + } + }; + } + + // Link the `#ref` and `#ref{}` references of the nodes to the nodes with the + // `#id` that they refer to. + static linkReferences(nodes) { + const nodesByUid = new Map(); + + for (const node of nodes) { + if (node.uid !== undefined) { + nodesByUid.set(node.uid, node); + } + } + + const getNode = (uid) => { + const node = nodesByUid.get(uid); + + if (!node) { + throw new Error(`could not resolve reference ${uid} in a graph.`); + } + + return node; + }; + + for (const node of nodes) { + if (node.isReference) { + node.referencedNode = getNode(node.reference); + node.referencedNode.referencingNodes.push(node); + } + + forEachPropertyReference(node.obj, ({ path, match, uid, refPath, isWholeValue }) => { + const referencedNode = getNode(uid); + node.propertyReferences.push({ path, match, refPath, isWholeValue, referencedNode }); + referencedNode.propertyReferencingNodes.push(node); + }); + } + } +} + +function forEachPropertyReference(obj, callback) { + const modelClass = obj.constructor; + const relationNames = modelClass.getRelationNames(); + + const visit = (value, path) => { + if (Array.isArray(value)) { + value.forEach((item, index) => visit(item, [...path, index])); + } else if (isObject(value) && !Buffer.isBuffer(value)) { + for (const key of Object.keys(value)) { + visit(value[key], [...path, key]); + } + } else if (isString(value)) { + for (const [match, uid, refPath] of value.matchAll(modelClass.propRefRegex)) { + callback({ + path, + match, + uid, + refPath: refPath.trim().split('.'), + isWholeValue: match === value, + }); + } + } + }; + + for (const key of Object.keys(obj)) { + if (!relationNames.includes(key)) { + visit(obj[key], [key]); + } + } +} + +export function createNotModelError(modelClass, value) { + return modelClass.createValidationError({ + type: ValidationErrorType.InvalidGraph, + message: `expected value "${value}" to be an instance of ${modelClass.name}`, + }); +} diff --git a/lib/queryBuilder/graph/GraphNodeDbExistence.js b/lib/queryBuilder/graph/GraphNodeDbExistence.js deleted file mode 100644 index d6f2a20c5..000000000 --- a/lib/queryBuilder/graph/GraphNodeDbExistence.js +++ /dev/null @@ -1,184 +0,0 @@ -'use strict'; - -const { GraphData } = require('./GraphData'); -const { GraphAction } = require('./GraphAction'); -const promiseUtils = require('../../utils/promiseUtils'); - -/** - * This weird little class is responsible for checking (and maintaining information) - * whether certain nodes exist in the database. - * - * Note that this information is only calculated for nodes for which `insertMissing` - * option is true. - */ -class GraphNodeDbExistence { - static createEveryNodeExistsExistence() { - return new GraphNodeDbExistence(new Map()); - } - - /** - * Goes through the graphs and identifies nodes that may not exist in the db - * (nodes that have an id, but are not found in `currentGraph`) and - * creates an instance of GraphNodeDbExistence that can be used to - * synchronously check if a node exist in the database. - */ - static async create({ builder, graph, graphOptions, currentGraph }) { - if (graphOptions.isInsertOnly()) { - // With insertGraph, we never want to do anything but inserts. - return GraphNodeDbExistence.createEveryNodeExistsExistence(); - } - - const graphData = new GraphData({ - graph, - graphOptions, - currentGraph, - // We don't yet have an instance of GraphNodeDbExistence since we are - // creating one. We can (and should) safely use an instance that - // assumes that all nodes exist in the db for the purposes of this - // method. - nodeDbExistence: GraphNodeDbExistence.createEveryNodeExistsExistence(), - }); - - const { mayNotExist, mayNotExistNodes } = createMayNotExistMap(graphData); - - if (mayNotExist.size == 0) { - // Early exit if we found no items for which we should check their - // existence in the db. - return GraphNodeDbExistence.createEveryNodeExistsExistence(); - } - - const dontExist = await createDontExistMap({ builder, mayNotExist, mayNotExistNodes }); - - return new GraphNodeDbExistence(dontExist); - } - - constructor(dontExist) { - this.dontExist = dontExist; - } - - doesNodeExistInDb(node) { - const idMap = this.dontExist.get(node.modelClass); - - if (!idMap) { - return true; - } - - return !idMap.has(node.obj.$idKey()); - } -} - -function createMayNotExistMap(graphData) { - const { graph, currentGraph, graphOptions } = graphData; - - const mayNotExist = new Map(); - const mayNotExistNodes = []; - - for (const node of graph.nodes) { - if ( - // As an optimization, only consider nodes for which `insertMissing` is true. - // We only need the information for those nodes. - graphOptions.shouldInsertMissing(node) && - // Only consider nodes that will be related. We don't consider nodes that - // would get inserted with an id. Those will still result in a unique - // constraint error. - graphOptions.shouldRelate(node, graphData) && - // Relate nodes may not have an id if they are `#ref` nodes. Only consider - // nodes that have an id so that we can check the existence. - node.hasId && - // We can ignore nodes if they are found anywhere in the graph. `shouldRelate` - // only checks if the node is found in the same relation. - !hasNodeById(currentGraph, node) - ) { - if (!mayNotExist.has(node.modelClass)) { - mayNotExist.set(node.modelClass, new Map()); - } - - mayNotExist.get(node.modelClass).set(node.obj.$idKey(), node.obj.$id()); - mayNotExistNodes.push(node); - } - } - - return { - mayNotExist, - mayNotExistNodes, - }; -} - -function hasNodeById(currentGraph, nodeToFind) { - const { modelClass } = nodeToFind; - - const tableToFind = modelClass.getTableName(); - const idProps = modelClass.getIdPropertyArray(); - - return currentGraph.nodes.some((node) => { - return ( - node.modelClass.getTableName() === tableToFind && - idProps.every((idProp) => node.obj[idProp] === nodeToFind.obj[idProp]) - ); - }); -} - -async function createDontExistMap({ builder, mayNotExist, mayNotExistNodes }) { - const dontExist = cloneExistenceMap(mayNotExist); - const existenceCheckQueries = createExistenceCheckQueries({ builder, mayNotExist }); - - const results = await promiseUtils.map(existenceCheckQueries, (builder) => builder.execute(), { - concurrency: GraphAction.getConcurrency(builder, mayNotExistNodes), - }); - - // Remove all items from the mayNotExist map that we have just proven to exist - // by executing the existenceCheckQueries. - for (const modelResult of results) { - for (const item of modelResult) { - const modelClass = item.constructor; - const idMap = dontExist.get(modelClass); - // Exist, remove from the map. - idMap.delete(item.$idKey()); - } - } - - // Now only items that don't exist in the db are left in this map. - return dontExist; -} - -function createExistenceCheckQueries({ builder, mayNotExist }) { - const builders = []; - - for (const [modelClass, idMap] of mayNotExist.entries()) { - const ids = Array.from(idMap.values()); - - // Create one query per model class (table) to fetch the identifiers - // of the nodes that may not exist. These queries should be super fast - // since they come straight from the index. - builders.push( - modelClass - .query() - .childQueryOf(builder, childQueryOptions()) - .findByIds(ids) - .select(modelClass.getIdColumnArray()), - ); - } - - return builders; -} - -function childQueryOptions() { - return { - fork: true, - isInternalQuery: true, - }; -} - -function cloneExistenceMap(exixtenseMap) { - const clone = new Map(exixtenseMap); - - for (const modelClass of clone.keys()) { - clone.set(modelClass, new Map(clone.get(modelClass))); - } - - return clone; -} - -module.exports = { - GraphNodeDbExistence, -}; diff --git a/lib/queryBuilder/graph/GraphOperation.js b/lib/queryBuilder/graph/GraphOperation.js deleted file mode 100644 index abd0fae4b..000000000 --- a/lib/queryBuilder/graph/GraphOperation.js +++ /dev/null @@ -1,27 +0,0 @@ -'use strict'; - -class GraphOperation { - constructor(graphData) { - this.graphData = graphData; - } - - get graph() { - return this.graphData.graph; - } - - get currentGraph() { - return this.graphData.currentGraph; - } - - get graphOptions() { - return this.graphData.graphOptions; - } - - createActions() { - return []; - } -} - -module.exports = { - GraphOperation, -}; diff --git a/lib/queryBuilder/graph/GraphOptions.js b/lib/queryBuilder/graph/GraphOptions.js index 6be8b1fc3..c41c0caec 100644 --- a/lib/queryBuilder/graph/GraphOptions.js +++ b/lib/queryBuilder/graph/GraphOptions.js @@ -1,7 +1,5 @@ -'use strict'; - -const { RelationExpression } = require('../RelationExpression'); -const { isString } = require('../../utils/objectUtils'); +import { RelationExpression } from '../RelationExpression.js'; +import { isString } from '../../utils/objectUtils.js'; const NO_RELATE = 'noRelate'; const NO_UNRELATE = 'noUnrelate'; @@ -18,7 +16,7 @@ const ALLOW_REFS = 'allowRefs'; const ALLOW_MARKERS = 'allowMarkers'; // The options that are matched against the relation path of each node, see -// `_hasOption()`. +// `has()`. const NODE_OPTION_NAMES = [ NO_RELATE, NO_UNRELATE, @@ -33,27 +31,31 @@ const NODE_OPTION_NAMES = [ const OPTION_NAMES = [...NODE_OPTION_NAMES, FETCH_STRATEGY, ALLOW_REFS, ALLOW_MARKERS]; -const FetchStrategy = { +// `insertGraph` never fetches, updates or removes existing rows, so only the +// options about inserting and relating apply to it, and the others are set: +const INSERT_OPTION_NAMES = [RELATE, NO_RELATE, NO_INSERT, ALLOW_REFS]; +const INSERT_ONLY_OPTIONS = { + [NO_UPDATE]: true, + [NO_DELETE]: true, + [NO_UNRELATE]: true, + [INSERT_MISSING]: true, +}; + +export const FetchStrategy = { OnlyIdentifiers: 'OnlyIdentifiers', Everything: 'Everything', OnlyNeeded: 'OnlyNeeded', }; -class GraphOptions { - // `rootRelationPath` is the relation path of the root node of a recursive - // upsert in the original graph, against which the relation paths and - // expressions of the options match. `insertOnly` is only set by - // `insertGraph`, which never fetches, updates or removes existing rows. - constructor(options, rootRelationPath = [], insertOnly = false) { - if (options instanceof GraphOptions) { - this.options = options.options; - this.rootRelationPath = options.rootRelationPath; - this.insertOnly = options.insertOnly; - } else { - this.options = parseRelationExpressions(options); - this.rootRelationPath = rootRelationPath; - this.insertOnly = insertOnly; - } +export class GraphOptions { + // `insertOnly` is only set by `insertGraph`, which never fetches, updates or + // removes existing rows. + constructor(options, insertOnly = false) { + checkForUnknownOptions(options, insertOnly); + this.options = parseRelationExpressions( + insertOnly ? { ...options, ...INSERT_ONLY_OPTIONS } : options, + ); + this.insertOnly = insertOnly; } isFetchStrategy(strategy) { @@ -72,136 +74,15 @@ class GraphOptions { return this.insertOnly; } - // Like `shouldRelate` but ignores settings that explicitly disable relate operations. - shouldRelateIgnoreDisable(node, graphData) { - if (node.toBeUnrelatedOrDeleted) { - return false; - } - - if (node.isReference || node.isDbReference) { - return true; - } - - return ( - this._hasOption(node, RELATE) && - !getCurrentNode(node, graphData) && - !!node.parentEdge && - !!node.parentEdge.relation && - hasRelateProp(node) && - graphData.nodeDbExistence.doesNodeExistInDb(node) - ); - } - - shouldRelate(node, graphData) { - return !this._hasOption(node, NO_RELATE) && this.shouldRelateIgnoreDisable(node, graphData); - } - - // Like `shouldInsert` but ignores settings that explicitly disable insert operations. - shouldInsertIgnoreDisable(node, graphData) { - if (node.toBeUnrelatedOrDeleted) { - return false; - } - - return ( - !getCurrentNode(node, graphData) && - !this.shouldRelateIgnoreDisable(node, graphData) && - (!node.hasId || this.shouldInsertMissing(node)) - ); - } - - shouldInsert(node, graphData) { - return !this._hasOption(node, NO_INSERT) && this.shouldInsertIgnoreDisable(node, graphData); - } - - shouldInsertMissing(node) { - return this._hasOption(node, INSERT_MISSING); - } - - // Like `shouldPatch() || shouldUpdate()` but ignores settings that explicitly disable - // update or patch operations. - shouldPatchOrUpdateIgnoreDisable(node, graphData) { - if (node.toBeUnrelatedOrDeleted) { - return false; - } - - if (this.shouldRelate(node, graphData)) { - // We should update all nodes that are going to be related. Note that - // we don't actually update anything unless there is something to update - // so this is just a preliminary test. - return true; - } - - return !!getCurrentNode(node, graphData); - } - - shouldPatch(node, graphData) { - return ( - this.shouldPatchOrUpdateIgnoreDisable(node, graphData) && - !this._hasOption(node, NO_UPDATE) && - !this._hasOption(node, UPDATE) - ); - } - - shouldUpdate(node, graphData) { - return ( - this.shouldPatchOrUpdateIgnoreDisable(node, graphData) && - !this._hasOption(node, NO_UPDATE) && - this._hasOption(node, UPDATE) - ); - } - - // Like `shouldUnrelate` but ignores settings that explicitly disable unrelate operations. - shouldUnrelateIgnoreDisable(currentNode) { - return this._hasOption(currentNode, UNRELATE); - } - - shouldUnrelate(currentNode, graphData) { - const node = getNode(currentNode, graphData.graph); - if (node && node.toBeUnrelated) return true; - - return ( - !node && - !hasIgnoredOtherModel(currentNode, graphData) && - !this._hasOption(currentNode, NO_UNRELATE) && - (this.shouldUnrelateIgnoreDisable(currentNode) || - // The unrelate replaces a delete, so `noDelete` disables it too. - (!this._hasOption(currentNode, NO_DELETE) && shouldUnrelateKeptRow(currentNode, graphData))) - ); - } - - shouldDelete(currentNode, graphData) { - const node = getNode(currentNode, graphData.graph); - // `#unrelate` takes precedence over `#delete` if both are set. - if (node && node.toBeDeleted && !node.toBeUnrelated) return true; - - return ( - !node && - !hasIgnoredOtherModel(currentNode, graphData) && - !this._hasOption(currentNode, NO_DELETE) && - !this.shouldUnrelateIgnoreDisable(currentNode) && - // Rows that the graph keeps in another place must not be deleted (#214). - !graphData.keptNodeForRow(currentNode) - ); - } - - shouldInsertOrRelate(node, graphData) { - return this.shouldInsert(node, graphData) || this.shouldRelate(node, graphData); + // Whether a node that the graph relates or patches is written with `patch()` + // or `update()`. With `noUpdate`, it's neither, and only the foreign keys of + // its relates and BelongsToOneRelations are patched. + shouldPatch(node) { + return !this.has(node, NO_UPDATE) && !this.has(node, UPDATE); } - // The graph ignores models that it neither inserts, relates nor patches, like - // models marked with `#unrelate` or `#delete` and models skipped by `noInsert` - // or `noRelate`. Inserted models have ids and can look like models to relate - // afterwards, so we check that they were inserted too. - shouldIgnore(node, graphData) { - return ( - !node.userData.inserted && - !this.shouldInsertOrRelate(node, graphData) && - !this.shouldPatchOrUpdateIgnoreDisable(node, graphData) - ); - } - - shouldDeleteOrUnrelate(currentNode, graphData) { - return this.shouldDelete(currentNode, graphData) || this.shouldUnrelate(currentNode, graphData); + shouldUpdate(node) { + return !this.has(node, NO_UPDATE) && this.has(node, UPDATE); } allowRefs() { @@ -212,38 +93,37 @@ class GraphOptions { return !!this.options[ALLOW_MARKERS]; } - // The options of recursive upserts keep matching against the relation paths - // of the original graph, which the relation paths of the new root lead to. - rebasedOptions(newRoot) { - return new GraphOptions(this.options, [...this.rootRelationPath, ...newRoot.relationPath]); + // Whether the option applies to the relation path of `node`. + has(node, optionName) { + const option = this.options[optionName]; + + return isRelationExpression(option) + ? matchesRelationExpression(option, node.relationPath) + : Array.isArray(option) + ? option.includes(node.relationPathKey) + : !!option; } +} - _hasOption(node, optionName) { - const option = this.options[optionName]; +// Reject misspelled options and options of `upsertGraph` passed to +// `insertGraph`, which would otherwise be ignored silently (#84). Options set +// to `undefined` count as not set. +function checkForUnknownOptions(options, insertOnly) { + const [method, optionNames] = insertOnly + ? ['insertGraph', INSERT_OPTION_NAMES] + : ['upsertGraph', OPTION_NAMES]; - if (isRelationExpression(option)) { - return matchesRelationExpression(option, this._relationPath(node)); - } else if (Array.isArray(option)) { - return option.includes(this._relationPath(node).join('.')); - } else if (typeof option === 'boolean') { - return option; - } else if (option === undefined) { - return false; - } else { + for (const [name, value] of Object.entries(options)) { + if (value !== undefined && !optionNames.includes(name)) { throw new Error( - `expected ${optionName} option value "${option}" to be a boolean, an array of relation paths or a relation expression`, + `"${name}" is not an option of ${method}(). Valid options are: ${optionNames.join(', ')}.`, ); } } - - // The relation path of the node in the original graph, see `rebasedOptions()`. - _relationPath(node) { - return [...this.rootRelationPath, ...node.relationPath]; - } } // Parses the string values of the node options into relation expressions, -// once per operation. +// once per operation, and checks the types of the other values. function parseRelationExpressions(options) { let parsedOptions = options; @@ -256,6 +136,10 @@ function parseRelationExpressions(options) { } parsedOptions[optionName] = parseRelationExpression(optionName, option); + } else if (!(option === undefined || typeof option === 'boolean' || Array.isArray(option))) { + throw new Error( + `expected ${optionName} option value "${option}" to be a boolean, an array of relation paths or a relation expression`, + ); } } @@ -288,14 +172,6 @@ function parseRelationExpression(optionName, expression) { throw createError(`aliases like "${alias.$relation} as ${alias.$name}" are not supported`); } - // `*` and `^` only have a meaning at the end of a relation path. Anywhere else - // they are parsed as relation names that would never match. - const token = findNode(parsedExpression.node, (node) => /^(\*|\^\d*)$/.test(node.$relation)); - - if (token) { - throw createError(`"${token.$relation}" is only supported at the end of a relation path`); - } - return parsedExpression; } @@ -342,72 +218,3 @@ function matchesRelationExpression(expression, relationPath) { function isRelationExpression(value) { return !!value && value.isObjectionRelationExpression === true; } - -function getCurrentNode(node, graphData) { - if (!graphData || !node) { - return null; - } - - return graphData.currentGraph.nodeForNode(node); -} - -// For `HasMany` and `HasOne` relations, the relate prop is the id of the -// related model. Use the id state of the input graph, since the parent's -// insert copies the foreign key into the related model, which can complete -// a composite id that contains it and would turn an insert into a relate (#2544). -function hasRelateProp(node) { - const relation = node.parentEdge.relation; - - if (relation.isObjectionHasManyRelation) { - return node.hasId; - } else { - return relation.hasRelateProp(node.obj); - } -} - -// An ignored model that isn't the current model of a BelongsToOneRelation -// doesn't replace it, so we keep the current one instead of removing it as -// missing. -function hasIgnoredOtherModel(currentNode, graphData) { - const { relation } = currentNode.parentEdge; - const parentNode = getNode(currentNode.parentNode, graphData.graph); - - return ( - relation.isObjectionBelongsToOneRelation && - !!parentNode && - parentNode.edges.some( - (edge) => - edge.relation === relation && - edge.isOwnerNode(parentNode) && - graphData.graphOptions.shouldIgnore(edge.relatedNode, graphData), - ) - ); -} - -// A row that the graph keeps in another place is unrelated here instead of -// being deleted, unless it's a has-many row that the graph relates through the -// same relation: That relate moves it by overwriting the foreign key, which -// may not be nullable. -function shouldUnrelateKeptRow(currentNode, graphData) { - const keptNode = graphData.keptNodeForRow(currentNode); - const { relation } = currentNode.parentEdge; - - return ( - !!keptNode && - !(relation.isObjectionHasManyRelation && keptNode.parentEdge?.relation === relation) - ); -} - -function getNode(currentNode, graph) { - if (!graph || !currentNode) { - return null; - } - - return graph.nodeForNode(currentNode); -} - -module.exports = { - GraphOptions, - OPTION_NAMES, - FetchStrategy, -}; diff --git a/lib/queryBuilder/graph/GraphPlan.js b/lib/queryBuilder/graph/GraphPlan.js new file mode 100644 index 000000000..d38986acf --- /dev/null +++ b/lib/queryBuilder/graph/GraphPlan.js @@ -0,0 +1,395 @@ +// What a graph insert or upsert does with each model of the graph and each row +// of the current graph: The rules that decide it, and the pruning of branches +// that nothing is written in. +export class GraphPlan { + constructor({ nodes, graphOptions, keptNodes = new Map() }) { + this.nodes = nodes; + this.currentNodes = []; + this.deferredNodes = new Set(); + // The nodes that earlier passes of the graph decided to keep, by row: + this.earlierKeptNodes = keptNodes; + this.graphOptions = graphOptions; + this.actions = null; + // The decisions only change while the current graph is loaded, and are + // cached once it is, see `cacheDecisions()`: + this._intents = null; + this._nodeActions = null; + this._activeNodes = null; + this._activeCurrentNodes = null; + this._keptNodesByRowKey = null; + } + + get activeNodes() { + return (this._activeNodes ??= this.nodes.filter((node) => !node.isPruned)); + } + + get activeCurrentNodes() { + return (this._activeCurrentNodes ??= this.currentNodes.filter( + (currentNode) => !currentNode.isPruned, + )); + } + + // Leave `nodes` to a later pass of the graph. + defer(nodes) { + this.deferredNodes = new Set(nodes); + this.nodes = this.nodes.filter((node) => !this.deferredNodes.has(node)); + this._activeNodes = null; + } + + cacheDecisions() { + this._intents = new Map(); + this._nodeActions = new Map(); + } + + // What the data asks to do with `node`, before the options that disable it: + // Models marked with `#unrelate` or `#delete` are removed, references and + // models with ids that the options let relate are related, models at their + // place are patched, and new models are inserted. Models with ids that are + // neither are `'missing'`. + intentFor(node) { + let intent = this._intents?.get(node); + + if (!intent) { + intent = this._getIntent(node); + this._intents?.set(node, intent); + } + + return intent; + } + + _getIntent(node) { + const insertMissing = this.graphOptions.has(node, 'insertMissing'); + + return node.toBeUnrelatedOrDeleted + ? 'remove' + : node.isReference + ? 'relate' + : node.isMissing + ? insertMissing + ? 'insert' + : this.findInsertingNode(node) + ? 'relate' + : 'missing' + : node.isDbReference || this._canRelate(node) + ? 'relate' + : node.current + ? 'patch' + : !node.hasId || insertMissing + ? 'insert' + : 'missing'; + } + + // Return true if the graph writes the branch below `node`: below models at + // their place, inserted models, related models with fetched rows and `#ref` + // models, but not below removed or ignored ones. + writesBranchOf(node) { + const action = this.actionFor(node); + + return ( + !node.toBeUnrelatedOrDeleted && + (!!node.current || + action === 'insert' || + (action === 'relate' && (!!node.row || node.isReference))) + ); + } + + // Find the model of the graph that inserts the missing row of `node` with its + // id, so that `node` relates it once it's inserted. Models that are missing + // themselves aren't inserted. + findInsertingNode(node) { + return this.nodes.find( + (other) => + other !== node && + other.rowKey === node.rowKey && + !(other.isMissing && !this.graphOptions.has(other, 'insertMissing')) && + this.actionFor(other) === 'insert', + ); + } + + // What the graph does with `node`: `'insert'`, `'relate'`, `'patch'` or + // `'ignore'`. Once the writes start, read `actions` instead. + actionFor(node) { + let action = this._nodeActions?.get(node); + + if (!action) { + action = this._getAction(node); + this._nodeActions?.set(node, action); + } + + return action; + } + + _getAction(node) { + const { graphOptions } = this; + + switch (this.intentFor(node)) { + case 'insert': + return graphOptions.has(node, 'noInsert') ? 'ignore' : 'insert'; + case 'relate': + return !graphOptions.has(node, 'noRelate') ? 'relate' : node.current ? 'patch' : 'ignore'; + case 'patch': + return 'patch'; + default: + return 'ignore'; + } + } + + // What the graph does with the row of `currentNode`: `'delete'`, + // `'unrelate'` or `'keep'`. + actionForCurrent(currentNode) { + const { node } = currentNode; + const has = (optionName) => this.graphOptions.has(currentNode, optionName); + + if (node) { + // `#unrelate` takes precedence over `#delete` if both are set. + return node.toBeUnrelated ? 'unrelate' : node.toBeDeleted ? 'delete' : 'keep'; + } else if (this._hasIgnoredOtherModel(currentNode)) { + return 'keep'; + } else { + const keptNode = this.keptNodeForRow(currentNode); + + if (keptNode && !this._isUnrelatedWhenKept(currentNode, keptNode)) { + return 'keep'; + } else if (keptNode || has('unrelate')) { + // Rows that the graph keeps in another place must not be deleted + // (#214). They're unrelated instead, which replaces the delete, so + // `noDelete` disables it too, unless the `unrelate` option asks for it. + return has('noUnrelate') || (!has('unrelate') && has('noDelete')) ? 'keep' : 'unrelate'; + } else { + return has('noDelete') ? 'keep' : 'delete'; + } + } + } + + // Leave out the branches that require no writes. We never do anything below + // models that are removed, and we never delete recursively. + prune() { + const isInsertOnly = this.graphOptions.isInsertOnly(); + const passNodes = new Set([...this.nodes, ...this.currentNodes]); + // The rows that were fetched for pruned nodes are left alone too. Nodes of + // later passes aren't pruned here: + const pruneNodes = (nodes) => { + for (const node of nodes.filter((node) => passNodes.has(node))) { + node.isPruned = true; + + for (const row of node.row ? [node.row, ...node.row.descendants] : []) { + row.isPruned = true; + } + } + + this._activeNodes = null; + this._activeCurrentNodes = null; + }; + const pruneDescendants = (nodes) => pruneNodes(nodes.flatMap((node) => node.descendants)); + + // Nothing is written below models that are neither inserted nor at their + // place. `upsertGraph()` upserts the branches below related models, whose + // rows were fetched for that, and below `#ref` models of new models, but + // `insertGraph()` leaves them out (#217). + pruneDescendants( + this.activeNodes.filter( + (node) => + !node.current && + this.intentFor(node) !== 'insert' && + !( + this.actionFor(node) === 'relate' && + (node.row || (node.isReference && !this.graphOptions.isInsertOnly())) + ), + ), + ); + + // Nothing is written below models that `noInsert` skips, as their + // descendants would be orphans (#215), except for the branches of their + // children that the references of written models need: the ones that + // models outside of all these branches point into, and then the ones that + // the models of kept branches point into. The references of ignored models + // are never resolved. + const isSkipped = (node) => + this.intentFor(node) === 'insert' && this.actionFor(node) === 'ignore'; + const isBranch = (node) => !!node.parent && isSkipped(node.parent); + const getBranches = (node) => { + const branches = []; + + for (let ancestor = node; ancestor; ancestor = ancestor.parent) { + if (isBranch(ancestor)) { + branches.push(ancestor); + } + } + + return branches; + }; + const getReferencedNodes = (node) => + this.actionFor(node) === 'ignore' + ? [] + : [node.referencedNode, ...node.propertyReferences.map((ref) => ref.referencedNode)].filter( + (referencedNode) => referencedNode && !referencedNode.isPruned, + ); + + const keptBranches = new Set(); + const visitedNodes = new Set(); + const getNewKeptNodes = () => + this.activeNodes.filter( + (node) => + !visitedNodes.has(node) && getBranches(node).every((branch) => keptBranches.has(branch)), + ); + + for (let nodes = getNewKeptNodes(); nodes.length > 0; nodes = getNewKeptNodes()) { + for (const node of nodes) { + visitedNodes.add(node); + + for (const branch of getReferencedNodes(node).flatMap(getBranches)) { + keptBranches.add(branch); + } + } + } + + pruneNodes( + this.activeNodes + .filter((node) => isBranch(node) && !keptBranches.has(node)) + .flatMap((node) => [node, ...node.descendants]), + ); + + if (!isInsertOnly) { + // Rows that the graph removes are removed without their descendants. Don't + // remove rows in relations that the graph doesn't mention. + const removedNodes = this.activeCurrentNodes.filter((currentNode) => !currentNode.node); + pruneDescendants(removedNodes); + + for (const currentNode of removedNodes) { + const parentNode = currentNode.parent?.node; + + if (parentNode && parentNode.obj[currentNode.relation.name] === undefined) { + currentNode.isPruned = true; + this._activeCurrentNodes = null; + } + } + + // Models marked with `#unrelate` or `#delete` are unrelated or deleted as + // a whole, in the graph and in the current graph. + const markedNodes = this.activeNodes.filter((node) => node.toBeUnrelatedOrDeleted); + pruneDescendants(markedNodes); + pruneDescendants(markedNodes.map((node) => node.current).filter(Boolean)); + } + + return this; + } + + // Decide what to do with each node once, before the first write, since the + // writes change the models that the rules read. Only call this after + // pruning. + plan() { + const actions = new Map(); + + for (const node of this.activeNodes) { + actions.set(node, this.actionFor(node)); + } + + for (const currentNode of this.activeCurrentNodes) { + actions.set(currentNode, this.actionForCurrent(currentNode)); + } + + this.actions = actions; + return this; + } + + _canRelate(node) { + return ( + this.graphOptions.has(node, 'relate') && + !node.current && + !node.isRoot && + hasRelateProp(node) && + !node.isMissing + ); + } + + // An ignored model that isn't the current model of a to-one relation doesn't + // replace it, so we keep the current one instead of removing it as missing. + _hasIgnoredOtherModel(currentNode) { + const { relation } = currentNode; + const parentNode = currentNode.parent?.node; + + return ( + relation.isOneToOne() && + !!parentNode && + parentNode.activeChildren.some( + (child) => child.relation === relation && this.actionFor(child) === 'ignore', + ) + ); + } + + // A row that the graph keeps in another place is unrelated here, unless the + // other place relates or patches it through the same key column: Then that + // place writes the key, which may not be nullable. A has-one row that a + // later pass moves is still unrelated if this pass writes another row into + // its slot, as the foreign key may be unique (#398). + _isUnrelatedWhenKept(currentNode, keptNode) { + const { relation } = currentNode; + return ( + relation.keySide !== 'related' || + !hasSameKey(relation, keptNode.relation) || + (this.deferredNodes.has(keptNode) && this._hasNewRowInSlot(currentNode)) + ); + } + + // Return true if this pass inserts or relates a row into the slot of the + // has-one relation that `currentNode` is in. + _hasNewRowInSlot(currentNode) { + return ( + currentNode.relation.isOneToOne() && + this.nodes.some( + (node) => + node.relation === currentNode.relation && + node.parent.existing === currentNode.parent && + ['insert', 'relate'].includes(this.actionFor(node)), + ) + ); + } + + // Find the node that keeps the row of `currentNode` in another place of the + // graph, by relating or patching it there, also in another pass. + keptNodeForRow(currentNode) { + return this.keptNodes.get(currentNode.rowKey) || null; + } + + // The nodes that keep their rows, by row, also the ones that earlier passes + // decided to keep. + get keptNodes() { + if (!this._keptNodesByRowKey) { + this._keptNodesByRowKey = new Map(this.earlierKeptNodes); + + for (const node of [...this.activeNodes, ...this.deferredNodes]) { + const action = this.actionFor(node); + + if (node.obj.$hasId() && (action === 'relate' || action === 'patch')) { + this._keptNodesByRowKey.set(node.rowKey, node); + } + } + } + + return this._keptNodesByRowKey; + } +} + +// Return true if both relations keep the keys of their related rows in the same +// columns, also the same JSON paths of them. +function hasSameKey(relation, otherRelation) { + const { relatedProp } = relation; + const otherProp = otherRelation?.keySide === 'related' ? otherRelation.relatedProp : null; + return ( + !!otherProp && + relation.relatedModelClass.getTableName() === otherRelation.relatedModelClass.getTableName() && + relatedProp.size === otherProp.size && + relatedProp.props.every( + (_, i) => relatedProp.propDescription(i) === otherProp.propDescription(i), + ) + ); +} + +// For `HasMany` and `HasOne` relations, the relate prop is the id of the +// related model. Use the id state of the input graph, since the parent's +// insert copies the foreign key into the related model, which can complete +// a composite id that contains it and would turn an insert into a relate (#2544). +function hasRelateProp(node) { + const { relation } = node; + return relation.keySide === 'related' ? node.hasId : relation.hasRelateProp(node.obj); +} diff --git a/lib/queryBuilder/graph/GraphUpsert.js b/lib/queryBuilder/graph/GraphUpsert.js index 9d593b9b6..c40fbf5b4 100644 --- a/lib/queryBuilder/graph/GraphUpsert.js +++ b/lib/queryBuilder/graph/GraphUpsert.js @@ -1,30 +1,25 @@ -'use strict'; - -const { ModelGraph } = require('../../model/graph/ModelGraph'); -const { ModelGraphEdge } = require('../../model/graph/ModelGraphEdge'); -const { createNotModelError } = require('../../model/graph/ModelGraphBuilder'); -const { GraphFetcher } = require('../graph/GraphFetcher'); -const { GraphInsert } = require('./insert/GraphInsert'); -const { GraphPatch } = require('./patch/GraphPatch'); -const { GraphDelete, GraphDereferencedBelongsToOneDelete } = require('./delete/GraphDelete'); -const { GraphRecursiveUpsert } = require('./recursiveUpsert/GraphRecursiveUpsert'); -const { GraphOptions, OPTION_NAMES } = require('./GraphOptions'); -const { ValidationErrorType } = require('../../model/ValidationError'); -const { RelationExpression } = require('../RelationExpression'); -const { GraphNodeDbExistence } = require('./GraphNodeDbExistence'); -const { GraphData } = require('./GraphData'); -const { uniqBy, asArray, isObject } = require('../../utils/objectUtils'); -const { warnOnce } = require('../../utils/warnings'); - -class GraphUpsert { +import { GraphNode, createNotModelError } from './GraphNode.js'; +import { GraphOptions } from './GraphOptions.js'; +import { GraphPlan } from './GraphPlan.js'; +import { GraphWriter } from './GraphWriter.js'; +import { loadCurrentGraph } from './GraphLoader.js'; +import { ValidationErrorType } from '../../model/ValidationError.js'; +import { RelationExpression } from '../RelationExpression.js'; +import { asArray, isObject, nonStrictEquals } from '../../utils/objectUtils.js'; +import { isInternalProp } from '../../utils/internalPropUtils.js'; + +export class GraphUpsert { constructor({ rootModelClass, objects, upsertOptions, insertOnly = false }) { - checkCanBeConvertedToModels(rootModelClass, objects); - warnOnUnknownOptions(upsertOptions); + // Wrap `undefined` too, so that it fails the check: + for (const obj of Array.isArray(objects) ? objects : [objects]) { + if (!isObject(obj)) { + throw createNotModelError(rootModelClass, obj); + } + } this.objects = rootModelClass.ensureModelArray(objects, GraphUpsert.modelOptions); this.isArray = Array.isArray(objects); - this.upsertOpt = upsertOptions; - this.insertOnly = insertOnly; + this.graphOptions = new GraphOptions(upsertOptions, insertOnly); } static get modelOptions() { @@ -36,478 +31,539 @@ class GraphUpsert { // to. Even applying it to the root table only would be unsound: rows skipped // by `ignore()` aren't returned, which leaves the root models without ids, // so that related rows would be inserted with missing or wrong foreign keys. - // The clause is ignored, and a warning is logged once per method. - // TODO: Throw an error instead in objection 4.0 (#53). - static warnOnConflict(builder, method) { + static checkForOnConflict(builder, method) { if (builder.has(isOnConflictOperation)) { - warnOnce( + throw new Error( `onConflict(), ignore() and merge() are not supported by ${method}(). ` + - 'Insert the conflicting rows with a separate insert() query instead. ' + - 'This will throw in objection 4.0.', + 'Insert the conflicting rows with a separate insert() query instead.', ); } } - run(builder) { - const modelClass = builder.modelClass(); - const graphOptions = new GraphOptions(this.upsertOpt, [], this.insertOnly); + async run(builder) { + const { graphOptions } = this; + const nodes = GraphNode.buildGraph(builder.modelClass(), this.roots); + GraphNode.linkReferences(nodes); + const restoreMarkers = GraphNode.removeMarkers(nodes); + + // A `#ref` to a model with an id is that model, also when its relations + // are loaded and upserted. Models inserted in a pass have their ids in the + // next ones: + const identifyReferences = (nodes) => { + for (const { obj, referencedNode } of nodes) { + if (referencedNode?.obj.$hasId() && !obj.$hasId()) { + obj.$id(referencedNode.obj.$id()); + } + } + }; - const graph = ModelGraph.create(modelClass, this.objects); - assignDbRefsAsRelateProps(graph); + for (const node of nodes) { + if (node.parent && node.isDbReference) { + node.relation.setRelateProp(node.obj, asArray(node.dbReference)); + } + } - return createGraphData(builder, graphOptions, graph) - .then(checkForErrors(builder)) - .then(pruneGraphs()) - .then(checkForJoinTableRelatedTableErrors(builder)) - .then(executeOperations(builder)) - .then(removeReferenceProps(graph.nodes)) - .then(returnResult(this.objects, this.isArray)); - } -} + identifyReferences(nodes); + + try { + checkGraph(builder, new GraphPlan({ nodes, graphOptions })); + checkPlacements(builder, nodes); + const writtenNodes = new Set(); + let keptNodes = new Map(); + + // Rows that the graph relates into a HasManyRelation or HasOneRelation + // and gives relations are moved first, and their relations are upserted + // in another pass, against their rows as they are after the move (#214). + for (let passNodes = nodes; passNodes.length > 0;) { + const plan = new GraphPlan({ + nodes: passNodes, + graphOptions, + keptNodes, + }); + const movedNodes = graphOptions.isInsertOnly() ? [] : await loadCurrentGraph(builder, plan); + + plan.defer(movedNodes.flatMap((node) => node.descendants)); + plan.cacheDecisions(); + checkMissingModels(builder, plan); + plan.prune().plan(); + checkChangedIds(builder, plan); + // The rows that this pass keeps stay kept in the next ones, as decided + // before its writes: + keptNodes = plan.keptNodes; + + // Models that `noInsert` or `noRelate` ignore aren't written, but the + // ones marked with `#delete` or `#unrelate` keep their values: + for (const node of plan.activeNodes) { + if (plan.actions.get(node) !== 'ignore' || node.toBeUnrelatedOrDeleted) { + writtenNodes.add(node); + } + } -// Misspelled options are silently ignored, so the operation runs with the -// default behaviour instead (#84). A warning is logged once per option. -// TODO: Throw an error instead in objection 4.0 (#53). -function warnOnUnknownOptions(upsertOptions) { - // Recursive upserts pass on the already checked options of their parent. - if (upsertOptions instanceof GraphOptions) { - return; - } + checkReferences(builder, plan, writtenNodes); + checkJoinTables(builder, plan); + await new GraphWriter(plan).write(builder); - for (const name of Object.keys(upsertOptions)) { - if (!OPTION_NAMES.includes(name)) { - warnOnce( - `Unknown graph option "${name}" is ignored. ` + - `Valid options are: ${OPTION_NAMES.join(', ')}. This will throw in objection 4.0.`, - ); + passNodes = movedNodes.flatMap((node) => [node, ...node.descendants]); + identifyReferences(passNodes); + } + } catch (err) { + // The input can be retried as it was: + restoreMarkers(); + throw err; } + + return this.roots; } -} -function checkCanBeConvertedToModels(modelClass, objects) { - asArray(objects).forEach((obj) => { - if (!isObject(obj)) { - throw createNotModelError(modelClass, obj); - } - }); + get roots() { + return this.isArray ? this.objects : this.objects[0]; + } } -function assignDbRefsAsRelateProps(graph) { - for (const node of graph.nodes) { - if (!node.parentEdge || !node.parentEdge.relation || !node.isDbReference) { - continue; +// Check the models with ids that are neither at their place nor related, once +// the current graph of a pass is loaded. +function checkMissingModels(builder, plan) { + for (const node of plan.nodes) { + if (plan.intentFor(node) === 'missing') { + const id = node.obj.$id(); + + throw node.modelClass.createNotFoundError(builder.context(), { + message: node.isMissing + ? `model (id=${id}) does not exist. If you want to insert it with an id, use the insertMissing option` + : node.isRoot + ? `root model (id=${id}) does not exist. If you want to insert it with an id, use the insertMissing option` + : `model (id=${id}) is not a child of model (id=${node.parent.obj.$id()}). If you want to relate it, use the relate option. If you want to insert it with an id, use the insertMissing option`, + dataPath: node.dataPath, + }); } - - node.parentEdge.relation.setRelateProp(node.obj, asArray(node.dbReference)); } } -async function createGraphData(builder, graphOptions, graph) { - const currentGraph = await fetchCurrentGraph(builder, graphOptions, graph); - - const nodeDbExistence = await GraphNodeDbExistence.create({ - builder, - graph, - graphOptions, - currentGraph, - }); +// The graph writes keys of rows that can't change if they're part of the ids +// of the rows: the foreign key of a row that a HasMany or HasOne relate moves +// to another owner, and the foreign key of an existing row that its own +// BelongsToOne relate or insert points to another row, or that `null`, +// `#unrelate` or `#delete` clears. Keys that aren't known before the writes +// are compared only where they surely change, as new models get new keys. +function checkChangedIds(builder, plan) { + const { actions } = plan; + const newKey = {}; + const getKey = (node, prop, i) => + actions.get(node) === 'insert' && !node.obj.$hasId() + ? newKey + : (prop.getProp(node.obj, i) ?? (node.existing && prop.getProp(node.existing.obj, i))); + + const checkKey = (row, relation, rowProp, getNewKey) => { + const idProps = row.modelClass.getIdPropertyArray(); + const isChanged = + actions.get(row) !== 'insert' && + rowProp.props.some((prop, i) => { + const key = row.obj[prop]; + const changedKey = getNewKey(i); + return ( + idProps.includes(prop) && + key !== undefined && + changedKey !== undefined && + (changedKey === newKey || !nonStrictEquals(key, changedKey)) + ); + }); - return new GraphData({ graph, currentGraph, graphOptions, nodeDbExistence }); -} + if (isChanged) { + throw builder.modelClass().createValidationError({ + type: ValidationErrorType.InvalidGraph, + message: `the graph changes the key of the ${row.modelClass.name} at ${formatPath(row.dataPathKey)} through ${relation.name}, which is part of its id`, + }); + } + }; -function fetchCurrentGraph(builder, graphOptions, graph) { - if (graphOptions.isInsertOnly()) { - return Promise.resolve(ModelGraph.createEmpty()); - } else { - return GraphFetcher.fetchCurrentGraph({ builder, graph, graphOptions }); - } -} + for (const node of plan.activeNodes) { + const { relation, parent } = node; + const action = actions.get(node); -// Remove branches from the graph that require no operations. For example -// we never want to do anything for descendant nodes of a node that is -// deleted or unrelated. We never delete recursively. -function pruneGraphs() { - return (graphData) => { - pruneRelatedBranches(graphData); - pruneSkippedBranches(graphData); - - if (!graphData.graphOptions.isInsertOnly()) { - pruneDeletedBranches(graphData); - pruneUnrelatedOrDeletedBranches(graphData); + if (relation?.keySide === 'related' && action === 'relate') { + checkKey(node.referencedNode || node, relation, relation.relatedProp, (i) => + getKey(parent, relation.ownerProp, i), + ); + } else if (relation?.keySide === 'owner' && relation.isOneToOne()) { + if (node.toBeUnrelatedOrDeleted) { + if (node.current) { + checkKey(parent, relation, relation.ownerProp, () => null); + } + } else if (action === 'relate' || action === 'insert') { + checkKey(parent, relation, relation.ownerProp, (i) => + getKey(node.referencedNode || node, relation.relatedProp, i), + ); + } } - return graphData; - }; -} + // `null` clears the key: + const nullNames = node.modelClass.getRelationNames().filter((name) => node.obj[name] === null); -function pruneRelatedBranches(graphData) { - const relateNodes = graphData.graph.nodes.filter((node) => { - return ( - !graphData.currentGraph.nodeForNode(node) && - !graphData.graphOptions.shouldInsertIgnoreDisable(node, graphData) - ); - }); + for (const name of nullNames) { + const nullRelation = node.modelClass.getRelation(name); - removeBranchesFromGraph(findRoots(relateNodes), graphData.graph); + if (nullRelation.keySide === 'owner' && nullRelation.isOneToOne()) { + checkKey(node, nullRelation, nullRelation.ownerProp, () => null); + } + } + } } -// Nothing is written below nodes that `noInsert` skips, as their descendants -// would be orphans (#215). Branches that references from outside point into -// are kept, since the references need their models. -function pruneSkippedBranches(graphData) { - const { graph, graphOptions } = graphData; - - const skippedNodes = graph.nodes.filter((node) => { - return ( - graphOptions.shouldInsertIgnoreDisable(node, graphData) && - !graphOptions.shouldInsert(node, graphData) && - !isReferencedFromOutside(node.descendantRelationNodes) - ); - }); - - removeBranchesFromGraph(findRoots(skippedNodes), graph); -} +// Check the whole graph before anything is written. +function checkGraph(builder, plan) { + const { nodes, graphOptions } = plan; + const modelClass = builder.modelClass(); + const createInvalidGraphError = (message) => + modelClass.createValidationError({ type: ValidationErrorType.InvalidGraph, message }); -function isReferencedFromOutside(nodes) { - const nodeSet = new Set(nodes); + const allowedExpression = builder.allowedGraphExpression(); + const rootObjs = nodes.filter((node) => node.isRoot).map((node) => node.obj); + + if ( + allowedExpression && + !allowedExpression.isSubExpression(RelationExpression.fromModelGraph(rootObjs)) + ) { + throw modelClass.createValidationError({ + type: ValidationErrorType.UnallowedRelation, + message: 'trying to upsert an unallowed relation', + }); + } - return nodes.some((node) => - node.refEdges.some((edge) => edge.isRelatedNode(node) && !nodeSet.has(edge.ownerNode)), - ); -} + if ( + !graphOptions.allowRefs() && + nodes.some((node) => node.isReference || node.propertyReferences.length > 0) + ) { + throw createInvalidGraphError( + '#ref references are not allowed in a graph by default. see the allowRefs insert/upsert graph option', + ); + } -function pruneDeletedBranches(graphData) { - const { graph, currentGraph } = graphData; + // `#unrelate` and `#delete` markers come from the graph data, which may be + // untrusted input. Like `#ref`, they have to be allowed explicitly (#2410). + // Root models can't be marked even with `allowMarkers`, so report that first. + // An insert has nothing to unrelate or delete, so `insertGraph` rejects them. + const markedNode = nodes.find((node) => node.toBeUnrelatedOrDeleted); - const deleteNodes = currentGraph.nodes.filter((currentNode) => !graph.nodeForNode(currentNode)); - const roots = findRoots(deleteNodes); + if (markedNode) { + const { graphUnrelateProp, graphDeleteProp } = markedNode.modelClass; + const markers = `${graphUnrelateProp} and ${graphDeleteProp}`; - // Don't delete relations the current graph doesn't even mention. - // So if the parent node doesn't even have the relation, it's not - // supposed to be deleted. - const rootsNotInRelation = roots.filter((deleteRoot) => { - if (!deleteRoot.parentNode) { - return false; + if (nodes.some((node) => node.isRoot && node.toBeUnrelatedOrDeleted)) { + throw createInvalidGraphError( + `${markers} can only be used for related models, not for root models`, + ); + } else if (!graphOptions.allowMarkers()) { + throw createInvalidGraphError( + graphOptions.isInsertOnly() + ? `${markers} can only be used in upsertGraph, not in insertGraph` + : `${markers} markers are not allowed in a graph by default. see the allowMarkers upsertGraph option`, + ); } + } - const { relation } = deleteRoot.parentEdge; - const parentNode = graph.nodeForNode(deleteRoot.parentNode); + if (graphOptions.isInsertOnly()) { + const relatedNodes = nodes.filter((node) => plan.actionFor(node) === 'relate'); - if (!parentNode) { - return false; + if (relatedNodes.some((node) => node.relation.keySide === 'related')) { + throw new Error( + 'You cannot relate HasManyRelation or HasOneRelation using insertGraph, because those require update operations. Consider using upsertGraph instead.', + ); } - return parentNode.obj[relation.name] === undefined; - }); - - removeBranchesFromGraph(roots, currentGraph); - removeNodesFromGraph(new Set(rootsNotInRelation), currentGraph); -} - -// Models marked with `#unrelate` or `#delete` are unrelated or deleted -// as a whole. Their relations are ignored, both in the upserted graph -// and in the current graph. -function pruneUnrelatedOrDeletedBranches(graphData) { - const { graph, currentGraph } = graphData; - - const roots = findRoots(graph.nodes.filter((node) => node.toBeUnrelatedOrDeleted)); - const currentRoots = roots.map((node) => currentGraph.nodeForNode(node)).filter(Boolean); + // `upsertGraph()` upserts the branches below related models, but + // `insertGraph()` leaves them out, which would drop the new models there + // (#217). Models with ids below related models are taken as fetched models + // with loaded relations, and are ignored. + for (const node of relatedNodes) { + const newNode = node.descendants.find( + (descendant) => !descendant.hasId && plan.actionFor(descendant) === 'insert', + ); - removeBranchesFromGraph(roots, graph); - removeBranchesFromGraph(currentRoots, currentGraph); + if (newNode) { + throw createInvalidGraphError( + `insertGraph cannot insert the new model at ${formatPath(newNode.dataPathKey)} below a related model. Insert it with a separate query or use upsertGraph instead`, + ); + } + } + } } -function findRoots(nodes) { - const nodeSet = new Set(nodes); +// One row can't get two values for one column, and the slot of a +// HasOneRelation can't get two rows. A `#ref` or `#dbRef` model is a place of +// the row that it refers to. The keys that relations give a row are the values +// of the key props across them, or stand for these where the graph doesn't +// give them, e.g. for new models. +function checkPlacements(builder, nodes) { + const newRowIds = new Map(); + const getRowId = (node) => { + const target = node.referencedNode || node; + + if (target.obj.$hasId()) { + return target.rowKey; + } else { + if (!newRowIds.has(target)) { + newRowIds.set(target, `new:${newRowIds.size}`); + } - return uniqBy( - nodes.filter((node) => { - let parentNode = node.parentNode; + return newRowIds.get(target); + } + }; - while (parentNode) { - if (nodeSet.has(parentNode)) { - return false; + // The values that the graph gives each row, by prop, and the model classes + // and relations through which the graph reaches the rows: + const rows = new Map(); + const rowTables = new Map(); + const rowRelations = new Map(); + const setValue = (rowId, name, prop, value, path) => { + const values = rows.get(rowId) || new Map(); + const other = values.get(prop); + rows.set(rowId, values); + + if (!other) { + values.set(prop, { value, path }); + } else if (!nonStrictEquals(other.value, value)) { + throw builder.modelClass().createValidationError({ + type: ValidationErrorType.InvalidGraph, + message: `the graph gives ${prop} of one ${name} two values, at ${formatPath(other.path)} and at ${formatPath(path)}`, + }); + } + }; + const setNodeValue = (node, prop, value, path) => + setValue(getRowId(node), node.modelClass.name, prop, value, path); + + // The join row of the pair of models that `node` and its parent are, the + // same through both relations of the join table: + const getJoinRowId = (node) => + [ + node.relation.joinTable, + ...[ + [node.relation.joinTableOwnerProp.cols, getRowId(node.parent)], + [node.relation.joinTableRelatedProp.cols, getRowId(node)], + ] + .sort(([cols1], [cols2]) => cols1.join().localeCompare(cols2.join())) + .map(([cols, rowId]) => `${cols}=${rowId}`), + ].join(':'); + + const getPath = (node, ...keys) => [node.dataPathKey, ...keys].filter(Boolean).join('.'); + const getRelations = (node) => + node.modelClass + .getRelationNames() + .filter((name) => node.obj[name] !== undefined) + .map((name) => node.modelClass.getRelation(name)); + const isOwnerKey = (relation) => relation.keySide === 'owner' && relation.isOneToOne(); + const isSlot = (relation) => relation.keySide === 'related' && relation.isOneToOne(); + + // The props that the models give, except for the ids and the keys that the + // relations of the same model set, which follow these relations: + for (const node of nodes) { + const { modelClass, obj, relation } = node; + const skippedProps = [ + ...modelClass.getRelationNames(), + ...modelClass.getIdPropertyArray(), + ...(relation?.keySide === 'related' ? relation.relatedProp.props : []), + // The extras of join rows belong to the join rows: + ...(relation?.keySide === 'join' ? relation.joinTableExtras.map((e) => e.aliasProp) : []), + ...getRelations(node) + .filter(isOwnerKey) + .flatMap((relation) => relation.ownerProp.props), + ]; + + const rowId = getRowId(node); + rowTables.set(rowId, modelClass.getTableName()); + rowRelations.set(rowId, (rowRelations.get(rowId) || new Set()).add(relation)); + + if (!node.toBeUnrelatedOrDeleted) { + for (const prop of Object.keys(obj)) { + if (obj[prop] !== undefined && !isInternalProp(prop) && !skippedProps.includes(prop)) { + setNodeValue(node, prop, obj[prop], getPath(node, prop)); } - - parentNode = parentNode.parentNode; } + } - return true; - }), - ); -} - -function removeBranchesFromGraph(branchRoots, graph) { - const nodesToRemove = new Set( - branchRoots.reduce( - (nodesToRemove, node) => [...nodesToRemove, ...node.descendantRelationNodes], - [], - ), - ); - - removeNodesFromGraph(nodesToRemove, graph); -} - -function removeNodesFromGraph(nodesToRemove, graph) { - const edgesToRemove = new Set(); - - for (const node of nodesToRemove) { - for (const edge of node.edges) { - const otherNode = edge.getOtherNode(node); - - if (!nodesToRemove.has(otherNode)) { - otherNode.removeEdge(edge); - edgesToRemove.add(edge); + // A pair of models has one join row, which gets the extras of all places + // of the pair: + if (relation?.keySide === 'join' && !node.toBeUnrelatedOrDeleted) { + for (const { aliasProp } of relation.joinTableExtras) { + if (obj[aliasProp] !== undefined) { + const path = getPath(node, aliasProp); + setValue( + getJoinRowId(node), + `join row of ${relation.name}`, + aliasProp, + obj[aliasProp], + path, + ); + } } } } - graph.nodes = graph.nodes.filter((node) => !nodesToRemove.has(node)); - graph.edges = graph.edges.filter((edge) => !edgesToRemove.has(edge)); - - return graph; -} + // The value of the key prop `i` of `prop` that `node` gives across a + // relation, from wherever the graph gives the row, or one that stands for it: + const getKey = (node, prop, i) => + prop.getProp(node.obj, i) ?? + rows.get(getRowId(node))?.get(prop.props[i])?.value ?? { + row: getRowId(node), + prop: prop.props[i], + }; + + // The keys that the relations give the rows, the rows that the graph puts + // in the slots of HasOneRelations, and whether the rows are deleted: + const slots = []; + for (const node of nodes) { + const { relation, parent } = node; + const path = getPath(node); + const isUnrelated = node.toBeUnrelated; + const isDeleted = node.toBeDeleted && !isUnrelated; + + if (relation?.keySide === 'related') { + if (!isDeleted) { + relation.relatedProp.forEach((i) => { + const key = isUnrelated ? null : getKey(parent, relation.ownerProp, i); + setNodeValue(node, relation.relatedProp.props[i], key, path); + }); + } -function checkForErrors(builder) { - return (graphData) => { - checkForNotFoundErrors(graphData, builder); - checkForUnallowedRelationErrors(graphData, builder); - checkForUnallowedReferenceErrors(graphData, builder); - // Root models can't be marked even with `allowMarkers`, so report that - // first instead of pointing to the option. - checkForRootUnrelateOrDeleteErrors(graphData, builder); - checkForUnallowedMarkerErrors(graphData, builder); - - if (graphData.graphOptions.isInsertOnly()) { - checkForHasManyRelateErrors(graphData); - warnOnInsertBelowRelate(graphData); + if (isSlot(relation)) { + const row = node.toBeUnrelatedOrDeleted ? null : getRowId(node); + slots.push({ owner: parent, relation, row, path }); + } + } else if (relation && isOwnerKey(relation)) { + relation.ownerProp.forEach((i) => { + const key = node.toBeUnrelatedOrDeleted ? null : getKey(node, relation.relatedProp, i); + setNodeValue(parent, relation.ownerProp.props[i], key, path); + }); } - return graphData; - }; -} + setNodeValue(node, '#delete', isDeleted, path); -// Inserting or relating a node of a many-to-many relation inserts a join row, -// which would be a new related row if the join table is also the related -// table (#133). Check this before this graph is written. It runs after -// pruning, as the branches below related nodes are upserted and checked by -// nested `upsertGraph` calls against their own current graphs. -function checkForJoinTableRelatedTableErrors(builder) { - return (graphData) => { - const { graphOptions, currentGraph, graph } = graphData; - - for (const node of graph.nodes) { - const relation = node.parentEdge && node.parentEdge.relation; - - if ( - relation && - relation.isObjectionManyToManyRelation && - !currentGraph.nodeForNode(node) && - graphOptions.shouldInsertOrRelate(node, graphData) - ) { - relation.assertCanModifyJoinTable( - builder, - graphOptions.shouldRelate(node, graphData) ? 'relate' : 'insert', - ); - } + // All places of a pair of models keep or remove its join row: + if (relation?.keySide === 'join') { + const name = `join row of ${relation.name}`; + setValue(getJoinRowId(node), name, '#unrelate', node.toBeUnrelatedOrDeleted, path); } - return graphData; - }; -} - -function checkForNotFoundErrors(graphData, builder) { - const { graphOptions, currentGraph, graph } = graphData; - // The branches below related nodes are upserted and checked by nested - // `upsertGraph` calls, against the current graphs of the related nodes: - const nestedNodes = new Set( - graph.nodes - .filter((node) => graphOptions.shouldRelate(node, graphData)) - .flatMap((node) => node.descendantRelationNodes), - ); - - for (const node of graph.nodes) { - if ( - node.obj.$hasId() && - !nestedNodes.has(node) && - !node.toBeUnrelatedOrDeleted && - !graphOptions.shouldInsertIgnoreDisable(node, graphData) && - !graphOptions.shouldRelateIgnoreDisable(node, graphData) && - !currentGraph.nodeForNode(node) - ) { - if (!node.parentNode) { - throw node.modelClass.createNotFoundError(builder.context(), { - message: `root model (id=${node.obj.$id()}) does not exist. If you want to insert it with an id, use the insertMissing option`, - dataPath: node.dataPath, - }); - } else { - throw node.modelClass.createNotFoundError(builder.context(), { - message: `model (id=${node.obj.$id()}) is not a child of model (id=${node.parentNode.obj.$id()}). If you want to relate it, use the relate option. If you want to insert it with an id, use the insertMissing option`, - dataPath: node.dataPath, + // `null` clears the key or the slot: + for (const nullRelation of getRelations(node).filter(({ name }) => node.obj[name] === null)) { + if (isOwnerKey(nullRelation)) { + nullRelation.ownerProp.forEach((i) => { + setNodeValue( + node, + nullRelation.ownerProp.props[i], + null, + getPath(node, nullRelation.name), + ); }); + } else if (isSlot(nullRelation)) { + const path = getPath(node, nullRelation.name); + slots.push({ owner: node, relation: nullRelation, row: null, path }); } } } -} -function checkForUnallowedRelationErrors(graphData, builder) { - const { graph } = graphData; - const allowedExpression = builder.allowedGraphExpression(); + // The rows of a table by the values that the graph gives the props of a key, + // with the values that stand for keys as they are and the others as strings, + // so that e.g. `1` and `'1'` are the same key: + const toIndexKey = (values) => + JSON.stringify(values.map((value) => (isObject(value) ? value : String(value)))); + const indexes = new Map(); + const getRowsByKey = (table, props) => { + const indexName = `${table}:${props.join()}`; + let index = indexes.get(indexName); + + if (!index) { + index = new Map(); + indexes.set(indexName, index); + + for (const [rowId, values] of rows) { + const keyValues = props.map((prop) => values.get(prop)?.value); + + if (rowTables.get(rowId) === table && keyValues.every((value) => value != null)) { + const indexKey = toIndexKey(keyValues); + const rowIds = index.get(indexKey); + + if (rowIds) { + rowIds.push(rowId); + } else { + index.set(indexKey, [rowId]); + } + } + } + } + + return index; + }; - if (allowedExpression) { - const rootsObjs = graph.nodes.filter((node) => !node.parentEdge).map((node) => node.obj); - const expression = RelationExpression.fromModelGraph(rootsObjs); + // Any other row that the graph gives the key of the owner of a slot is a + // second row in it, unless the relation filters its rows and the graph + // doesn't reach the row through it: + for (const { owner, relation, row, path } of slots) { + const { relatedModelClass, relatedProp, ownerProp } = relation; + const ownerKey = ownerProp.props.map((_, i) => getKey(owner, ownerProp, i)); + const rowsByKey = getRowsByKey(relatedModelClass.getTableName(), relatedProp.props); + const otherRowId = (rowsByKey.get(toIndexKey(ownerKey)) || []).find( + (rowId) => rowId !== row && (!relation.modify || rowRelations.get(rowId).has(relation)), + ); - if (!allowedExpression.isSubExpression(expression)) { + if (otherRowId) { + const other = rows.get(otherRowId).get(relatedProp.props[0]); throw builder.modelClass().createValidationError({ - type: ValidationErrorType.UnallowedRelation, - message: 'trying to upsert an unallowed relation', + type: ValidationErrorType.InvalidGraph, + message: `the graph puts two ${relatedModelClass.name} rows in ${relation.name} of one ${owner.modelClass.name}, at ${formatPath(path)} and at ${formatPath(other.path)}`, }); } } } -function checkForUnallowedReferenceErrors(graphData, builder) { - const { graph, graphOptions } = graphData; - - if (graphOptions.allowRefs()) { - return; - } - - if (graph.edges.some((edge) => edge.type === ModelGraphEdge.Type.Reference)) { - throw builder.modelClass().createValidationError({ - type: ValidationErrorType.InvalidGraph, - message: - '#ref references are not allowed in a graph by default. see the allowRefs insert/upsert graph option', - }); - } -} - -// `#unrelate` and `#delete` markers come from the graph data, which may be -// untrusted input. Like `#ref`, they have to be allowed explicitly (#2410). -// An insert has nothing to unrelate or delete, so `insertGraph` rejects them. -function checkForUnallowedMarkerErrors(graphData, builder) { - const { graph, graphOptions } = graphData; - const isInsertOnly = graphOptions.isInsertOnly(); - - if (graphOptions.allowMarkers() && !isInsertOnly) { - return; - } - - const node = graph.nodes.find((node) => node.toBeUnrelatedOrDeleted); +// References need the ids of the models they point to, which the database +// only gives the models that the graph writes in the same pass or an earlier +// one. The ids of new models that the graph doesn't write, e.g. as `noInsert` +// skips them, are never known. Everything else reads the models as the graph +// gives them. The references of models that aren't written are never resolved. +function checkReferences(builder, plan, writtenNodes) { + const isReadable = (node, refPath) => + writtenNodes.has(node) || + node.obj.$hasId() || + (!!refPath && !node.modelClass.getIdPropertyArray().includes(refPath[0])); - if (node) { - const { graphUnrelateProp, graphDeleteProp } = node.modelClass; - - throw builder.modelClass().createValidationError({ - type: ValidationErrorType.InvalidGraph, - message: isInsertOnly - ? `${graphUnrelateProp} and ${graphDeleteProp} can only be used in upsertGraph, not in insertGraph` - : `${graphUnrelateProp} and ${graphDeleteProp} markers are not allowed in a graph by default. see the allowMarkers upsertGraph option`, - }); - } -} - -function checkForRootUnrelateOrDeleteErrors(graphData, builder) { - for (const node of graphData.graph.nodes) { - if (!node.parentEdge && node.toBeUnrelatedOrDeleted) { - const { graphUnrelateProp, graphDeleteProp } = node.modelClass; + for (const node of plan.activeNodes.filter((node) => writtenNodes.has(node))) { + const { referencedNode, propertyReferences } = node; + if ( + (referencedNode && !isReadable(referencedNode)) || + propertyReferences.some(({ referencedNode, refPath }) => !isReadable(referencedNode, refPath)) + ) { throw builder.modelClass().createValidationError({ type: ValidationErrorType.InvalidGraph, - message: `${graphUnrelateProp} and ${graphDeleteProp} can only be used for related models, not for root models`, + message: `the reference at ${formatPath(node.dataPathKey)} points to a model that the graph doesn't write before it`, }); } } } -function checkForHasManyRelateErrors(graphData) { - const { graph, graphOptions } = graphData; +// Inserting or relating a model of a many-to-many relation inserts a join row, +// which would be a new related row if the join table is also the related table +// (#133). +function checkJoinTables(builder, plan) { + for (const node of plan.activeNodes) { + const action = plan.actions.get(node); - for (const node of graph.nodes) { if ( - graphOptions.shouldRelate(node, graphData) && - node.parentEdge.relation.isObjectionHasManyRelation + node.relation?.keySide === 'join' && + !node.current && + (action === 'insert' || action === 'relate') ) { - throw new Error( - 'You cannot relate HasManyRelation or HasOneRelation using insertGraph, because those require update operations. Consider using upsertGraph instead.', - ); + node.relation.assertCanModifyJoinTable(builder, action); } } } -// `upsertGraph` upserts the branches below related models recursively, but -// `insertGraph` prunes them, which drops the models that would be inserted -// there (#217). A warning is logged once. -// TODO: Throw an error instead in objection 4.0 (#53). -function warnOnInsertBelowRelate(graphData) { - const { graph, graphOptions } = graphData; - - const hasInsertBelowRelate = graph.nodes.some( - (node) => - graphOptions.shouldRelate(node, graphData) && - node.descendantRelationNodes.some((descendantNode) => - graphOptions.shouldInsert(descendantNode, graphData), - ), - ); - - if (hasInsertBelowRelate) { - warnOnce( - 'insertGraph() ignores new models below related models. ' + - 'Insert them with a separate query or use upsertGraph() instead. ' + - 'This will throw in objection 4.0.', - ); - } -} - -function executeOperations(builder) { - return (graphData) => { - const operations = graphData.graphOptions.isInsertOnly() - ? [GraphInsert] - : [ - GraphDelete, - GraphInsert, - GraphPatch, - GraphDereferencedBelongsToOneDelete, - GraphRecursiveUpsert, - ]; - - return operations.reduce((promise, Operation) => { - const operation = new Operation(graphData); - const actions = operation.createActions(); - - return promise.then(() => executeActions(builder, actions)); - }, Promise.resolve()); - }; -} - -function executeActions(builder, actions) { - return actions.reduce( - (promise, action) => promise.then(() => action.run(builder)), - Promise.resolve(), - ); +// Name the place of a model in error messages, by its data path. +function formatPath(dataPathKey) { + const path = dataPathKey.replace(/^\./, ''); + return path ? `'${path}'` : 'the root'; } -// The reference markers only describe the input graph, don't return them. -// `nodes` are the nodes before pruning, since the results of pruned branches -// contain markers too. -function removeReferenceProps(nodes) { - return () => { - for (const node of nodes) { - delete node.obj[node.modelClass.uidRefProp]; - delete node.obj[node.modelClass.dbRefProp]; - } - }; +function isOnConflictOperation(operation) { + return ['onConflict', 'ignore', 'merge'].includes(operation.name); } - -function isOnConflictOperation(op) { - return op.name === 'onConflict' || op.name === 'ignore' || op.name === 'merge'; -} - -function returnResult(objects, isArray) { - return () => (isArray ? objects : objects[0]); -} - -module.exports = { - GraphUpsert, -}; diff --git a/lib/queryBuilder/graph/GraphWriter.js b/lib/queryBuilder/graph/GraphWriter.js new file mode 100644 index 000000000..d92ec016e --- /dev/null +++ b/lib/queryBuilder/graph/GraphWriter.js @@ -0,0 +1,1263 @@ +import { getModel } from '../../model/getModel.js'; +import { restoreOmitFromDatabaseJson } from '../../model/modelUtils.js'; +import { UpdateOperation } from '../operations/UpdateOperation.js'; +import { getMaxRows, hasBatchInsert } from '../../utils/knexUtils.js'; +import { isInternalProp } from '../../utils/internalPropUtils.js'; +import { + chunk, + difference, + get, + groupBy, + isObject, + jsonEquals, + nonStrictEquals, + set, +} from '../../utils/objectUtils.js'; +import { map as promiseMap } from '../../utils/promiseUtils/index.js'; + +const MAX_INSERT_BATCH_SIZE = 100; +const MAX_CONCURRENCY = 100; + +// Write a planned graph in steps, in an order that keeps keys and constraints +// intact (#214): +// 1. Move the rows that the graph relates to existing owners of HasMany and +// HasOne relations, as they may still sit below rows that the next step +// deletes. Rows that wait for inserts are related in step 3 or 4. +// 2. Remove rows that reference their owners, before the inserts that may need +// their unique keys. +// 3. Insert the new rows in the order of their keys and references, then the +// keys deferred to break cycles and the join rows. Rows that move out of +// the slots of HasOneRelations that new rows go into are related in the +// same order, as the foreign key may be unique. +// 4. Patch the changed rows, the other relates, and the keys of owners whose +// BelongsToOne rows were inserted, related or removed. +// 5. Remove the BelongsToOne rows that the owners no longer reference. +// Each step writes a row that goes into the slot of a HasOneRelation after the +// row that moves out of it, unless that closes a cycle. +// `insertGraph()` only inserts. +export class GraphWriter { + constructor(plan) { + this.plan = plan; + // The nodes that each node waits for, through its keys, its references and + // the rows that move out of the slots that it goes into: + this.dependencies = createDependencies(plan); + // Dependencies that aren't waited for, to break cycles. Deferred keys are + // written after the inserts: + this.deferredDependencies = new Set(); + // Relates whose rows are moved to their new owners before the removals: + this.movedNodes = new Set( + plan.graphOptions.isInsertOnly() + ? [] + : plan.activeNodes.filter((node) => this._isMovedFirst(node)), + ); + // Relates that move the rows out of the slots of HasOneRelations that new + // rows go into, and that aren't moved first: + this.slotMovedNodes = this._getSlotMovedNodes(); + this.steps = this._planSteps(); + } + + async write(builder) { + // Fill in the keys that existing models leave out, before new children read them: + for (const node of this.plan.activeNodes) { + copyKeysFromExisting(node, this.plan.actions); + } + + for (const step of this.steps) { + await this._writeStep(builder, step); + } + + // `#ref` models return the props of the models they refer to: + for (const node of this.plan.activeNodes) { + resolveReference(node); + } + } + + _planSteps() { + const { activeNodes, activeCurrentNodes, actions, graphOptions } = this.plan; + const insertSteps = [ + ...this._planLevels(activeNodes, { resolvesReferences: true }).map((level) => ({ + kind: 'insert', + ...level, + })), + { + kind: 'deferredKeys', + dependencies: [...this.deferredDependencies].filter((dependency) => dependency.relation), + }, + { + kind: 'joinRows', + nodes: activeNodes.filter( + (node) => + node.relation?.keySide === 'join' && + !node.current && + ['insert', 'relate'].includes(actions.get(node)) && + // Models that `noInsert` skips have no key for join rows (#369): + actions.get(node.parent) !== 'ignore', + ), + }, + ]; + + if (graphOptions.isInsertOnly()) { + return insertSteps; + } else { + const removedNodes = activeCurrentNodes.filter((node) => actions.get(node) !== 'keep'); + const isReferencedByOwner = (currentNode) => currentNode.relation.keySide === 'owner'; + + return [ + ...this._planLevels([...this.movedNodes]).map(({ nodes }) => ({ kind: 'move', nodes })), + { + kind: 'remove', + nodes: removedNodes.filter((node) => !isReferencedByOwner(node)), + areReferenced: true, + }, + ...insertSteps, + ...this._planLevels( + activeNodes.filter((node) => { + const action = actions.get(node); + return ( + (action === 'patch' || + (action === 'relate' && + !this.movedNodes.has(node) && + !this.slotMovedNodes.has(node))) && + // The row of a `#ref` model is written once, by its model: + !this._isWrittenByModel(node) + ); + }), + ).map(({ nodes }) => ({ kind: 'patch', nodes })), + { + kind: 'remove', + // Owners unrelate their BelongsToOne rows by patching their keys: + nodes: removedNodes.filter( + (node) => isReferencedByOwner(node) && actions.get(node) === 'delete', + ), + areReferenced: false, + }, + ]; + } + } + + _writeStep(builder, step) { + switch (step.kind) { + case 'move': + return this._move(builder, step.nodes); + case 'remove': + return this._remove(builder, step.nodes, step.areReferenced); + case 'insert': + return this._insert(builder, step.nodes, step.references); + case 'deferredKeys': + return this._writeDeferredKeys(builder, step.dependencies); + case 'joinRows': + return this._insertJoinRows(builder, step.nodes); + case 'patch': + return this._patch(builder, step.nodes); + } + } + + // Group the nodes into levels that can be written once the levels before + // them are: Nodes wait for the nodes whose keys they store, the nodes that + // they reference, and the rows that move out of the slots they go into. For + // the inserts, all nodes take part, so that the keys of the nodes that aren't + // inserted are resolved in order too, and `resolvesReferences` says so. + _planLevels(nodes, { resolvesReferences = false } = {}) { + const nodeSet = new Set(nodes); + const handledNodes = new Set(); + const levels = []; + + // The keys of models with rows are known, also of related models that were + // fetched, except for the props that the graph fills in: + const hasRow = (node) => !!(node.referencedNode || node).existing; + // Nodes outside this pass were written by an earlier one, or are never + // written, see `checkReferences()`: + const getPendingDependencies = (node) => + (this.dependencies.get(node) || []).filter( + (dependency) => + nodeSet.has(dependency.node) && + !this.deferredDependencies.has(dependency) && + !handledNodes.has(dependency.node) && + (dependency.isSlotMove || !hasRow(dependency.node) || this._readsFilledProps(dependency)), + ); + + // Return true if `node` waits for `blockingNode`, through other nodes too. + const isBlockedBy = (node, blockingNode, visitedNodes = new Set()) => { + if (node === blockingNode) { + return true; + } else if (visitedNodes.has(node)) { + return false; + } else { + visitedNodes.add(node); + return getPendingDependencies(node).some((dependency) => + isBlockedBy(dependency.node, blockingNode, visitedNodes), + ); + } + }; + + // Break a cycle by deferring one BelongsToOne key that points to a `#ref` + // model: Its owner is inserted without the key, which is patched in once + // the referenced model is inserted. Without `#ref`, the graph is a tree, + // and only waits for the rows that move out of slots can close a cycle, + // like rows that swap slots. One of these isn't waited for then. + const breakCycle = () => { + for (const canDefer of [ + (dependency, node) => this._canDefer(dependency, node), + (dependency) => dependency.isSlotMove, + ]) { + for (const node of nodes) { + if (!handledNodes.has(node)) { + for (const dependency of getPendingDependencies(node)) { + if (canDefer(dependency, node) && isBlockedBy(dependency.node, node)) { + this.deferredDependencies.add(dependency); + return true; + } + } + } + } + } + + return false; + }; + + const isReady = (node) => !handledNodes.has(node) && getPendingDependencies(node).length === 0; + const isReadyReference = (node) => resolvesReferences && node.isReference && isReady(node); + + while (handledNodes.size < nodes.length) { + const levelNodes = nodes.filter(isReady); + + if (levelNodes.length > 0) { + const level = { nodes: levelNodes, references: [] }; + levels.push(level); + + for (const node of levelNodes) { + handledNodes.add(node); + } + + // `#ref` models aren't inserted. They're resolved right after the + // inserts of the level that completes their dependencies, so that the + // nodes that wait for them can be inserted in the next level (#311). + // The other steps write them in levels of their own. + for ( + let references = nodes.filter(isReadyReference); + references.length > 0; + references = nodes.filter(isReadyReference) + ) { + for (const node of references) { + handledNodes.add(node); + level.references.push(node); + } + } + } else if (!breakCycle()) { + throw new Error('the object graph contains cyclic references'); + } + } + + return levels; + } + + _canDefer(dependency, node) { + const { actions } = this.plan; + const { relation } = dependency; + + return ( + relation && + hasOwnerKey(relation) && + dependency.node.isReference && + // The owner is patched by its id, which models without one don't have (#2305). + node.modelClass.getIdColumnArray().length > 0 && + // Both ends need to be inserted for the key to be patched in later. + actions.get(node) === 'insert' && + actions.get(dependency.node.referencedNode) === 'insert' && + // A `#ref{}` that reads the key might be resolved before it's patched in. + !node.propertyReferencingNodes.some((referencingNode) => + referencingNode.propertyReferences.some( + (reference) => + reference.referencedNode === node && + relation.ownerProp.props.includes(reference.refPath[0]), + ), + ) + ); + } + + // Return true if the HasMany or HasOne relate of `node` moves its row to an + // existing owner before the removals, see the steps above. `#ref` models may + // get their ids from the inserts, rows that wait for the inserts are written + // once they're known, and a relate into the slot of a HasOne row waits for + // the removal of that row, or its move, as its key may be unique. + _isMovedFirst(node, visitedNodes = new Set()) { + const { actions } = this.plan; + visitedNodes.add(node); + + if ( + actions.get(node) === 'relate' && + !node.isReference && + node.relation.keySide === 'related' && + node.parent.existing && + !this._waitsForInserts(node) + ) { + const slotNode = getSlotNode(node); + const moverNode = getSlotMover(this.plan, node); + return ( + !(slotNode && actions.get(slotNode) !== 'keep') && + // Swapped rows are related together: + (!moverNode || visitedNodes.has(moverNode) || this._isMovedFirst(moverNode, visitedNodes)) + ); + } else { + return false; + } + } + + // Find the relates that move the rows out of the slots of HasOneRelations + // that new rows go into, and that aren't moved first. They're related in the + // inserts, along with the relates that move the rows out of their own slots, + // as the foreign key may be unique. + _getSlotMovedNodes() { + const nodes = new Set(); + const addMoverNodes = (node) => { + for (const { node: moverNode, isSlotMove } of this.dependencies.get(node) || []) { + if (isSlotMove && !this.movedNodes.has(moverNode) && !nodes.has(moverNode)) { + nodes.add(moverNode); + addMoverNodes(moverNode); + } + } + }; + + for (const node of this.plan.activeNodes) { + if (this.plan.actions.get(node) === 'insert') { + addMoverNodes(node); + } + } + + return nodes; + } + + // Return the props of `node` that the graph fills in from other models, + // through its relations and its references. + _getFilledProps(node) { + return (this.dependencies.get(node) || []).flatMap((dependency) => + dependency.reference + ? [dependency.reference.path[0]] + : dependency.relation + ? getKeyProps(dependency, node) + : [], + ); + } + + // Return true if `dependency` reads props that the graph fills into the + // model it depends on: the keys of a relation, or the value of a `#ref{}`. + // The other props of existing models are known right away, but these only + // once they're filled in (#356). A `#ref` model is the model it refers to, + // so its relations wait for the props filled into that model. + _readsFilledProps(dependency) { + const { node, relation, reference } = dependency; + const filledProps = this._getFilledProps(node.referencedNode || node); + const readProps = relation + ? getKeyProps(dependency, node) + : reference + ? [reference.refPath[0]] + : []; + return readProps.some((prop) => filledProps.includes(prop)); + } + + // Return true if `node` waits for the keys or references of inserted models, + // also through the models that it references (#356), and through the models + // whose keys it reads once the graph fills them in. + _waitsForInserts(node, visitedNodes = new Set()) { + const { actions } = this.plan; + visitedNodes.add(node); + return (this.dependencies.get(node) || []).some((dependency) => { + const { node: dependencyNode, relation } = dependency; + const referencedNode = dependencyNode.referencedNode || dependencyNode; + return ( + actions.get(referencedNode) === 'insert' || + ((!relation || this._readsFilledProps(dependency)) && + !visitedNodes.has(referencedNode) && + this._waitsForInserts(referencedNode, visitedNodes)) + ); + }); + } + + // Return true if the model of the `#ref` model `node` writes what `node` + // would write to the row: The model is inserted, or it's written and its path + // allows any write that the path of `node` allows. + _isCoveredByModel(node) { + const { actions, graphOptions } = this.plan; + const { referencedNode } = node; + const action = actions.get(referencedNode); + const allowsWrite = (node) => graphOptions.shouldPatch(node) || graphOptions.shouldUpdate(node); + return ( + action === 'insert' || + (['relate', 'patch'].includes(action) && (allowsWrite(referencedNode) || !allowsWrite(node))) + ); + } + + // Return true if `node` is a `#ref` model whose model writes all that it would + // write, as its place keeps nothing of its own: It's a BelongsToOne model, or + // it's placed where its model is. Many-to-many models write their join rows. + _isWrittenByModel(node) { + const { referencedNode, relation, parent } = node; + return ( + !!referencedNode && + (relation.keySide === 'owner' || + (relation.keySide === 'related' && + referencedNode.relation === relation && + referencedNode.parent === parent)) && + this._isCoveredByModel(node) + ); + } + + _move(builder, nodes) { + for (const node of nodes) { + this._resolveFilledDependencies(node); + } + + return this._patch(builder, nodes); + } + + // Copy the keys and `#ref{}` values that `node` waits for into its model, + // after those that the graph fills into the models that it reads them from, + // e.g. the new key of an owner whose BelongsToOne row is replaced. Rows that + // move first only wait for rows that exist, see `_waitsForInserts()`. + _resolveFilledDependencies(node, visitedNodes = new Set()) { + visitedNodes.add(node); + + for (const dependency of this.dependencies.get(node) || []) { + const referencedNode = dependency.node.referencedNode || dependency.node; + + if (!visitedNodes.has(referencedNode) && this._readsFilledProps(dependency)) { + this._resolveFilledDependencies(referencedNode, visitedNodes); + } + } + + this._resolveDependencies(node); + } + + _remove(builder, nodes, areReferenced) { + const { actions } = this.plan; + const queries = []; + + for (const [relation, relationNodes] of groupBy(nodes, (node) => node.relation)) { + // Each row binds its id, and the owner keys of its owner at most. + const maxRows = getMaxRows( + builder.unsafeKnex(), + relation.relatedModelClass.getIdColumnArray().length + relation.ownerProp.size, + ); + + for (const [action, actionNodes] of groupBy(relationNodes, (node) => actions.get(node))) { + // One query would match the ids with all owners, so join rows are + // removed per owner, and so are unrelated rows, which may have moved + // from one owner to another before (#214). + const groups = + relation.keySide === 'join' || action === 'unrelate' + ? [...groupBy(actionNodes, (node) => node.parent).values()] + : [actionNodes]; + + for (const groupNodes of groups.flatMap((nodes) => chunk(nodes, maxRows))) { + // Rows that reference their owners are found through the relation. + // BelongsToOne rows aren't referenced anymore once their owners are + // patched. + const owners = [...new Set(groupNodes.map((node) => node.parent.obj))]; + const query = ( + areReferenced + ? relation.ownerModelClass.relatedQuery(relation.name).for(owners) + : relation.relatedModelClass.query() + ) + .childQueryOf(builder) + .findByIds(groupNodes.map((node) => node.obj.$id())); + + queries.push(action === 'unrelate' ? query.unrelate() : query.delete()); + } + } + } + + return promiseMap(queries, (query) => query.execute(), { + concurrency: getConcurrency(builder, nodes), + }); + } + + async _insert(builder, nodes, references) { + const batchSize = getBatchSize(builder); + const batches = [...groupBy(nodes, (node) => node.modelClass).values()].flatMap((nodes) => + chunk(nodes, batchSize), + ); + + await promiseMap(batches, (batch) => this._insertBatch(builder, batch), { + concurrency: getConcurrency(builder, nodes), + }); + await this._patch( + builder, + nodes.filter((node) => this.slotMovedNodes.has(node)), + ); + + for (const node of references) { + this._resolveDependencies(node); + } + } + + // Copy the keys and `#ref{}` values that `node` waits for into its model. + _resolveDependencies(node) { + for (const dependency of this.dependencies.get(node) || []) { + if (!this.deferredDependencies.has(dependency)) { + if (dependency.relation) { + resolveKeys(dependency.related); + } else if (dependency.reference) { + resolvePropertyReference(dependency.dependent, dependency.reference); + } + } + } + } + + async _insertBatch(builder, nodes) { + const { actions } = this.plan; + + for (const node of nodes) { + this._resolveDependencies(node); + + // Join table extras are written to the join rows. + if (node.relation?.keySide === 'join') { + node.relation.omitExtraProps([node.obj]); + } + + if (actions.get(node) === 'insert') { + this._setKeyArrays(node); + } + } + + const insertNodes = nodes.filter((node) => actions.get(node) === 'insert'); + + if (insertNodes.length > 0) { + const [{ modelClass }] = insertNodes; + + for (const node of insertNodes) { + node.obj.$validate(null, { dataPath: node.dataPathKey }); + } + + await Promise.all( + insertNodes.map( + (node) => + node.parent && node.relation.beforeInsert(node.obj, builder.context(), node.parent.obj), + ), + ); + + await modelClass + .query() + .insert(insertNodes.map((node) => node.obj)) + .childQueryOf(builder) + .copyFrom(builder, isReturningAll) + .execute(); + + await fetchGeneratedKeys(builder, modelClass, insertNodes); + } + } + + _writeDeferredKeys(builder, dependencies) { + const owners = groupBy(dependencies, (dependency) => dependency.dependent); + + return promiseMap( + [...owners], + ([owner, dependencies]) => { + const { modelClass } = owner; + const idProp = modelClass.getIdRelationProperty(); + const patch = {}; + + for (const { relation, related } of dependencies) { + resolveKeys(related); + + relation.ownerProp.forEach((i) => { + relation.ownerProp.patch(patch, i, relation.ownerProp.getProp(owner.obj, i)); + }); + } + + return modelClass + .query() + .childQueryOf(builder) + .patch(patch) + .whereInComposite(idProp.refs(builder), [idProp.getProps(owner.obj)]); + }, + { concurrency: getConcurrency(builder, [...owners.keys()]) }, + ); + } + + async _insertJoinRows(builder, nodes) { + const knex = builder.unsafeKnex(); + const inserts = []; + + for (const [joinModelClass, joinNodes] of groupBy(nodes, (node) => + node.relation.getJoinModelClass(knex), + )) { + // Each pair of models has one join row, which gets the extras of all + // places of the pair: + const joinRows = new Map(); + + for (const node of joinNodes) { + const { relation, parent } = node; + resolveReference(node); + + const key = `${relation.name}:${relation.ownerProp.propKey(parent.obj)}:${relation.relatedProp.propKey(node.obj)}`; + const row = relation.createJoinModel(relation.ownerProp.getProps(parent.obj), node.obj); + joinRows.set(key, { relation, row: { ...row, ...joinRows.get(key)?.row } }); + } + + const objs = await Promise.all( + [...joinRows.values()].map(async ({ relation, row }) => { + const obj = joinModelClass.fromJson(row); + await relation.joinTableBeforeInsert(obj, builder.context()); + return obj; + }), + ); + + // Join rows that have their ids can be inserted in batches everywhere. + const batchSize = objs.every((obj) => obj.$hasId()) + ? MAX_INSERT_BATCH_SIZE + : getBatchSize(builder); + + for (const batch of chunk(objs, batchSize)) { + inserts.push(() => joinModelClass.query().childQueryOf(builder).insert(batch)); + } + } + + return promiseMap(inserts, (insert) => insert(), { + concurrency: getConcurrency(builder, nodes), + }); + } + + async _patch(builder, nodes) { + // The patches leave out the props that don't change, but the models that + // the graph returns get them back once the step is written (#371): + const omittedProps = new Map(nodes.map((node) => [node, node.obj.$omitFromDatabaseJson()])); + + try { + const patches = nodes.map((node) => this._preparePatch(builder, node)).filter(Boolean); + const cycles = this._findReferenceCycles(patches.map(({ node }) => node)); + // Patches whose rows reference each other lock them in opposite order + // when they run at the same time, which deadlocks on MySQL. We write the + // patches of each cycle one after the other (#385): + const groups = groupBy(patches, ({ node }) => cycles.get(node) || node); + await promiseMap( + [...groups.values()], + async (patches) => { + for (const patch of patches) { + await this._writePatch(builder, patch); + } + }, + { concurrency: getConcurrency(builder, nodes) }, + ); + } finally { + for (const [node, props] of omittedProps) { + restoreOmitFromDatabaseJson(node.obj, props); + } + } + } + + // Map the nodes whose rows reference each other through the keys and values + // that they write, also through the rows of other nodes, to the first node of + // their cycle: the strongly connected components of these references, found + // with Tarjan's algorithm. + _findReferenceCycles(nodes) { + const getRow = (node) => node.referencedNode || node; + const nodesByRow = groupBy(nodes, getRow); + const getReferencedNodes = (node) => + (this.dependencies.get(node) || []) + .flatMap((dependency) => nodesByRow.get(getRow(dependency.node)) || []) + .filter((referencedNode) => referencedNode !== node); + + const indices = new Map(); + const lowLinks = new Map(); + const stack = []; + const stackedNodes = new Set(); + const cycles = new Map(); + + const visit = (node) => { + indices.set(node, indices.size); + lowLinks.set(node, indices.get(node)); + stack.push(node); + stackedNodes.add(node); + + for (const referencedNode of getReferencedNodes(node)) { + if (!indices.has(referencedNode)) { + visit(referencedNode); + lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(referencedNode))); + } else if (stackedNodes.has(referencedNode)) { + lowLinks.set(node, Math.min(lowLinks.get(node), indices.get(referencedNode))); + } + } + + // `node` is the first node of a component, which holds the nodes above + // it on the stack: + if (lowLinks.get(node) === indices.get(node)) { + const componentNodes = stack.splice(stack.lastIndexOf(node)); + + for (const componentNode of componentNodes) { + stackedNodes.delete(componentNode); + + if (componentNodes.length > 1) { + cycles.set(componentNode, node); + } + } + } + }; + + for (const node of nodes) { + if (!indices.has(node)) { + visit(node); + } + } + + return cycles; + } + + // Find out what to write for `node`, validate it, and return the patch, or + // nothing if there is nothing to write. + _preparePatch(builder, node) { + const { referencedNode } = node; + + // The props that the graph fills into the model of a `#ref` model are only + // final now: + if (referencedNode) { + resolveReference(node, this._getFilledProps(referencedNode)); + } + + const { graphOptions } = this.plan; + const shouldPatch = graphOptions.shouldPatch(node); + const shouldUpdate = graphOptions.shouldUpdate(node); + // Inserts, relates and removals of BelongsToOne rows change the keys of + // their owners, and relates into other relations the keys of the related + // rows, which are written even with `noUpdate` (#53): + const keyProps = [ + ...this._setKeysOfRelatedRows(node), + ...this._clearKeysOfRemovedRows(node), + ...this._setKeyArrays(node), + ...(this.plan.actions.get(node) === 'relate' && node.relation.keySide === 'related' + ? node.relation.relatedProp.props + : []), + ]; + // Changing the id never makes sense with `upsertGraph()`. A many-to-many + // `#ref` model whose model writes its row only writes its join row: + const idProps = node.modelClass.getIdPropertyArray(); + const isJoinRowOnly = + !!referencedNode && node.relation.keySide === 'join' && this._isCoveredByModel(node); + const isWritable = (prop) => + !idProps.includes(prop) && + (!isJoinRowOnly || node.relation.joinTableExtras.some((extra) => extra.aliasProp === prop)); + + const getPropsToUpdate = (changedProps) => + (shouldPatch || shouldUpdate ? changedProps : keyProps).filter(isWritable); + + // Leave out the props that don't change, and return true if any do. + const handleUpdate = ({ changedProps, unchangedProps } = this._findChanges(node)) => { + const propsToUpdate = getPropsToUpdate(changedProps); + + if (propsToUpdate.length > 0) { + node.obj.$omitFromDatabaseJson( + difference([...changedProps, ...unchangedProps], propsToUpdate), + ); + return true; + } else { + return false; + } + }; + + // Without a `beforeUpdate` or `$beforeUpdate` hook, we know right away + // what to update. Otherwise, the hook may still change the model, and we + // check again after it runs, but only if the model has props to update, as + // relates are no updates (#2233, #2605). + const changes = this._findChanges(node); + const propsToUpdate = getPropsToUpdate(changes.changedProps); + const hasBeforeUpdate = hasHook(node.obj, 'beforeUpdate'); + + if (propsToUpdate.length === 0) { + return null; + } else if (!hasBeforeUpdate) { + handleUpdate(changes); + } + + node.obj.$validate(null, { + dataPath: node.dataPathKey, + patch: shouldPatch || !shouldUpdate, + }); + + // Rows that their owners relate by their keys can't be found through the + // relation until the owners are patched, so they're patched directly. + const { relation } = node; + const isPatchedThroughRelation = + !!node.parent && + (relation.keySide === 'join' || + (relation.keySide === 'owner' && this.plan.actions.get(node) !== 'relate')); + + return { + node, + shouldPatch, + hasBeforeUpdate, + handleUpdate, + propsToUpdate, + isPatchedThroughRelation, + }; + } + + _writePatch( + builder, + { node, shouldPatch, hasBeforeUpdate, handleUpdate, propsToUpdate, isPatchedThroughRelation }, + ) { + const query = ( + isPatchedThroughRelation + ? // Find the row through the keys of the owner's current row, as the + // graph may leave them out of the owner: + (node.parent.existing || node.parent).obj + .$relatedQuery(node.relation.name) + .findById(node.obj.$id()) + : (node.current?.obj || node.obj).$query() + ) + .childQueryOf(builder, { fork: true, isInternalQuery: true }) + // The current graph was fetched through the relations, so the patched + // models are known to be related to their parents. + .internalOptions({ + skipRelationFilter: true, + // Join rows are patched with the extras among the props to update. The + // extras of new join rows are written with them, see `_findChanges()`: + joinTableExtras: node.relation?.keySide === 'join' && !node.current ? [] : propsToUpdate, + }) + .copyFrom(builder, isReturningAll); + + if (hasBeforeUpdate) { + query.internalContext().runBefore.push((result, query) => { + if (!handleUpdate()) { + query.internalOptions().returnImmediatelyValue = null; + } + + return result; + }); + } + + return (shouldPatch ? query.patch(node.obj) : query.update(node.obj)) + .runAfter(async (result, query) => { + if (isObject(result) && result.$isObjectionModel) { + // Handle `returning('*')`. + node.obj.$set(result); + } else if ( + (result === 0 || result === undefined) && + // Not if a hook resolved the query, e.g. with `cancelQuery()`, or + // there was no column to write: + !query.findOperation(UpdateOperation).isResolvedWithoutWrite(query) + ) { + // Rows patched through relations are gone from there if not found. + if (isPatchedThroughRelation) { + throw createPatchNotFoundError(builder, node); + } else { + await throwForMissingRow(builder, node); + } + } + + return result; + }) + .execute(); + } + + // Change the arrays of keys that `node` holds for the relations that the graph + // gives it: Remove the keys of the current related rows that it deletes or + // unrelates, and add the keys of the related models that it writes. Other + // keys stay, e.g. of rows that the relation's filter leaves out. Relations + // that the graph leaves out keep their arrays (#1202). Return the props that + // change. + _setKeyArrays(node) { + const { actions, graphOptions } = this.plan; + const { existing } = node; + const props = []; + + for (const name of node.modelClass.getRelationNames()) { + const relation = node.obj[name] !== undefined && node.modelClass.getRelation(name); + + if (relation && hasOwnerKeyArray(relation)) { + const { ownerProp, relatedProp } = relation; + const getKey = (related) => relatedProp.getProp(related.obj, 0); + const keys = relation.getKeys(existing ? existing.obj : node.obj); + const removed = (existing?.activeChildren || []).filter( + (child) => + child.relation.name === name && ['delete', 'unrelate'].includes(actions.get(child)), + ); + const added = node.activeChildren.filter( + (child) => + child.relation.name === name && + !child.toBeUnrelatedOrDeleted && + actions.get(child) !== 'ignore', + ); + + for (const related of added) { + resolveReference(related); + } + + const newKeys = relation.changeKeys(keys, { + add: added.map(getKey), + remove: removed.map(getKey), + }); + + if (!existing || graphOptions.shouldUpdate(node)) { + // Inserts and updates write the whole row: + ownerProp.setProp(node.obj, 0, newKeys); + props.push(ownerProp.props[0]); + } else if (!jsonEquals(keys, newKeys)) { + const patch = relation.createKeysPatch(existing.obj, newKeys); + Object.assign(node.obj, patch); + props.push(...Object.keys(patch)); + } + } + } + + return props; + } + + // Point the keys of `node` to its BelongsToOne rows that are inserted or + // related, and return the props that change. + _setKeysOfRelatedRows(node) { + const { actions } = this.plan; + const props = []; + + for (const child of node.activeChildren) { + const { relation } = child; + + if ( + hasOwnerKey(relation) && + relation.relatedProp.hasProps(child.obj) && + // Ignored models don't replace the current one, and models marked with + // `#unrelate` or `#delete` are handled below. + actions.get(child) !== 'ignore' + ) { + relation.ownerProp.forEach((i) => { + const currentValue = node.existing && relation.ownerProp.getProp(node.existing.obj, i); + const value = relation.relatedProp.getProp(child.obj, i); + + if (currentValue != value) { + relation.ownerProp.setProp(node.obj, i, value); + props.push(relation.ownerProp.props[i]); + } + }); + } + } + + return props; + } + + // Clear the keys of `node` that point to BelongsToOne rows that are removed, + // and return the props that change. + _clearKeysOfRemovedRows(node) { + const { actions } = this.plan; + const props = []; + + for (const currentChild of node.existing?.activeChildren || []) { + const { relation } = currentChild; + + if ( + hasOwnerKey(relation) && + (node.obj[relation.name] === null || currentChild.node?.toBeUnrelatedOrDeleted) && + actions.get(currentChild) !== 'keep' + ) { + relation.ownerProp.forEach((i) => { + if (relation.ownerProp.getProp(node.existing.obj, i) != null) { + relation.ownerProp.setProp(node.obj, i, null); + props.push(relation.ownerProp.props[i]); + } + }); + } + } + + return props; + } + + _findChanges(node) { + const { obj, relation } = node; + const currentObj = node.current?.obj || {}; + const relationNames = node.modelClass.getRelationNames(); + // The extras of a new join row are written with it: + const extraProps = + relation?.keySide === 'join' && !node.current + ? relation.joinTableExtras.map((extra) => extra.aliasProp) + : []; + const changedProps = []; + const unchangedProps = []; + + for (const prop of Object.keys(obj)) { + if (!isInternalProp(prop) && !relationNames.includes(prop)) { + const value = obj[prop]; + const currentValue = currentObj[prop]; + + // We can't know if a prop that the current model doesn't have changes, + // so we assume it does (#732). + if ( + !extraProps.includes(prop) && + (currentValue === undefined || !nonStrictEquals(currentValue, value)) + ) { + changedProps.push(prop); + } else { + unchangedProps.push(prop); + } + } + } + + // Query properties may change the values too. + if (obj.$$queryProps) { + changedProps.push(...Object.keys(obj.$$queryProps)); + } + + return { changedProps, unchangedProps }; + } +} + +// Collect what each node waits for: The node on the other side of its relation +// if the node stores its keys, and the nodes it references with `#ref` and +// `#ref{}`. +function createDependencies(plan) { + const { actions } = plan; + const dependencies = new Map(); + + const addDependency = (dependent, dependency) => { + const nodeDependencies = dependencies.get(dependent); + const entry = { dependent, ...dependency }; + + if (nodeDependencies) { + nodeDependencies.push(entry); + } else { + dependencies.set(dependent, [entry]); + } + }; + + for (const node of plan.activeNodes) { + const { parent, relation } = node; + + // Models marked with `#unrelate` or `#delete` are never inserted or related, + // so their keys aren't copied. Ignored models don't replace the current one, + // so their keys aren't copied to their owners either. + if (parent && !node.toBeUnrelatedOrDeleted) { + if (relation.keySide === 'related') { + addDependency(node, { node: parent, relation, related: node }); + } else if (relation.keySide === 'owner' && actions.get(node) !== 'ignore') { + addDependency(parent, { node, relation, related: node }); + } + } + } + + for (const node of plan.activeNodes) { + if (node.referencedNode) { + addDependency(node, { node: node.referencedNode }); + } + + for (const reference of node.propertyReferences) { + addDependency(node, { node: reference.referencedNode, reference }); + } + + // A missing row that the graph inserts elsewhere is related once it is: + const insertingNode = + node.isMissing && actions.get(node) === 'relate' && plan.findInsertingNode(node); + + if (insertingNode) { + addDependency(node, { node: insertingNode }); + } + + // A row that goes into the slot of a HasOneRelation waits for the relate + // that moves the current row out of it, as the foreign key may be unique. + const moverNode = getSlotMover(plan, node); + + if (moverNode) { + addDependency(node, { node: moverNode, isSlotMove: true }); + } + } + + return dependencies; +} + +// Find the relate that moves the current row in the slot of the HasOneRelation +// that `node` is inserted or related into to another owner. +function getSlotMover(plan, node) { + const { actions } = plan; + const slotNode = ['insert', 'relate'].includes(actions.get(node)) && getSlotNode(node); + const keptNode = slotNode && plan.keptNodeForRow(slotNode); + return keptNode && actions.get(keptNode) === 'relate' ? keptNode : null; +} + +// Fetch the keys that the relations of the inserted nodes need, but that the +// database generated and the insert didn't return, like a uuid that isn't the +// id (Vincit#853). +async function fetchGeneratedKeys(builder, modelClass, nodes) { + const keyProps = (node) => [ + ...(node.relation && node.relation.keySide !== 'related' ? [node.relation.relatedProp] : []), + ...node.activeChildren + .filter((child) => child.relation.keySide !== 'owner') + .map((child) => child.relation.ownerProp), + ]; + const missingNodes = nodes.filter((node) => + keyProps(node).some((prop) => prop.getProps(node.obj).some((value) => value === undefined)), + ); + + if (missingNodes.length > 0 && missingNodes.every((node) => node.obj.$hasId())) { + // SQL Server returns columns selected twice as arrays, so the ids are + // part of the set: + const columns = new Set([ + ...modelClass.getIdColumnArray(), + ...missingNodes.flatMap((node) => keyProps(node)).flatMap((prop) => prop.cols), + ]); + const rows = await modelClass + .query() + .childQueryOf(builder, { fork: true, isInternalQuery: true }) + .findByIds(missingNodes.map((node) => node.obj.$id())) + .select([...columns]); + const rowsByKey = new Map(rows.map((row) => [row.$idKey(), row])); + + for (const node of missingNodes) { + const row = rowsByKey.get(node.obj.$idKey()); + + for (const prop of keyProps(node)) { + prop.forEach((i) => { + if (prop.getProp(node.obj, i) === undefined) { + prop.setProp(node.obj, i, prop.getProp(row, i)); + } + }); + } + } + } +} + +// Copy the keys of the relation of `node` to the side that stores them, +// whichever node was inserted first (#311). +function resolveKeys(node) { + const { relation, parent } = node; + + // The arrays of keys of owners are written by `_setKeyArrays()`. + if (relation.keySide === 'related' || hasOwnerKey(relation)) { + const { ownerProp, relatedProp } = relation; + const [source, sourceProp, target, targetProp] = + relation.keySide === 'owner' + ? [node, relatedProp, parent, ownerProp] + : [parent, ownerProp, node, relatedProp]; + + resolveReference(source); + resolveReference(target); + + targetProp.forEach((i) => { + targetProp.setProp(target.obj, i, sourceProp.getProp(source.obj, i)); + }); + } +} + +// Give a `#ref` model the props of the model it refers to that it doesn't have, +// and the `filledProps` again, as they may have changed since. Its own keys +// stay its own, and the join table extras of the place of the model belong to +// the join row there. +function resolveReference(node, filledProps = []) { + const { referencedNode, relation } = node; + + if (referencedNode) { + const ownKeys = relation?.keySide === 'related' ? relation.relatedProp.props : []; + const modelExtras = + referencedNode.relation?.keySide === 'join' + ? referencedNode.relation.joinTableExtras.map((extra) => extra.aliasProp) + : []; + + for (const prop of difference(Object.keys(referencedNode.obj), modelExtras)) { + if ( + !Object.hasOwn(node.obj, prop) || + (filledProps.includes(prop) && !ownKeys.includes(prop)) + ) { + node.obj[prop] = referencedNode.obj[prop]; + } + } + } +} + +// Replace a `#ref{}` in a prop of `node` with the value it refers to. A whole +// value is replaced even if the relation set the prop already (Vincit#1378). +function resolvePropertyReference(node, { path, match, refPath, isWholeValue, referencedNode }) { + const value = get(referencedNode.obj, refPath); + set(node.obj, path, isWholeValue ? value : get(node.obj, path).replace(match, value)); +} + +// Fill in the keys of `node` that the graph leaves out from its row: the keys +// that its related models read from wherever the row was found, and the key +// of its parent relation only from its row in the same place. The keys that it +// holds for the BelongsToOne rows that the graph writes come from the graph. +function copyKeysFromExisting(node, actions) { + const { existing, current } = node; + + if (existing) { + const copies = [ + ...(node.parent && current ? [[node.relation.relatedProp, current]] : []), + ...node.activeChildren + .filter( + (child) => + !hasOwnerKeyArray(child.relation) && + (child.relation.keySide !== 'owner' || actions.get(child) === 'ignore'), + ) + .map((child) => [child.relation.ownerProp, existing]), + ]; + + for (const [prop, source] of copies) { + prop.forEach((i) => { + if (prop.getProp(node.obj, i) === undefined) { + prop.setProp(node.obj, i, prop.getProp(source.obj, i)); + } + }); + } + } +} + +function hasHook(obj, name) { + const Model = getModel(); + return obj.constructor[name] !== Model[name] || obj[`$${name}`] !== Model.prototype[`$${name}`]; +} + +// Throw if the patched row of `node` doesn't exist anymore, e.g. deleted +// through `ON DELETE CASCADE` by an earlier write of the graph. A row that still +// exists counts as patched, even if a trigger skipped it, or MySQL didn't count +// it as changed. +async function throwForMissingRow(builder, node) { + const { modelClass } = node; + const row = await modelClass + .query() + .childQueryOf(builder, { fork: true, isInternalQuery: true }) + .findById(node.obj.$id()) + .select(modelClass.getIdColumnArray()); + + if (!row) { + throw createPatchNotFoundError(builder, node); + } +} + +function createPatchNotFoundError(builder, node) { + return node.modelClass.createNotFoundError(builder.context(), { + message: `model (id=${node.obj.$id()}) was not found when updating it. An earlier write of the graph or a concurrent query may have deleted it`, + dataPath: node.dataPath, + }); +} + +// Find the current row in the slot of the HasOneRelation that `node` relates +// or is inserted into, or null if it's in another relation or an empty slot. +function getSlotNode(node) { + const { relation } = node; + return relation?.keySide === 'related' && relation.isOneToOne() + ? (node.parent.existing?.activeChildren.find( + (child) => child.relation.name === relation.name, + ) ?? null) + : null; +} + +// Return the key props of `node` on its side of the relation of `dependency`. +function getKeyProps({ relation, related }, node) { + return (related === node ? relation.relatedProp : relation.ownerProp).props; +} + +function hasOwnerKey(relation) { + return relation.keySide === 'owner' && relation.isOneToOne(); +} + +function hasOwnerKeyArray(relation) { + return relation.keySide === 'owner' && !relation.isOneToOne(); +} + +function getBatchSize(builder) { + return hasBatchInsert(builder.unsafeKnex()) ? MAX_INSERT_BATCH_SIZE : 1; +} + +function getConcurrency(builder, nodes) { + const knex = builder.unsafeKnex(); + return nodes.reduce( + (concurrency, node) => Math.min(concurrency, node.modelClass.getConcurrency(knex)), + MAX_CONCURRENCY, + ); +} + +function isReturningAll(operation) { + return operation.name === 'returning' && operation.args.includes('*'); +} diff --git a/lib/queryBuilder/graph/delete/GraphDelete.js b/lib/queryBuilder/graph/delete/GraphDelete.js deleted file mode 100644 index dbdac8619..000000000 --- a/lib/queryBuilder/graph/delete/GraphDelete.js +++ /dev/null @@ -1,73 +0,0 @@ -'use strict'; - -const { GraphOperation } = require('../GraphOperation'); -const { GraphDeleteAction } = require('./GraphDeleteAction'); -const { getPropertyTypes } = require('../../../model/modelJsonAttributes'); - -class GraphDelete extends GraphOperation { - // The owner of a BelongsToOneRelation row references it until `GraphPatch` - // points the owner to a new row or to null. These rows are deleted - // afterwards by `GraphDereferencedBelongsToOneDelete`, all others before. - get deletesDereferencedRows() { - return false; - } - - createActions() { - return [ - new GraphDeleteAction(this.graphData, { - nodes: this.currentGraph.nodes.filter( - (currentNode) => - this.graphOptions.shouldDeleteOrUnrelate(currentNode, this.graphData) && - isDereferencedBelongsToOneNode(currentNode, this.graphData) === - this.deletesDereferencedRows, - ), - areReferenced: !this.deletesDereferencedRows, - }), - ]; - } -} - -class GraphDereferencedBelongsToOneDelete extends GraphDelete { - get deletesDereferencedRows() { - return true; - } -} - -function isDereferencedBelongsToOneNode(currentNode, graphData) { - const { graph, graphOptions } = graphData; - const relation = currentNode.parentEdge?.relation; - const parentNode = graph.nodeForNode(currentNode.parentNode); - - if (relation?.isObjectionBelongsToOneRelation && parentNode) { - const node = graph.nodeForNode(currentNode); - const isReplaced = parentNode.edges.some( - (edge) => - edge.relation === relation && - edge.isOwnerNode(parentNode) && - !graphOptions.shouldIgnore(edge.relatedNode, graphData), - ); - const isRemoved = - parentNode.obj[relation.name] === null || (!!node && node.toBeUnrelatedOrDeleted); - - // Removing the row sets the owner's foreign key to null, but only where - // its schema allows it. Otherwise we delete the row first and leave the - // owner's reference to the database, as the column may be `NOT NULL`. - return ( - isReplaced || - (isRemoved && - relation.ownerProp.props.every((prop) => isNullable(relation.ownerModelClass, prop))) - ); - } else { - return false; - } -} - -function isNullable(modelClass, prop) { - const schema = modelClass.getJsonSchema()?.properties?.[prop]; - return !!schema && (schema.nullable === true || getPropertyTypes(schema).includes('null')); -} - -module.exports = { - GraphDelete, - GraphDereferencedBelongsToOneDelete, -}; diff --git a/lib/queryBuilder/graph/delete/GraphDeleteAction.js b/lib/queryBuilder/graph/delete/GraphDeleteAction.js deleted file mode 100644 index f25b49217..000000000 --- a/lib/queryBuilder/graph/delete/GraphDeleteAction.js +++ /dev/null @@ -1,88 +0,0 @@ -'use strict'; - -const { GraphAction } = require('../GraphAction'); -const { groupBy } = require('../../../utils/objectUtils'); -const promiseUtils = require('../../../utils/promiseUtils'); - -class GraphDeleteAction extends GraphAction { - constructor(graphData, { nodes, areReferenced = true }) { - super(graphData); - // Nodes to delete. - this.nodes = nodes; - // Whether the parents still reference the nodes. Rows of - // BelongsToOneRelations can be deleted after their owners were patched to - // reference new rows or null, so they can't be found through the relation. - this.areReferenced = areReferenced; - } - - run(builder) { - const nodesTodelete = this._filterOutBelongsToOneRelationUnrelates(this.nodes); - const builders = this._createDeleteBuilders(builder, nodesTodelete); - - return promiseUtils.map(builders, (builder) => builder.execute(), { - concurrency: this._getConcurrency(builder, nodesTodelete), - }); - } - - _filterOutBelongsToOneRelationUnrelates(nodes) { - // `BelongsToOneRelation` unrelate is handled by `GraphPatch` because - // unrelating a `BelongsToOneRelation` is just a matter of updating - // one field of the parent node. - return nodes.filter((node) => { - return !( - this.graphOptions.shouldUnrelate(node, this.graphData) && - node.parentEdge.relation.isObjectionBelongsToOneRelation - ); - }); - } - - _createDeleteBuilders(parentBuilder, nodesTodelete) { - const nodesByRelation = groupBy(nodesTodelete, getRelation); - const builders = []; - - nodesByRelation.forEach((nodes, relation) => { - const nodesByParent = groupBy(nodes, getParent); - - nodesByParent.forEach((nodes, parentNode) => { - // Nodes of the same relation and parent can still require different - // operations when they are marked with `#unrelate` or `#delete`. - const nodesByShouldUnrelate = groupBy(nodes, (node) => - this.graphOptions.shouldUnrelate(node, this.graphData), - ); - - nodesByShouldUnrelate.forEach((nodes, shouldUnrelate) => { - const builder = ( - this.areReferenced - ? parentNode.obj.$relatedQuery(relation.name) - : relation.relatedModelClass.query() - ).childQueryOf(parentBuilder); - - if (!this.areReferenced || !relation.isObjectionBelongsToOneRelation) { - // This is useless in case of a referenced BelongsToOneRelation. - builder.findByIds(nodes.map((node) => node.obj.$id())); - } - - for (const node of nodes) { - node.userData.deleted = true; - } - - builders.push(shouldUnrelate ? builder.unrelate() : builder.delete()); - }); - }); - }); - - return builders; - } -} - -function getRelation(node) { - return node.parentEdge.relation; -} - -function getParent(node) { - return node.parentNode; -} - -module.exports = { - GraphDeleteAction, -}; diff --git a/lib/queryBuilder/graph/insert/DeferredRelationGraphInsertAction.js b/lib/queryBuilder/graph/insert/DeferredRelationGraphInsertAction.js deleted file mode 100644 index 3eb309cac..000000000 --- a/lib/queryBuilder/graph/insert/DeferredRelationGraphInsertAction.js +++ /dev/null @@ -1,57 +0,0 @@ -'use strict'; - -const { GraphAction } = require('../GraphAction'); -const { groupBy } = require('../../../utils/objectUtils'); -const promiseUtils = require('../../../utils/promiseUtils'); - -/** - * Patches in the foreign keys of `BelongsToOneRelation` edges that `GraphInsert` - * had to defer to break a dependency cycle. The owner nodes of these edges were - * inserted without the foreign keys. `GraphInsertAction` already copied them to - * the owner objects once the related nodes were inserted, so all that's left to - * do is one patch query per owner. - */ -class DeferredRelationGraphInsertAction extends GraphAction { - constructor(graphData, { edges }) { - super(graphData); - - // `BelongsToOneRelation` edges whose owner node needs to be patched. - this.edges = edges; - } - - run(builder) { - const edgesByOwner = groupBy(this.edges, (edge) => edge.ownerNode); - const owners = [...edgesByOwner.keys()]; - const concurrency = this._getConcurrency(builder, owners); - - return promiseUtils.map( - owners, - (owner) => this._patchOwner(builder, owner, edgesByOwner.get(owner)), - { concurrency }, - ); - } - - _patchOwner(builder, ownerNode, edges) { - const { modelClass } = ownerNode; - const ownerIdProp = modelClass.getIdRelationProperty(); - const patch = {}; - - for (const { relation } of edges) { - const { ownerProp } = relation; - - ownerProp.forEach((i) => { - ownerProp.patch(patch, i, ownerProp.getProp(ownerNode.obj, i)); - }); - } - - return modelClass - .query() - .childQueryOf(builder) - .patch(patch) - .whereInComposite(ownerIdProp.refs(builder), [ownerIdProp.getProps(ownerNode.obj)]); - } -} - -module.exports = { - DeferredRelationGraphInsertAction, -}; diff --git a/lib/queryBuilder/graph/insert/GraphInsert.js b/lib/queryBuilder/graph/insert/GraphInsert.js deleted file mode 100644 index c578f8af8..000000000 --- a/lib/queryBuilder/graph/insert/GraphInsert.js +++ /dev/null @@ -1,257 +0,0 @@ -'use strict'; - -const { JoinRowGraphInsertAction } = require('./JoinRowGraphInsertAction'); -const { DeferredRelationGraphInsertAction } = require('./DeferredRelationGraphInsertAction'); -const { GraphInsertAction } = require('./GraphInsertAction'); -const { GraphOperation } = require('../GraphOperation'); -const { ModelGraphEdge } = require('../../../model/graph/ModelGraphEdge'); - -class GraphInsert extends GraphOperation { - constructor(...args) { - super(...args); - this.dependencies = this._createDependencyMap(); - this.deferredEdges = new Set(); - } - - createActions() { - return [ - ...this._createNormalActions(), - ...this._createDeferredRelationActions(), - ...this._createJoinRowActions(), - ]; - } - - _createDependencyMap() { - const dependencies = new Map(); - - for (const edge of this.graph.edges) { - if (edge.type == ModelGraphEdge.Type.Relation) { - this._createRelationDependency(edge, dependencies); - } else { - this._createReferenceDependency(edge, dependencies); - } - } - - return dependencies; - } - - _createRelationDependency(edge, dependencies) { - if (edge.relatedNode.toBeUnrelatedOrDeleted) { - // Models marked with `#unrelate` or `#delete` are never inserted or - // related, so their keys must not be copied to the owner (or vice versa). - return; - } - - if (edge.relation.isObjectionHasManyRelation) { - // In case of HasManyRelation the related node depends on the owner node - // because the related node has the foreign key. - this._addDependency(edge.relatedNode, edge, dependencies); - } else if ( - edge.relation.isObjectionBelongsToOneRelation && - // Ignored models don't replace the current one, so their keys must not - // be copied to the owner either. - !this.graphOptions.shouldIgnore(edge.relatedNode, this.graphData) - ) { - // In case of BelongsToOneRelation the owner node depends on the related - // node because the owner node has the foreign key. - this._addDependency(edge.ownerNode, edge, dependencies); - } - } - - _createReferenceDependency(edge, dependencies) { - this._addDependency(edge.ownerNode, edge, dependencies); - } - - _addDependency(node, edge, dependencies) { - let edges = dependencies.get(node); - - if (!edges) { - edges = []; - dependencies.set(node, edges); - } - - edges.push(edge); - } - - _createNormalActions() { - const handledNodes = new Set(); - const actions = []; - - while (true) { - // At this point, don't care if the nodes have already been inserted before - // given to this class. `GraphInsertAction` will test that and only insert - // new ones. We need to pass all nodes to `GraphInsertActions` so that we - // can resolve all dependencies. - const nodesToInsert = this.graph.nodes.filter((node) => { - return !this._isHandled(node, handledNodes) && !this._hasDependencies(node, handledNodes); - }); - - if (nodesToInsert.length === 0) { - if (handledNodes.size !== this.graph.nodes.length && this._breakCycle(handledNodes)) { - continue; - } - - break; - } - - actions.push( - new GraphInsertAction(this.graphData, { - nodes: nodesToInsert, - dependencies: this.dependencies, - deferredEdges: this.deferredEdges, - }), - ); - - for (const node of nodesToInsert) { - this._markHandled(node, handledNodes); - } - } - - if (handledNodes.size !== this.graph.nodes.length) { - throw new Error('the object graph contains cyclic references'); - } - - return actions; - } - - // Tries to break a dependency cycle by deferring one `BelongsToOneRelation` - // edge that points to a `#ref` node. The owner node is then inserted without - // the foreign key, which is patched in by `DeferredRelationGraphInsertAction` - // once the referenced node has been inserted. Cycles can only exist through - // `#ref` nodes, since the graph is a tree otherwise. - _breakCycle(handledNodes) { - for (const node of this.graph.nodes) { - if (this._isHandled(node, handledNodes)) { - continue; - } - - for (const edge of this._pendingDependencyEdges(node, handledNodes)) { - if ( - this._canDeferEdge(edge, node) && - this._isBlockedBy(edge.relatedNode, node, handledNodes, new Set()) - ) { - this.deferredEdges.add(edge); - return true; - } - } - } - - return false; - } - - _canDeferEdge(edge, node) { - return ( - edge.type === ModelGraphEdge.Type.Relation && - edge.relation.isObjectionBelongsToOneRelation && - edge.isOwnerNode(node) && - edge.relatedNode.isReference && - // The owner is patched by its id, which models without one don't have (#2305). - node.modelClass.getIdColumnArray().length > 0 && - // Both ends need to be inserted for the foreign key to be patched in later. - this.graphOptions.shouldInsert(node, this.graphData) && - this.graphOptions.shouldInsert(edge.relatedNode.referencedNode, this.graphData) && - !this._isReadByPropertyReference(node, edge.relation.ownerProp) - ); - } - - // Returns true if a `#ref{}` property reference reads one of the `props` of - // `node`. Such references may be resolved before a deferred prop is set, - // which would resolve them to `undefined`. - _isReadByPropertyReference(node, props) { - return node.refEdges.some((edge) => { - return ( - edge.refType === ModelGraphEdge.ReferenceType.Property && - edge.isRelatedNode(node) && - props.props.includes(edge.refRelatedDataPath[0]) - ); - }); - } - - // Returns true if `node` (transitively) waits for `blockingNode` to be handled. - _isBlockedBy(node, blockingNode, handledNodes, visitedNodes) { - if (node === blockingNode) { - return true; - } - - if (visitedNodes.has(node)) { - return false; - } - - visitedNodes.add(node); - - for (const edge of this._pendingDependencyEdges(node, handledNodes)) { - if (this._isBlockedBy(edge.getOtherNode(node), blockingNode, handledNodes, visitedNodes)) { - return true; - } - } - - return false; - } - - _createDeferredRelationActions() { - if (this.deferredEdges.size === 0) { - return []; - } - - return [ - new DeferredRelationGraphInsertAction(this.graphData, { - edges: [...this.deferredEdges], - }), - ]; - } - - _isHandled(node, handledNodes) { - return handledNodes.has(node); - } - - _hasDependencies(node, handledNodes) { - return this._pendingDependencyEdges(node, handledNodes).length > 0; - } - - // Returns the dependency edges of `node` whose other node still needs to be - // inserted first. Deferred edges are ignored. - _pendingDependencyEdges(node, handledNodes) { - const edges = this.dependencies.get(node) || []; - - return edges.filter((edge) => { - const dependencyNode = edge.getOtherNode(node); - - return ( - !this.deferredEdges.has(edge) && - !handledNodes.has(dependencyNode) && - !this.currentGraph.nodeForNode(dependencyNode) - ); - }); - } - - _markHandled(node, handledNodes) { - handledNodes.add(node); - - // The referencing nodes are all references that don't - // represent any real entity. They are simply intermediate nodes - // that depend on this node. Once this node is handled, we can - // also mark those nodes as handled as there is nothing to actually - // insert. - for (const refNode of node.referencingNodes) { - this._markHandled(refNode, handledNodes); - } - } - - _createJoinRowActions() { - return [ - new JoinRowGraphInsertAction(this.graphData, { - nodes: this.graph.nodes.filter((node) => { - return ( - this.currentGraph.nodeForNode(node) === null && - node.parentEdge && - node.parentEdge.relation.isObjectionManyToManyRelation - ); - }), - }), - ]; - } -} - -module.exports = { - GraphInsert, -}; diff --git a/lib/queryBuilder/graph/insert/GraphInsertAction.js b/lib/queryBuilder/graph/insert/GraphInsertAction.js deleted file mode 100644 index 761275251..000000000 --- a/lib/queryBuilder/graph/insert/GraphInsertAction.js +++ /dev/null @@ -1,238 +0,0 @@ -'use strict'; - -const { GraphAction } = require('../GraphAction'); -const { groupBy, chunk, get, set } = require('../../../utils/objectUtils'); -const { ModelGraphEdge } = require('../../../model/graph/ModelGraphEdge'); -const promiseUtils = require('../../../utils/promiseUtils'); - -/** - * Inserts a batch of nodes for a GraphInsert. - * - * One of these is created for each batch of nodes that can be inserted at once. - * However, the nodes can have a different table and not all databases support - * batch inserts, so this class splits the inserts into further sub batches - * when needed. - */ -class GraphInsertAction extends GraphAction { - constructor(graphData, { nodes, dependencies, deferredEdges }) { - super(graphData); - - // Nodes to insert. - this.nodes = nodes; - this.dependencies = dependencies; - // Dependency edges that are resolved once the dependency node has been - // inserted, and are patched in by `DeferredRelationGraphInsertAction`. - this.deferredEdges = deferredEdges; - } - - run(builder) { - const batches = this._createInsertBatches(builder); - const concurrency = this._getConcurrency(builder, this.nodes); - - return promiseUtils.map(batches, (batch) => this._insertBatch(builder, batch), { concurrency }); - } - - _createInsertBatches(builder) { - const batches = []; - const batchSize = this._getBatchSize(builder); - const nodesByModelClass = groupBy(this.nodes, getModelClass); - - for (const nodes of nodesByModelClass.values()) { - for (const nodeBatch of chunk(nodes, batchSize)) { - batches.push(nodeBatch); - } - } - - return batches; - } - - async _insertBatch(parentBuilder, nodes) { - await this._beforeInsert(nodes); - await this._insert(parentBuilder, nodes); - await this._afterInsert(nodes); - } - - _beforeInsert(nodes) { - this._resolveDependencies(nodes); - this._omitManyToManyExtraProps(nodes); - this._copyRelationPropsFromCurrentIfNeeded(nodes); - - return Promise.resolve(); - } - - _resolveDependencies(nodes) { - for (const node of nodes) { - const edges = this.dependencies.get(node); - - if (edges) { - for (const edge of edges) { - if (this.deferredEdges.has(edge)) { - continue; - } - - // `node` needs `dependencyNode` to have been inserted (and it has been). - const dependencyNode = edge.getOtherNode(node); - - this._resolveDependency(dependencyNode, edge); - } - } - } - } - - _resolveDependency(dependencyNode, edge) { - if (edge.type === ModelGraphEdge.Type.Relation && !edge.relation.joinTable) { - this._resolveRelationDependency(dependencyNode, edge); - } else if (edge.refType === ModelGraphEdge.ReferenceType.Property) { - this._resolvePropertyReferenceNode(dependencyNode, edge); - } - } - - _resolveRelationDependency(dependencyNode, edge) { - const dependentNode = edge.getOtherNode(dependencyNode); - - let sourceProp; - let targetProp; - - if (edge.isOwnerNode(dependencyNode)) { - sourceProp = edge.relation.ownerProp; - targetProp = edge.relation.relatedProp; - } else { - targetProp = edge.relation.ownerProp; - sourceProp = edge.relation.relatedProp; - } - - this._resolveReferences(dependencyNode); - - targetProp.forEach((i) => { - targetProp.setProp(dependentNode.obj, i, sourceProp.getProp(dependencyNode.obj, i)); - }); - } - - _resolvePropertyReferenceNode(dependencyNode, edge) { - const dependentNode = edge.getOtherNode(dependencyNode); - - let sourcePath; - let targetPath; - - if (edge.isOwnerNode(dependencyNode)) { - sourcePath = edge.refOwnerDataPath; - targetPath = edge.refRelatedDataPath; - } else { - targetPath = edge.refOwnerDataPath; - sourcePath = edge.refRelatedDataPath; - } - - const sourceValue = get(dependencyNode.obj, sourcePath); - const targetValue = get(dependentNode.obj, targetPath); - - if (targetValue === edge.refMatch) { - set(dependentNode.obj, targetPath, sourceValue); - } else { - set(dependentNode.obj, targetPath, targetValue.replace(edge.refMatch, sourceValue)); - } - } - - _omitManyToManyExtraProps(nodes) { - for (const node of nodes) { - if ( - node.parentEdge && - node.parentEdge.type === ModelGraphEdge.Type.Relation && - node.parentEdge.relation.joinTableExtras.length > 0 - ) { - node.parentEdge.relation.omitExtraProps([node.obj]); - } - } - } - - _copyRelationPropsFromCurrentIfNeeded(nodes) { - for (const node of nodes) { - const currentNode = this.currentGraph.nodeForNode(node); - - if (!currentNode) { - continue; - } - - for (const edge of node.edges) { - if (edge.type !== ModelGraphEdge.Type.Relation) { - continue; - } - - const prop = edge.isOwnerNode(node) ? edge.relation.ownerProp : edge.relation.relatedProp; - - prop.forEach((i) => { - const value = prop.getProp(node.obj, i); - - if (value !== undefined) { - return; - } - - prop.setProp(node.obj, i, prop.getProp(currentNode.obj, i)); - }); - } - } - } - - _insert(parentBuilder, nodes) { - const [{ modelClass }] = nodes; - - nodes = nodes.filter((node) => { - return this.graphOptions.shouldInsert(node, this.graphData); - }); - - for (const node of nodes) { - delete node.obj[modelClass.uidProp]; - delete node.obj[modelClass.graphUnrelateProp]; - delete node.obj[modelClass.graphDeleteProp]; - node.obj.$validate(null, { dataPath: node.dataPathKey }); - } - - if (nodes.length === 0) { - return; - } - - for (const node of nodes) { - node.userData.inserted = true; - } - - return this._runRelationBeforeInsertMethods(parentBuilder, nodes).then(() => { - return modelClass - .query() - .insert(nodes.map((node) => node.obj)) - .childQueryOf(parentBuilder) - .copyFrom(parentBuilder, GraphAction.ReturningAllSelector) - .execute(); - }); - } - - _runRelationBeforeInsertMethods(parentBuilder, nodes) { - return Promise.all( - nodes.map((node) => { - if (node.parentEdge) { - return node.parentEdge.relation.beforeInsert( - node.obj, - parentBuilder.context(), - node.parentNode.obj, - ); - } else { - return null; - } - }), - ); - } - - _afterInsert(nodes) { - for (const node of nodes) { - for (const refNode of node.referencingNodes) { - this._resolveDependency(refNode, refNode.parentEdge); - } - } - } -} - -function getModelClass(node) { - return node.modelClass; -} - -module.exports = { - GraphInsertAction, -}; diff --git a/lib/queryBuilder/graph/insert/JoinRowGraphInsertAction.js b/lib/queryBuilder/graph/insert/JoinRowGraphInsertAction.js deleted file mode 100644 index a6c1cb7ea..000000000 --- a/lib/queryBuilder/graph/insert/JoinRowGraphInsertAction.js +++ /dev/null @@ -1,92 +0,0 @@ -'use strict'; - -const { GraphAction } = require('../GraphAction'); -const { groupBy, chunk } = require('../../../utils/objectUtils'); -const promiseUtils = require('../../../utils/promiseUtils'); - -class JoinRowGraphInsertAction extends GraphAction { - constructor(graphData, { nodes }) { - super(graphData); - this.nodes = nodes; - } - - run(builder) { - const batches = this._createInsertBatches(builder); - const concurrency = this._getConcurrency(builder, this.nodes); - - return promiseUtils.map(batches, (batch) => this._insertBatch(builder, batch), { concurrency }); - } - - _createInsertBatches(builder) { - const batches = []; - const batchSize = this._getBatchSize(builder); - const nodesByModel = groupBy(this.nodes, (node) => getJoinTableModel(builder, node)); - - for (const [joinTableModelClass, nodes] of nodesByModel.entries()) { - for (const nodeBatch of chunk(nodes, batchSize)) { - batches.push(this._createBatch(joinTableModelClass, nodeBatch)); - } - } - - return batches; - } - - _createBatch(joinTableModelClass, nodes) { - return nodes - .filter((node) => { - return this.graphOptions.shouldRelate(node, this.graphData) || node.userData.inserted; - }) - .map((node) => ({ - node, - joinTableModelClass, - joinTableObj: this._createJoinTableObj(joinTableModelClass, node), - })); - } - - _createJoinTableObj(joinTableModelClass, node) { - const { parentEdge, parentNode } = node; - const { relation } = parentEdge; - - this._resolveReferences(node); - - return joinTableModelClass.fromJson( - relation.createJoinModel(relation.ownerProp.getProps(parentNode.obj), node.obj), - ); - } - - _insertBatch(parentBuilder, batch) { - return this._beforeInsert(parentBuilder, batch).then(() => this._insert(parentBuilder, batch)); - } - - _beforeInsert(parentBuilder, batch) { - return Promise.all( - batch.map(({ node, joinTableObj }) => { - if (node.parentEdge) { - return node.parentEdge.relation.joinTableBeforeInsert( - joinTableObj, - parentBuilder.context(), - ); - } else { - return null; - } - }), - ); - } - - _insert(parentBuilder, batch) { - if (batch.length > 0) { - return batch[0].joinTableModelClass - .query() - .childQueryOf(parentBuilder) - .insert(batch.map((it) => it.joinTableObj)); - } - } -} - -function getJoinTableModel(builder, node) { - return node.parentEdge.relation.getJoinModelClass(builder.unsafeKnex()); -} - -module.exports = { - JoinRowGraphInsertAction, -}; diff --git a/lib/queryBuilder/graph/patch/GraphPatch.js b/lib/queryBuilder/graph/patch/GraphPatch.js deleted file mode 100644 index 19e777422..000000000 --- a/lib/queryBuilder/graph/patch/GraphPatch.js +++ /dev/null @@ -1,20 +0,0 @@ -'use strict'; - -const { GraphOperation } = require('../GraphOperation'); -const { GraphPatchAction } = require('./GraphPatchAction'); - -class GraphPatch extends GraphOperation { - createActions() { - return [ - new GraphPatchAction(this.graphData, { - nodes: this.graph.nodes.filter((node) => - this.graphOptions.shouldPatchOrUpdateIgnoreDisable(node, this.graphData), - ), - }), - ]; - } -} - -module.exports = { - GraphPatch, -}; diff --git a/lib/queryBuilder/graph/patch/GraphPatchAction.js b/lib/queryBuilder/graph/patch/GraphPatchAction.js deleted file mode 100644 index 642ee3c40..000000000 --- a/lib/queryBuilder/graph/patch/GraphPatchAction.js +++ /dev/null @@ -1,277 +0,0 @@ -'use strict'; - -const { getModel } = require('../../../model/getModel'); -const { GraphAction } = require('../GraphAction'); -const { isInternalProp } = require('../../../utils/internalPropUtils'); -const { difference, isObject, isPlainObject, jsonEquals } = require('../../../utils/objectUtils'); -const promiseUtils = require('../../../utils/promiseUtils'); - -class GraphPatchAction extends GraphAction { - constructor(graphData, { nodes }) { - super(graphData); - // Nodes to patch. - this.nodes = nodes; - } - - run(builder) { - return promiseUtils.map(this.nodes, (node) => this._runForNode(builder, node), { - concurrency: this._getConcurrency(builder, this.nodes), - }); - } - - _runForNode(builder, node) { - const shouldPatch = this.graphOptions.shouldPatch(node, this.graphData); - const shouldUpdate = this.graphOptions.shouldUpdate(node, this.graphData); - - // BelongsToOneRelation inserts and relates change the parent object's - // properties. That's why we handle them here. - const changedPropsBecauseOfBelongsToOneInsert = this._handleBelongsToOneInserts(node); - - // BelongsToOneRelation deletes and unrelates change the parent object's - // properties. That's why we handle them here. - const changePropsBecauseOfBelongsToOneDelete = this._handleBelongsToOneDeletes(node); - - const handleUpdate = () => { - const { changedProps, unchangedProps } = this._findChanges(node); - const allProps = [...changedProps, ...unchangedProps]; - - const propsToUpdate = difference( - shouldPatch || shouldUpdate - ? changedProps - : [...changedPropsBecauseOfBelongsToOneInsert, ...changePropsBecauseOfBelongsToOneDelete], - - // Remove id properties from the props to update. With upsertGraph - // it never makes sense to change the id. - node.modelClass.getIdPropertyArray(), - ); - - const update = propsToUpdate.length > 0; - if (update) { - // Don't update the fields that we know not to change. - node.obj.$omitFromDatabaseJson(difference(allProps, propsToUpdate)); - node.userData.updated = true; - } - - return update; - }; - - const Model = getModel(); - // See if the model defines a beforeUpdate or $beforeUpdate hook. If it does - // not, we can check for updated properties now and drop out immediately if - // there is nothing to update. Otherwise, we need to wait for the hook to be - // called before calling handleUpdate, but only if the node contains changes - // that aren't id properties (relates). See issues #2233, #2605. - const hasBeforeUpdate = - node.obj.constructor.beforeUpdate !== Model.beforeUpdate || - node.obj.$beforeUpdate !== Model.prototype.$beforeUpdate; - - if ( - (hasBeforeUpdate && !this._hasNonIdPropertyChanges(node)) || - (!hasBeforeUpdate && !handleUpdate()) - ) { - return null; - } - - delete node.obj[node.modelClass.uidProp]; - delete node.obj[node.modelClass.uidRefProp]; - delete node.obj[node.modelClass.dbRefProp]; - delete node.obj[node.modelClass.graphUnrelateProp]; - delete node.obj[node.modelClass.graphDeleteProp]; - - node.obj.$validate(null, { - dataPath: node.dataPathKey, - patch: shouldPatch || (!shouldPatch && !shouldUpdate), - }); - - const updateBuilder = this._createBuilder(node) - .childQueryOf(builder, childQueryOptions()) - // The current graph was fetched through the relations, so the patched - // models are known to be related to their parents. - .internalOptions({ skipRelationFilter: true }) - .copyFrom(builder, GraphAction.ReturningAllSelector); - - if (hasBeforeUpdate) { - updateBuilder.internalContext().runBefore.push((result, builder) => { - // Call handleUpdate in the runBefore hook which runs after the - // $beforeUpdate hook, allowing it to modify the object before the - // updated properties are determined. See issue #2233. - if (hasBeforeUpdate && !handleUpdate()) { - builder.internalOptions().returnImmediatelyValue = null; - } - return result; - }); - } - - if (shouldPatch) { - updateBuilder.patch(node.obj); - } else { - updateBuilder.update(node.obj); - } - - return updateBuilder.execute().then((result) => { - if (isObject(result) && result.$isObjectionModel) { - // Handle returning('*'). - node.obj.$set(result); - } - - return result; - }); - } - - _handleBelongsToOneInserts(node) { - const currentNode = this.currentGraph.nodeForNode(node); - const updatedProps = []; - - for (const edge of node.edges) { - if ( - edge.isOwnerNode(node) && - edge.relation && - edge.relation.isObjectionBelongsToOneRelation && - edge.relation.relatedProp.hasProps(edge.relatedNode.obj) && - // Ignored models don't replace the current one, and models marked with - // `#unrelate` or `#delete` are handled below. - !this.graphOptions.shouldIgnore(edge.relatedNode, this.graphData) - ) { - const { relation } = edge; - - relation.ownerProp.forEach((i) => { - const currentValue = currentNode && relation.ownerProp.getProp(currentNode.obj, i); - const relatedValue = relation.relatedProp.getProp(edge.relatedNode.obj, i); - - if (currentValue != relatedValue) { - relation.ownerProp.setProp(node.obj, i, relatedValue); - updatedProps.push(relation.ownerProp.props[i]); - } - }); - } - } - - return updatedProps; - } - - _handleBelongsToOneDeletes(node) { - const currentNode = this.currentGraph.nodeForNode(node); - const updatedProps = []; - - if (!currentNode) { - return updatedProps; - } - - for (const edge of currentNode.edges) { - if ( - edge.isOwnerNode(currentNode) && - edge.relation.isObjectionBelongsToOneRelation && - (node.obj[edge.relation.name] === null || - isToBeUnrelatedOrDeleted(this.graph.nodeForNode(edge.relatedNode))) && - this.graphOptions.shouldDeleteOrUnrelate(edge.relatedNode, this.graphData) - ) { - const { relation } = edge; - - relation.ownerProp.forEach((i) => { - const currentValue = relation.ownerProp.getProp(currentNode.obj, i); - - if (currentValue != null) { - relation.ownerProp.setProp(node.obj, i, null); - updatedProps.push(relation.ownerProp.props[i]); - } - }); - } - } - - return updatedProps; - } - - _findChanges(node) { - const obj = node.obj; - const currentNode = this.currentGraph.nodeForNode(node); - const currentObj = (currentNode && currentNode.obj) || {}; - const relationNames = node.modelClass.getRelationNames(); - - const unchangedProps = []; - const changedProps = []; - - for (const prop of Object.keys(obj)) { - if (isInternalProp(prop) || relationNames.includes(prop)) { - continue; - } - - const value = obj[prop]; - const currentValue = currentObj[prop]; - - // If the current object doesn't have the property, we have to assume - // it changes (we cannot know if it will). If the object does have the - // property, we test non-strict equality. See issue #732. - if (currentValue === undefined || !nonStrictEquals(currentValue, value)) { - changedProps.push(prop); - } else { - unchangedProps.push(prop); - } - } - - // We cannot know if the query properties cause changes to the values. - // We must assume that they do. - if (obj.$$queryProps) { - changedProps.push(...Object.keys(obj.$$queryProps)); - } - - return { - changedProps, - unchangedProps, - }; - } - - _hasNonIdPropertyChanges(node) { - const idProps = node.modelClass.getIdPropertyArray(); - return this._findChanges(node).changedProps.some((prop) => !idProps.includes(prop)); - } - - _createBuilder(node) { - if (node.parentEdge && !node.parentEdge.relation.isObjectionHasManyRelation) { - return this._createRelatedBuilder(node); - } else { - return this._createRootBuilder(node); - } - } - - _createRelatedBuilder(node) { - return node.parentNode.obj - .$relatedQuery(node.parentEdge.relation.name) - .findById(node.obj.$id()); - } - - _createRootBuilder(node) { - const currentNode = this.currentGraph.nodeForNode(node); - const currentObj = currentNode && currentNode.obj; - - return currentObj ? currentObj.$query() : node.obj.$query(); - } -} - -function childQueryOptions() { - return { - fork: true, - isInternalQuery: true, - }; -} - -function nonStrictEquals(val1, val2) { - // Only use loose equality for non-JSON values, so that for example `false` - // and `[]` are not considered equal. See issue #1874. - if (!isJsonValue(val1) && !isJsonValue(val2) && val1 == val2) { - return true; - } - - return jsonEquals(val1, val2); -} - -function isJsonValue(value) { - return Array.isArray(value) || isPlainObject(value); -} - -function isToBeUnrelatedOrDeleted(node) { - return !!node && node.toBeUnrelatedOrDeleted; -} - -module.exports = { - GraphPatchAction, -}; diff --git a/lib/queryBuilder/graph/recursiveUpsert/GraphRecursiveUpsert.js b/lib/queryBuilder/graph/recursiveUpsert/GraphRecursiveUpsert.js deleted file mode 100644 index 36edd5987..000000000 --- a/lib/queryBuilder/graph/recursiveUpsert/GraphRecursiveUpsert.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; - -const { GraphOperation } = require('../GraphOperation'); -const { GraphRecursiveUpsertAction } = require('./GraphRecursiveUpsertAction'); - -class GraphRecursiveUpsert extends GraphOperation { - createActions() { - return [ - new GraphRecursiveUpsertAction(this.graphData, { - nodes: this.graph.nodes.filter((node) => { - const shouldRelate = this.graphOptions.shouldRelate(node, this.graphData); - return shouldRelate && hasRelations(node.obj); - }), - }), - ]; - } -} - -function hasRelations(obj) { - for (const relationName of obj.constructor.getRelationNames()) { - if (obj[relationName] !== undefined) { - return true; - } - } - - return false; -} - -module.exports = { - GraphRecursiveUpsert, -}; diff --git a/lib/queryBuilder/graph/recursiveUpsert/GraphRecursiveUpsertAction.js b/lib/queryBuilder/graph/recursiveUpsert/GraphRecursiveUpsertAction.js deleted file mode 100644 index 07d6b4efc..000000000 --- a/lib/queryBuilder/graph/recursiveUpsert/GraphRecursiveUpsertAction.js +++ /dev/null @@ -1,126 +0,0 @@ -'use strict'; - -const { GraphAction } = require('../GraphAction'); -const { groupBy, get, set } = require('../../../utils/objectUtils'); -const { forEachPropertyReference } = require('../../../model/graph/ModelGraphBuilder'); -const promiseUtils = require('../../../utils/promiseUtils'); - -class GraphRecursiveUpsertAction extends GraphAction { - constructor(graphData, { nodes }) { - super(graphData); - // Nodes to upsert. - this.nodes = nodes; - } - - run(builder) { - const builders = this._createUpsertBuilders(builder, this.nodes); - - return promiseUtils.map(builders, (builder) => builder.execute(), { - concurrency: this._getConcurrency(builder, this.nodes), - }); - } - - _createUpsertBuilders(parentBuilder, nodesToUpsert) { - const nodesByRelation = groupBy(nodesToUpsert, getRelation); - const builders = []; - - nodesByRelation.forEach((nodes) => { - const nodesByParent = groupBy(nodes, getParent); - - nodesByParent.forEach((nodes) => { - for (const node of nodes) { - this._resolveReferences(node); - node.userData.upserted = true; - } - - builders.push( - nodes[0].modelClass - .query() - .childQueryOf(parentBuilder) - .copyFrom(parentBuilder, GraphAction.ReturningAllSelector) - .upsertGraph( - nodes.map((node) => node.obj), - this.graphOptions.rebasedOptions(nodes[0]), - ), - ); - }); - }); - - return builders; - } - - /** - * The nodes inside the subgraph we are about to recursively upsert may - * have references outside that graph that won't be available during the - * recursive upsertGraph call. This method resolves the references. - * - * TODO: This doesn't work if a recursively upserted node refers to - * a node inside another recursively upsertable graph. - */ - _resolveReferences(node) { - node.obj.$traverse((obj) => this._resolveReferencesForObject(obj)); - } - - _resolveReferencesForObject(obj) { - this._resolveObjectReference(obj); - this._resolvePropertyReferences(obj); - } - - _resolveObjectReference(obj) { - const modelClass = obj.constructor; - const ref = obj[modelClass.uidRefProp]; - - if (!ref) { - return; - } - - const referencedNode = this.graph.nodes.find((it) => it.uid === ref); - - if (!referencedNode) { - return; - } - - const relationNames = referencedNode.modelClass.getRelationNames(); - - for (const prop of Object.keys(referencedNode.obj)) { - if (relationNames.includes(prop)) { - continue; - } - - obj[prop] = referencedNode.obj[prop]; - } - - delete obj[modelClass.uidRefProp]; - } - - _resolvePropertyReferences(obj) { - forEachPropertyReference(obj, ({ path, refMatch, ref, refPath }) => { - const referencedNode = this.graph.nodes.find((it) => it.uid === ref); - - if (!referencedNode) { - return; - } - - const referencedValue = get(referencedNode.obj, refPath); - const value = get(obj, path); - - if (value === refMatch) { - set(obj, path, referencedValue); - } else { - set(obj, path, value.replace(refMatch, referencedValue)); - } - }); - } -} - -function getRelation(node) { - return node.parentEdge.relation; -} - -function getParent(node) { - return node.parentNode; -} - -module.exports = { - GraphRecursiveUpsertAction, -}; diff --git a/lib/queryBuilder/join/JoinResultColumn.js b/lib/queryBuilder/join/JoinResultColumn.js index 76748814c..4f6c2928f 100644 --- a/lib/queryBuilder/join/JoinResultColumn.js +++ b/lib/queryBuilder/join/JoinResultColumn.js @@ -1,6 +1,4 @@ -'use strict'; - -class JoinResultColumn { +export class JoinResultColumn { constructor({ columnAlias, tableNode, name }) { this.columnAlias = columnAlias; this.tableNode = tableNode; @@ -17,7 +15,3 @@ class JoinResultColumn { }); } } - -module.exports = { - JoinResultColumn, -}; diff --git a/lib/queryBuilder/join/JoinResultParser.js b/lib/queryBuilder/join/JoinResultParser.js index 9712b5c4f..b569069ef 100644 --- a/lib/queryBuilder/join/JoinResultParser.js +++ b/lib/queryBuilder/join/JoinResultParser.js @@ -1,11 +1,10 @@ -'use strict'; +import { JoinResultColumn } from './JoinResultColumn.js'; +import { groupBy } from '../../utils/objectUtils.js'; -const { JoinResultColumn } = require('./JoinResultColumn'); -const { groupBy } = require('../../utils/objectUtils'); - -class JoinResultParser { - constructor({ tableTree, omitColumnAliases = [] }) { +export class JoinResultParser { + constructor({ tableTree, knex, omitColumnAliases = [] }) { this.tableTree = tableTree; + this.modelOptions = { knex }; this.omitColumnAliases = new Set(omitColumnAliases); this.columnsByTableNode = null; @@ -71,7 +70,7 @@ class JoinResultParser { } } - const model = tableNode.modelClass.fromDatabaseJson(row); + const model = tableNode.modelClass.fromDatabaseJson(row, this.modelOptions); for (const childNode of tableNode.childNodes) { if (childNode.relation.isOneToOne()) { @@ -140,7 +139,3 @@ function getKey(parentKey, id) { return escapedId; } } - -module.exports = { - JoinResultParser, -}; diff --git a/lib/queryBuilder/join/RelationJoiner.js b/lib/queryBuilder/join/RelationJoiner.js index 146571e9e..4a4a885b4 100644 --- a/lib/queryBuilder/join/RelationJoiner.js +++ b/lib/queryBuilder/join/RelationJoiner.js @@ -1,20 +1,18 @@ -'use strict'; +import { uniqBy } from '../../utils/objectUtils.js'; +import { Selection, isSameTable, mapIdentifier } from '../operations/select/Selection.js'; +import { SelectOperation } from '../operations/select/SelectOperation.js'; +import { createModifier } from '../../utils/createModifier.js'; +import { map as mapPromise } from '../../utils/promiseUtils/index.js'; +import { ValidationErrorType } from '../../model/ValidationError.js'; -const { uniqBy } = require('../../utils/objectUtils'); -const { Selection, isSameTable, mapIdentifier } = require('../operations/select/Selection'); -const { SelectOperation } = require('../operations/select/SelectOperation'); -const { createModifier } = require('../../utils/createModifier'); -const { map: mapPromise } = require('../../utils/promiseUtils'); -const { ValidationErrorType } = require('../../model/ValidationError'); - -const { TableTree } = require('./TableTree'); -const { JoinResultParser } = require('./JoinResultParser'); -const { getIdentifierLengthLimit } = require('./utils'); +import { TableTree } from './TableTree.js'; +import { JoinResultParser } from './JoinResultParser.js'; +import { getIdentifierLengthLimit } from './utils.js'; /** * Given a relation expression, builds a query using joins to fetch it. */ -class RelationJoiner { +export class RelationJoiner { constructor({ modelClass }) { this.rootModelClass = modelClass; @@ -99,6 +97,7 @@ class RelationJoiner { parseResult(builder, flatRows) { const parser = JoinResultParser.create({ tableTree: this._getTableTree(builder), + knex: builder.knex(), omitColumnAliases: this.internalSelections.map((it) => it.alias), }); @@ -132,7 +131,7 @@ class RelationJoiner { // also contain selects. userSelectQuery.modify(tableNode.relation.modify); - if (selectsOnlyAliasesKeepingSelectAll(findJoinSelections(userSelectQuery))) { + if (selectsOnlyAliasesKeepingSelectAll(userSelectQuery)) { // All columns are selected from the joined subquery in the main query, // so the subquery needs to select them too. subqueryToJoin.select(`${subqueryToJoin.tableRef()}.*`); @@ -142,8 +141,23 @@ class RelationJoiner { // for later use. userSelectQueries.set(tableNode, userSelectQuery); + const path = getRelationPath(tableNode); + // The `modifyJoin()` modifiers whose path ends at this relation: + const joinModifiers = builder + .joinModifiersAtPath() + .filter((it) => getExpressionAtPath(it.path, path)?.isEmpty) + .map((it) => it.modifier); + tableNode.relation.join(builder, { - joinOperation: this._getJoinOperation(tableNode), + joinOperation: this._getJoinOperation(path), + modifyJoin: + joinModifiers.length > 0 + ? (join, aliases) => { + for (const modifier of joinModifiers) { + modifier(join, aliases); + } + } + : undefined, ownerTable: tableNode.parentNode.alias, relatedTableAlias: tableNode.alias, @@ -161,18 +175,12 @@ class RelationJoiner { } // Returns the `joinOperation` of the last `withGraphJoined` call that - // contains the relation of `tableNode`, falling back to the default. - _getJoinOperation(tableNode) { - const path = []; - - for (let node = tableNode; node.parentNode; node = node.parentNode) { - path.unshift(node.relationProperty); - } - + // contains the relation path, falling back to the default. + _getJoinOperation(path) { for (let i = this.joinOperations.length - 1; i >= 0; --i) { const { expression, joinOperation } = this.joinOperations[i]; - if (containsPath(expression, path)) { + if (getExpressionAtPath(expression, path)) { return joinOperation; } } @@ -216,7 +224,7 @@ class RelationJoiner { const tableTree = this._getTableTree(builder); const userSelectionsByName = new Map(); - for (const selection of findJoinSelections(builder)) { + for (const selection of builder.findAllSelections()) { // Only the columns of the joined tables are known for `table.*`: const node = isSelectAll(selection) ? tableTree.nodesByAlias.get(selection.table) : null; const names = node @@ -276,16 +284,26 @@ function defaultOptions() { }; } -function containsPath(expression, path) { +function getRelationPath(tableNode) { + const path = []; + + for (let node = tableNode; node.parentNode; node = node.parentNode) { + path.unshift(node.relationProperty); + } + + return path; +} + +function getExpressionAtPath(expression, path) { for (const name of path) { expression = expression.childExpression(name); if (!expression) { - return false; + return null; } } - return true; + return expression; } function createSubqueryToJoin({ builder, tableNode, modifiers }) { @@ -328,9 +346,8 @@ function ensureIdAndRelationPropsAreSelected({ builder, tableNode }) { ), ]; - const hasSelectionAs = createHasSelectionAs(builder); const selectStrings = uniqBy(cols) - .filter((col) => !hasSelectionAs(col)) + .filter((col) => !builder.hasSelectionAs(col, col)) .map((col) => `${tableRef}.${col}`); return builder.select(selectStrings); @@ -338,8 +355,8 @@ function ensureIdAndRelationPropsAreSelected({ builder, tableNode }) { function getSelectionsForNode({ builder, tableNode, userSelectQueries }) { const userSelectQuery = userSelectQueries.get(tableNode); - const userSelections = findUserSelections(userSelectQuery, tableNode); - const userSelectedAllColumns = isSelectAllSelectionSet(userSelections); + const userSelections = userSelectQuery.findAllSelections(); + const userSelectedAllColumns = isSelectAllSelectionSet(userSelectQuery); let selections = []; let internalSelections = []; @@ -381,57 +398,15 @@ function getSelectionsForNode({ builder, tableNode, userSelectQueries }) { }; } -// Like `builder.findAllSelections()`, but aggregates select their aliases -// instead of the columns they're passed. Aggregates without an alias are left -// out, as their column name differs between databases. -function findJoinSelections(builder) { - let selections = []; - - builder.forEachOperation(true, (op) => { - if (op.constructor === SelectOperation) { - selections = selections.concat(op.aggregateSelections || op.selections); - } - }); - - return selections; -} - -// Create a `(column) => builder.hasSelectionAs(column, column)` function that -// ignores the columns passed to aggregates, as they don't select them. Clone -// the builder once, not once per column. -function createHasSelectionAs(builder) { - if (builder.has(isAggregateOperation)) { - const selectionQuery = builder.clone().clear(isAggregateOperation); - // Explicit, as a query with only aggregates doesn't select all columns. - return (column) => selectionQuery.hasSelectionAs(column, column, true); - } else { - return (column) => builder.hasSelectionAs(column, column); - } -} - -// Only the joined subqueries select aggregates by their aliases. The root -// query keeps treating the columns passed to them as selected. -function findUserSelections(builder, tableNode) { - if (tableNode.parentNode) { - return findJoinSelections(builder); - } else { - return builder.findAllSelections(); - } -} - function createHasUserSelectionAs(builder, tableNode) { if (tableNode.parentNode) { - return createHasSelectionAs(builder); + return (column) => builder.hasSelectionAs(column, column); } else { // Qualified, as the root query can also select the columns of the joined tables. return (column) => builder.hasSelectionAs(`${tableNode.alias}.${column}`, column); } } -function isAggregateOperation(op) { - return op.constructor === SelectOperation && op.aggregateSelections !== null; -} - function mapUserSelectionsFromSubqueryToMainQuery({ userSelections, tableNode }) { return userSelections.filter(isNotSelectAll).map((selection) => { return new Selection( @@ -490,7 +465,7 @@ function removeRootSelectAll(builder) { selection.column === '*' && (!selection.table || isSameTable(builder, selection.table, tableRef)); const isSelect = (op) => - op.constructor === SelectOperation && !isAggregateOperation(op) && !op.isDistinctOn; + op.constructor === SelectOperation && !op.isAggregate && !op.isDistinctOn; builder.forEachOperation(isSelect, (op) => { op.args = op.args.filter((arg) => { @@ -524,17 +499,28 @@ function isNotSelectAll(selection) { return selection.column !== '*'; } -function isSelectAllSelectionSet(selections) { - selections = selections.filter(isNotKeepingSelectAll); - return selections.length === 0 || selections.some(isSelectAll); +// Aggregates without an alias select no column that we can name, but they +// still replace the select all. +function isSelectAllSelectionSet(builder) { + const selections = builder.findAllSelections().filter(isNotKeepingSelectAll); + return (selections.length === 0 && !builder.has(isAggregate)) || selections.some(isSelectAll); } function isNotKeepingSelectAll(selection) { return !selection.keepsSelectAll; } -function selectsOnlyAliasesKeepingSelectAll(selections) { - return selections.length > 0 && selections.every((it) => it.keepsSelectAll); +function selectsOnlyAliasesKeepingSelectAll(builder) { + const selections = builder.findAllSelections(); + return ( + selections.length > 0 && + selections.every((it) => it.keepsSelectAll) && + !builder.has(isAggregate) + ); +} + +function isAggregate(op) { + return op.constructor === SelectOperation && op.isAggregate; } function checkAliasLength(modelClass, alias, lengthLimit) { @@ -547,7 +533,3 @@ function checkAliasLength(modelClass, alias, lengthLimit) { }); } } - -module.exports = { - RelationJoiner, -}; diff --git a/lib/queryBuilder/join/TableNode.js b/lib/queryBuilder/join/TableNode.js index 6c856cdfe..8f1f45b91 100644 --- a/lib/queryBuilder/join/TableNode.js +++ b/lib/queryBuilder/join/TableNode.js @@ -1,8 +1,6 @@ -'use strict'; +import { propKey, hasProps } from '../../model/modelValues.js'; -const { propKey, hasProps } = require('../../model/modelValues'); - -class TableNode { +export class TableNode { constructor({ tableTree, modelClass, expression, parentNode = null, relation = null }) { this.tableTree = tableTree; this.modelClass = modelClass; @@ -95,7 +93,3 @@ class TableNode { return (flatRow) => (hasProps(flatRow, columnAliases) ? propKey(flatRow, columnAliases) : null); } } - -module.exports = { - TableNode, -}; diff --git a/lib/queryBuilder/join/TableTree.js b/lib/queryBuilder/join/TableTree.js index 52d8f4e82..4ece20278 100644 --- a/lib/queryBuilder/join/TableTree.js +++ b/lib/queryBuilder/join/TableTree.js @@ -1,9 +1,7 @@ -'use strict'; +import { forEachChildExpression } from './utils.js'; +import { TableNode } from './TableNode.js'; -const { forEachChildExpression } = require('./utils'); -const { TableNode } = require('./TableNode'); - -class TableTree { +export class TableTree { constructor({ expression, rootModelClass, rootTableAlias, options }) { this.options = options; this.rootModelClass = rootModelClass; @@ -79,7 +77,3 @@ class TableTree { }); } } - -module.exports = { - TableTree, -}; diff --git a/lib/queryBuilder/join/utils.js b/lib/queryBuilder/join/utils.js index f9ac1417f..311b8a526 100644 --- a/lib/queryBuilder/join/utils.js +++ b/lib/queryBuilder/join/utils.js @@ -1,14 +1,12 @@ -'use strict'; +import { ValidationErrorType } from '../../model/ValidationError.js'; +import { isPostgres, isMySql, isMsSql, isSqlite } from '../../utils/knexUtils.js'; -const { ValidationErrorType } = require('../../model/ValidationError'); -const { isPostgres, isMySql, isMsSql, isSqlite } = require('../../utils/knexUtils'); - -const RELATION_RECURSION_LIMIT = 64; +export const RELATION_RECURSION_LIMIT = 64; // The maximum length of the column aliases of the joined relations, after // which the database would truncate them. Other databases keep the limit of // postgres, which isn't the strictest one: Oracle before 12.2 only allows 30. -function getIdentifierLengthLimit(knex) { +export function getIdentifierLengthLimit(knex) { if (isPostgres(knex)) { return 63; } else if (isMySql(knex)) { @@ -23,7 +21,7 @@ function getIdentifierLengthLimit(knex) { } // Given a relation expression, goes through all first level children. -function forEachChildExpression(expr, modelClass, callback) { +export function forEachChildExpression(expr, modelClass, callback) { if (expr.node.$allRecursive || expr.maxRecursionDepth > RELATION_RECURSION_LIMIT) { throw modelClass.createValidationError({ type: ValidationErrorType.RelationExpression, @@ -33,11 +31,3 @@ function forEachChildExpression(expr, modelClass, callback) { expr.forEachChildExpression(modelClass, callback); } - -module.exports = { - RELATION_RECURSION_LIMIT, - - getIdentifierLengthLimit, - - forEachChildExpression, -}; diff --git a/lib/queryBuilder/jsonDialects/JsonDialect.js b/lib/queryBuilder/jsonDialects/JsonDialect.js new file mode 100644 index 000000000..5f9656826 --- /dev/null +++ b/lib/queryBuilder/jsonDialects/JsonDialect.js @@ -0,0 +1,51 @@ +// Renders the SQL of json field expressions like `ref('column:a.b')`, their +// casts and the json values of `val()` for one database. The json methods are +// implemented by the subclasses of the databases that support them. +export class JsonDialect { + // Throw or warn for databases that can't render json field expressions. + // @overridable + checkSupport() {} + + // @overridable + cast(sql, type) { + return `CAST(${sql} AS ${type})`; + } + + // Turn the json text of a binding into a json value. + // @overridable + json(sql) { + return sql; + } + + // Reference the bare column if there is no path, so that the database can use + // its indexes. + field(sql, access) { + return access.length > 0 ? this.extract(sql, access) : sql; + } + + // Turn the operands of `superset()` and `subset()` into json values. + // @overridable + jsonOperand(sql) { + return sql; + } + + // The operands are pairs of SQL and bindings, so they can swap places: + // @overridable + subset(left, right) { + return this.superset(right, left); + } + + // Return the SQL and bindings, as the column may appear more than once. + // @overridable + hasKeys(column, access, keys, all) { + if (all) { + const parts = keys.map((key) => this.hasAnyKey(column, access, [key])); + return [ + `(${parts.map(([sql]) => sql).join(' and ')})`, + parts.flatMap(([, bindings]) => bindings), + ]; + } else { + return this.hasAnyKey(column, access, keys); + } + } +} diff --git a/lib/queryBuilder/jsonDialects/MySqlJsonDialect.js b/lib/queryBuilder/jsonDialects/MySqlJsonDialect.js new file mode 100644 index 000000000..afa5817c6 --- /dev/null +++ b/lib/queryBuilder/jsonDialects/MySqlJsonDialect.js @@ -0,0 +1,81 @@ +import { JsonDialect } from './JsonDialect.js'; +import { toJsonPath } from '../../utils/parseFieldExpression.js'; + +// MySQL can only cast to these types, see `CAST()` in its manual. `float` is a +// double precision number in Postgres, `real` a single precision one, and the +// precision of `decimal` is the maximum, as MySQL's default rounds to integers. +const castTypes = { + text: 'CHAR', + integer: 'SIGNED', + bigint: 'SIGNED', + boolean: 'SIGNED', + float: 'DOUBLE', + real: 'FLOAT', + decimal: 'DECIMAL(65,30)', + jsonb: 'JSON', +}; + +export class MySqlJsonDialect extends JsonDialect { + // Casting json values works for all types but text, where json strings need + // to be unquoted. Json null is cast to 0 or 'null', so turn it into NULL like + // Postgres and SQLite do. + extract(sql, access, cast) { + const value = `JSON_EXTRACT(${sql}, ${toMySqlJsonPathLiteral(access)})`; + if (cast) { + const nullable = `NULLIF(${value}, CAST('null' AS JSON))`; + return /^char\b/i.test(castTypes[cast] || cast) ? `JSON_UNQUOTE(${nullable})` : nullable; + } else { + return value; + } + } + + // Like `to_jsonb()`, turn SQL values into json and keep json values as they are: + toJson(sql) { + return `JSON_EXTRACT(JSON_ARRAY(${sql}), '$[0]')`; + } + + set(sql, access, value) { + return `JSON_SET(${sql}, ${toMySqlJsonPathLiteral(access)}, ${value})`; + } + + superset([left, leftBindings], [right, rightBindings]) { + return [`JSON_CONTAINS(${left}, ${right})`, [...leftBindings, ...rightBindings]]; + } + + isType(sql, access, type) { + return `JSON_TYPE(${this.field(sql, access)}) = '${type.toUpperCase()}'`; + } + + // Like the `?|` operator of Postgres, find the keys of objects, the strings in + // arrays and strings: + hasAnyKey(column, access, keys) { + const value = this.field('??', access); + return [ + `JSON_OVERLAPS(COALESCE(JSON_KEYS(${value}), ${value}), ` + + `JSON_ARRAY(${keys.map(() => '?').join(', ')}))`, + [column, column, ...keys], + ]; + } + + // @override + cast(sql, type) { + return super.cast(sql, castTypes[type] || type); + } + + // @override + json(sql) { + return `CAST(${sql} AS JSON)`; + } +} + +// Formats the json path of a parsed field expression as a string literal, e.g. +// `'$."a"[0]'`. Like in `toPostgresJsonPathLiteral()`, the path can't be a +// binding. Paths with `?` or backslashes are written as hex literals: the mysql +// driver reads every `?` as a binding placeholder, and whether backslashes +// escape in string literals depends on the `NO_BACKSLASH_ESCAPES` SQL mode. +function toMySqlJsonPathLiteral(access) { + const path = toJsonPath(access); + return /[?\\]/.test(path) + ? `_utf8mb4 X'${Buffer.from(path).toString('hex')}'` + : `'${path.replace(/'/g, "''")}'`; +} diff --git a/lib/queryBuilder/jsonDialects/PostgresJsonDialect.js b/lib/queryBuilder/jsonDialects/PostgresJsonDialect.js new file mode 100644 index 000000000..5b6f4e8fa --- /dev/null +++ b/lib/queryBuilder/jsonDialects/PostgresJsonDialect.js @@ -0,0 +1,45 @@ +import { JsonDialect } from './JsonDialect.js'; +import { toPostgresJsonPathLiteral } from '../../utils/parseFieldExpression.js'; + +export class PostgresJsonDialect extends JsonDialect { + // Casts need the value as text, e.g. `CAST('"a"'::jsonb AS text)` would keep + // the quotes. + extract(sql, access, cast) { + return `${sql}${cast ? '#>>' : '#>'}${toPostgresJsonPathLiteral(access)}`; + } + + toJson(sql) { + return `to_jsonb(${sql})`; + } + + set(sql, access, value) { + return `jsonb_set(${sql}, ${toPostgresJsonPathLiteral(access)}, ${value}, true)`; + } + + // The json operators need jsonb, also for json columns: + // @override + jsonOperand(sql) { + return `( ${sql} )::jsonb`; + } + + superset([left, leftBindings], [right, rightBindings]) { + return [`${left} @> ${right}`, [...leftBindings, ...rightBindings]]; + } + + // @override + subset([left, leftBindings], [right, rightBindings]) { + return [`${left} <@ ${right}`, [...leftBindings, ...rightBindings]]; + } + + isType(sql, access, type) { + return `jsonb_typeof(${this.jsonOperand(this.field(sql, access))}) = '${type}'`; + } + + // @override + hasKeys(column, access, keys, all) { + return [ + `${this.field('??', access)} ${all ? '\\?&' : '\\?|'} array[${keys.map(() => '?').join(',')}]`, + [column, ...keys], + ]; + } +} diff --git a/lib/queryBuilder/jsonDialects/SqliteJsonDialect.js b/lib/queryBuilder/jsonDialects/SqliteJsonDialect.js new file mode 100644 index 000000000..a1d614d2c --- /dev/null +++ b/lib/queryBuilder/jsonDialects/SqliteJsonDialect.js @@ -0,0 +1,67 @@ +import { JsonDialect } from './JsonDialect.js'; +import { toJsonPath } from '../../utils/parseFieldExpression.js'; + +export class SqliteJsonDialect extends JsonDialect { + // `json_extract()` returns SQL values, which compare and sort like json + // values, unlike the json text that `->` returns. + extract(sql, access) { + return `json_extract(${sql}, ${toSqliteJsonPathLiteral(access)})`; + } + + // `json_quote()` keeps the values of json functions as json, but SQLite can't + // tell json columns from text columns, which it would quote: + toJson(sql, isColumn) { + if (isColumn) { + throw new Error( + "ref().castJson() needs a json path or a cast on SQLite, which can't tell json columns " + + 'from text columns.', + ); + } + return `json_quote(${sql})`; + } + + set(sql, access, value) { + return `json_set(${sql}, ${toSqliteJsonPathLiteral(access)}, ${value})`; + } + + superset() { + throw new Error( + 'whereJsonSupersetOf() and whereJsonSubsetOf() are not supported on SQLite, which has no ' + + 'json containment.', + ); + } + + isType(sql, access, type) { + return `json_type(${sql}, ${toSqliteJsonPathLiteral(access)}) = '${type}'`; + } + + // Like the `?|` operator of Postgres, find the keys of objects, the strings in + // arrays and strings. The keys of objects are text, those of arrays integers: + hasAnyKey(column, access, keys) { + return [ + `exists (select 1 from json_each(??, ${toSqliteJsonPathLiteral(access)}) ` + + "where case when typeof(json_each.key) = 'text' then json_each.key " + + `when json_each.type = 'text' then json_each.value end in (${keys.map(() => '?').join(', ')}))`, + [column, ...keys], + ]; + } + + // SQLite accepts any type name, but not as json: + // @override + cast(sql, type) { + return type === 'jsonb' ? this.json(sql) : super.cast(sql, type); + } + + // @override + json(sql) { + return `json(${sql})`; + } +} + +// Formats the json path of a parsed field expression as a string literal, e.g. +// `'$."a"[0]'`. Like in `toPostgresJsonPathLiteral()`, the path can't be a +// binding, and knex reads every `?` in it as a binding placeholder. SQLite +// understands json escapes in quoted keys, so `?` is written as `\u003f`. +function toSqliteJsonPathLiteral(access) { + return `'${toJsonPath(access).replace(/\?/g, '\\u003f').replace(/'/g, "''")}'`; +} diff --git a/lib/queryBuilder/jsonDialects/index.js b/lib/queryBuilder/jsonDialects/index.js new file mode 100644 index 000000000..5ed91d81f --- /dev/null +++ b/lib/queryBuilder/jsonDialects/index.js @@ -0,0 +1,44 @@ +import { JsonDialect } from './JsonDialect.js'; +import { PostgresJsonDialect } from './PostgresJsonDialect.js'; +import { MySqlJsonDialect } from './MySqlJsonDialect.js'; +import { SqliteJsonDialect } from './SqliteJsonDialect.js'; +import { isPostgres, isMySql, isSqlite, isMsSql, isOracle } from '../../utils/knexUtils.js'; +import { warnOnce } from '../../utils/warnings.js'; + +const message = + 'JSON field expressions and the whereJson*() methods of objection are only supported on ' + + 'PostgreSQL, MySQL and SQLite. Use the JSON methods of knex like whereJsonPath() or ' + + 'jsonExtract() with other databases.'; + +class UnsupportedJsonDialect extends JsonDialect { + // @override + checkSupport() { + throw new Error(message); + } +} + +// Other databases may understand the SQL of Postgres, so only warn: +class UnknownJsonDialect extends PostgresJsonDialect { + // @override + checkSupport() { + warnOnce(message); + } +} + +const postgres = new PostgresJsonDialect(); +const mySql = new MySqlJsonDialect(); +const sqlite = new SqliteJsonDialect(); +const unsupported = new UnsupportedJsonDialect(); +const unknown = new UnknownJsonDialect(); + +export function getJsonDialect(knex) { + return isPostgres(knex) + ? postgres + : isMySql(knex) + ? mySql + : isSqlite(knex) + ? sqlite + : isMsSql(knex) || isOracle(knex) + ? unsupported + : unknown; +} diff --git a/lib/queryBuilder/operations/DelegateOperation.js b/lib/queryBuilder/operations/DelegateOperation.js index 687dd11ba..6c123b837 100644 --- a/lib/queryBuilder/operations/DelegateOperation.js +++ b/lib/queryBuilder/operations/DelegateOperation.js @@ -1,10 +1,8 @@ -'use strict'; - -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; // Operation that simply delegates all calls to the operation passed // to to the constructor in `opt.delegate`. -class DelegateOperation extends QueryBuilderOperation { +export class DelegateOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); @@ -157,7 +155,3 @@ class DelegateOperation extends QueryBuilderOperation { return clone; } } - -module.exports = { - DelegateOperation, -}; diff --git a/lib/queryBuilder/operations/DeleteOperation.js b/lib/queryBuilder/operations/DeleteOperation.js index 8d77992e1..264a576b3 100644 --- a/lib/queryBuilder/operations/DeleteOperation.js +++ b/lib/queryBuilder/operations/DeleteOperation.js @@ -1,30 +1,24 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; +import { StaticHookArguments } from '../StaticHookArguments.js'; +import { isMySql } from '../../utils/knexUtils.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); -const { StaticHookArguments } = require('../StaticHookArguments'); -const { isMySql } = require('../../utils/knexUtils'); -const { warnOnce } = require('../../utils/warnings'); - -class DeleteOperation extends QueryBuilderOperation { +export class DeleteOperation extends QueryBuilderOperation { async onBefore2(builder, result) { await callBeforeDelete(builder); return result; } // knex leaves the offset out of deletes. On MySQL, it leaves out the order as - // well, but keeps the limit, so the order doesn't pick the deleted rows. Warn - // when the query is executed. + // well, but keeps the limit, so the order doesn't pick the deleted rows. + // Throw when the query is executed. onAfterBuild(builder) { if (builder.has('offset')) { - warnOnce( - 'offset() has no effect in delete queries, so no rows are skipped. ' + - 'This will throw an error in objection 4.0.', - ); + throw new Error('offset() is not supported in delete queries, as knex leaves it out.'); } if (isMySql(builder.knex()) && builder.has('limit') && builder.has(/^orderBy/)) { - warnOnce( - 'orderBy() has no effect in delete queries with limit() on MySQL, so the limit ' + - 'deletes rows in any order. This will throw an error in objection 4.0.', + throw new Error( + 'orderBy() is not supported in delete queries with limit() on MySQL, ' + + 'as knex leaves out the order.', ); } } @@ -33,10 +27,6 @@ class DeleteOperation extends QueryBuilderOperation { return knexBuilder.delete(); } - onAfter2(builder, result) { - return callAfterDelete(builder, result); - } - toFindOperation() { return null; } @@ -50,22 +40,3 @@ function callStaticBeforeDelete(builder) { const args = StaticHookArguments.create({ builder }); return builder.modelClass().beforeDelete(args); } - -function callAfterDelete(builder, result) { - return callStaticAfterDelete(builder, result); -} - -async function callStaticAfterDelete(builder, result) { - const args = StaticHookArguments.create({ builder, result }); - const maybeResult = await builder.modelClass().afterDelete(args); - - if (maybeResult === undefined) { - return result; - } else { - return maybeResult; - } -} - -module.exports = { - DeleteOperation, -}; diff --git a/lib/queryBuilder/operations/FindByIdOperation.js b/lib/queryBuilder/operations/FindByIdOperation.js index aaccaab3e..3f7abb0bc 100644 --- a/lib/queryBuilder/operations/FindByIdOperation.js +++ b/lib/queryBuilder/operations/FindByIdOperation.js @@ -1,12 +1,13 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; +import { assertIdNotUndefined } from '../../utils/assert.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); -const { assertIdNotUndefined } = require('../../utils/assert'); - -class FindByIdOperation extends QueryBuilderOperation { +export class FindByIdOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); this.id = this.opt.id; + // Keep the id condition in its own where group, so that it applies to + // whatever the user adds, `orWhere()` included. See `whereGroups.js`. + this.whereGroup = {}; } onAdd(builder, args) { @@ -18,9 +19,7 @@ class FindByIdOperation extends QueryBuilderOperation { } onBuild(builder) { - if (!builder.internalOptions().skipUndefined) { - assertIdNotUndefined(this.id, `undefined was passed to ${this.name}`); - } + assertIdNotUndefined(this.id, `undefined was passed to ${this.name}`); builder.whereComposite(builder.fullIdColumn(), this.id); } @@ -31,7 +30,3 @@ class FindByIdOperation extends QueryBuilderOperation { return clone; } } - -module.exports = { - FindByIdOperation, -}; diff --git a/lib/queryBuilder/operations/FindByIdsOperation.js b/lib/queryBuilder/operations/FindByIdsOperation.js index e38f15c96..a48c292bc 100644 --- a/lib/queryBuilder/operations/FindByIdsOperation.js +++ b/lib/queryBuilder/operations/FindByIdsOperation.js @@ -1,11 +1,11 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); - -class FindByIdsOperation extends QueryBuilderOperation { +export class FindByIdsOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); this.ids = null; + // Its own where group, like `FindByIdOperation`. + this.whereGroup = {}; } onAdd(builder, args) { @@ -23,7 +23,3 @@ class FindByIdsOperation extends QueryBuilderOperation { return clone; } } - -module.exports = { - FindByIdsOperation, -}; diff --git a/lib/queryBuilder/operations/FindOperation.js b/lib/queryBuilder/operations/FindOperation.js index d46e76aa8..1df77aebc 100644 --- a/lib/queryBuilder/operations/FindOperation.js +++ b/lib/queryBuilder/operations/FindOperation.js @@ -1,22 +1,26 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; +import { StaticHookArguments, isGraphQuery } from '../StaticHookArguments.js'; +import { isPromise, afterReturn } from '../../utils/promiseUtils/index.js'; +import { isObject } from '../../utils/objectUtils.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); -const { StaticHookArguments } = require('../StaticHookArguments'); -const { isPromise, after, afterReturn } = require('../../utils/promiseUtils'); -const { isObject } = require('../../utils/objectUtils'); - -class FindOperation extends QueryBuilderOperation { +export class FindOperation extends QueryBuilderOperation { onBefore2(builder, result) { - return afterReturn(callStaticBeforeFind(builder), result); + if (isGraphQuery(builder) || builder.findOptions().dontCallFindHooks) { + return result; + } else { + return afterReturn(callStaticBeforeFind(builder), result); + } } onAfter3(builder, results) { const opt = builder.findOptions(); - if (opt.dontCallFindHooks) { + // The outer queries of `insertGraph()` and `upsertGraph()` return the + // input models, unless they read the written rows back with `returning()`: + if ((isGraphQuery(builder) && !builder.has('returning')) || opt.dontCallFindHooks) { return results; } else { - return callAfterFind(builder, results); + return callInstanceAfterFind(builder.context(), results, opt.callAfterFindDeeply); } } } @@ -26,26 +30,6 @@ function callStaticBeforeFind(builder) { return builder.modelClass().beforeFind(args); } -function callAfterFind(builder, result) { - const opt = builder.findOptions(); - const maybePromise = callInstanceAfterFind(builder.context(), result, opt.callAfterFindDeeply); - - return after(maybePromise, () => callStaticAfterFind(builder, result)); -} - -function callStaticAfterFind(builder, result) { - const args = StaticHookArguments.create({ builder, result }); - const maybePromise = builder.modelClass().afterFind(args); - - return after(maybePromise, (maybeResult) => { - if (maybeResult === undefined) { - return result; - } else { - return maybeResult; - } - }); -} - function callInstanceAfterFind(ctx, results, deep) { if (Array.isArray(results)) { if (results.length === 1) { @@ -158,7 +142,3 @@ function getAfterFindHook(model) { return null; } } - -module.exports = { - FindOperation, -}; diff --git a/lib/queryBuilder/operations/FirstOperation.js b/lib/queryBuilder/operations/FirstOperation.js index 41600a72a..404661863 100644 --- a/lib/queryBuilder/operations/FirstOperation.js +++ b/lib/queryBuilder/operations/FirstOperation.js @@ -1,8 +1,7 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; +import { LimitPerParentOperation } from './LimitPerParentOperation.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); - -class FirstOperation extends QueryBuilderOperation { +export class FirstOperation extends QueryBuilderOperation { shouldLimit(builder) { return builder.isFind() && builder.modelClass().useLimitInFirst; } @@ -15,15 +14,12 @@ class FirstOperation extends QueryBuilderOperation { return knexBuilder; } - onAfter3(_, result) { - if (Array.isArray(result)) { + onAfter3(builder, result) { + // Limited per parent, the query still returns the models of all parents: + if (Array.isArray(result) && !builder.has(LimitPerParentOperation)) { return result[0]; } else { return result; } } } - -module.exports = { - FirstOperation, -}; diff --git a/lib/queryBuilder/operations/FromOperation.js b/lib/queryBuilder/operations/FromOperation.js index d58278ae9..a9f1ebcbc 100644 --- a/lib/queryBuilder/operations/FromOperation.js +++ b/lib/queryBuilder/operations/FromOperation.js @@ -1,7 +1,5 @@ -'use strict'; - -const { ObjectionToKnexConvertingOperation } = require('./ObjectionToKnexConvertingOperation'); -const { isPlainObject, isString } = require('../../utils/objectUtils'); +import { ObjectionToKnexConvertingOperation } from './ObjectionToKnexConvertingOperation.js'; +import { isPlainObject, isString } from '../../utils/objectUtils.js'; const ALIAS_REGEX = /\s+as\s+/i; @@ -9,7 +7,7 @@ const ALIAS_REGEX = /\s+as\s+/i; // knex, but we first try to parse the arguments so that we can determine which // tables have been mentioned in a query's from clause. We only parse string // references and not `raw` or `ref` etc. references at this point thouhg. -class FromOperation extends ObjectionToKnexConvertingOperation { +export class FromOperation extends ObjectionToKnexConvertingOperation { constructor(name, opt) { super(name, opt); @@ -95,7 +93,3 @@ function parseTableAndAliasFromObject(arg, builder) { `one of the tables in ${JSON.stringify(arg)} must be the query's model class's table.`, ); } - -module.exports = { - FromOperation, -}; diff --git a/lib/queryBuilder/operations/InsertAndFetchOperation.js b/lib/queryBuilder/operations/InsertAndFetchOperation.js index e7654d5d8..f30482b61 100644 --- a/lib/queryBuilder/operations/InsertAndFetchOperation.js +++ b/lib/queryBuilder/operations/InsertAndFetchOperation.js @@ -1,11 +1,9 @@ -'use strict'; +import { InsertOperation } from './InsertOperation.js'; +import { DelegateOperation } from './DelegateOperation.js'; +import { keyByProps } from '../../model/modelUtils.js'; +import { asArray } from '../../utils/objectUtils.js'; -const { InsertOperation } = require('./InsertOperation'); -const { DelegateOperation } = require('./DelegateOperation'); -const { keyByProps } = require('../../model/modelUtils'); -const { asArray } = require('../../utils/objectUtils'); - -class InsertAndFetchOperation extends DelegateOperation { +export class InsertAndFetchOperation extends DelegateOperation { constructor(name, opt) { super(name, opt); @@ -24,7 +22,7 @@ class InsertAndFetchOperation extends DelegateOperation { // Models without identifiers can't be fetched. This happens when a row // with a database generated identifier is ignored by `onConflict()`. - const insertedModelArray = asArray(insertedModels).filter((model) => model && model.$hasId()); + const insertedModelArray = asArray(insertedModels).filter((model) => model.$hasId()); const idProps = modelClass.getIdPropertyArray(); const ids = insertedModelArray.map((model) => model.$id()); @@ -38,6 +36,13 @@ class InsertAndFetchOperation extends DelegateOperation { .findByIds(ids) .castTo(builder.resultModelClass()); + if (!Array.isArray(fetchedModels) || !fetchedModels.every((model) => model.$isObjectionModel)) { + throw new Error( + `Unable to merge the result of ${modelClass.name}.afterFind() in ` + + `${this.name}(): expected an array of models.`, + ); + } + const modelsById = keyByProps(fetchedModels, idProps); // Instead of returning the freshly fetched models, update the input @@ -49,7 +54,3 @@ class InsertAndFetchOperation extends DelegateOperation { return insertedModels; } } - -module.exports = { - InsertAndFetchOperation, -}; diff --git a/lib/queryBuilder/operations/InsertGraphAndFetchOperation.js b/lib/queryBuilder/operations/InsertGraphAndFetchOperation.js index c57022003..2d72258eb 100644 --- a/lib/queryBuilder/operations/InsertGraphAndFetchOperation.js +++ b/lib/queryBuilder/operations/InsertGraphAndFetchOperation.js @@ -1,10 +1,8 @@ -'use strict'; +import { DelegateOperation } from './DelegateOperation.js'; +import { InsertGraphOperation } from './InsertGraphOperation.js'; +import { RelationExpression } from '../RelationExpression.js'; -const { DelegateOperation } = require('./DelegateOperation'); -const { InsertGraphOperation } = require('./InsertGraphOperation'); -const { RelationExpression } = require('../RelationExpression'); - -class InsertGraphAndFetchOperation extends DelegateOperation { +export class InsertGraphAndFetchOperation extends DelegateOperation { constructor(name, opt) { super(name, opt); @@ -39,7 +37,3 @@ class InsertGraphAndFetchOperation extends DelegateOperation { return this.isArray ? models : models[0] || null; } } - -module.exports = { - InsertGraphAndFetchOperation, -}; diff --git a/lib/queryBuilder/operations/InsertGraphOperation.js b/lib/queryBuilder/operations/InsertGraphOperation.js index 857d6dddd..a6a77c7c0 100644 --- a/lib/queryBuilder/operations/InsertGraphOperation.js +++ b/lib/queryBuilder/operations/InsertGraphOperation.js @@ -1,10 +1,8 @@ -'use strict'; +import { DelegateOperation } from './DelegateOperation.js'; +import { InsertOperation } from './InsertOperation.js'; +import { GraphUpsert } from '../graph/GraphUpsert.js'; -const { DelegateOperation } = require('./DelegateOperation'); -const { InsertOperation } = require('./InsertOperation'); -const { GraphUpsert } = require('../graph/GraphUpsert'); - -class InsertGraphOperation extends DelegateOperation { +export class InsertGraphOperation extends DelegateOperation { constructor(name, opt = null) { super(name, opt); @@ -33,15 +31,10 @@ class InsertGraphOperation extends DelegateOperation { const retVal = super.onAdd(builder, args); this.upsert = new GraphUpsert({ - objects: this.models, + objects: this.isArray ? this.models : this.models[0], rootModelClass: builder.modelClass(), - upsertOptions: Object.assign({}, this.upsertOptions, { - noUpdate: true, - noDelete: true, - noUnrelate: true, - insertMissing: true, - }), + upsertOptions: this.upsertOptions, insertOnly: true, }); @@ -53,7 +46,7 @@ class InsertGraphOperation extends DelegateOperation { } onBefore1(builder, result) { - GraphUpsert.warnOnConflict(builder, this.name); + GraphUpsert.checkForOnConflict(builder, this.name); return result; } @@ -108,7 +101,3 @@ class InsertGraphOperation extends DelegateOperation { return clone; } } - -module.exports = { - InsertGraphOperation, -}; diff --git a/lib/queryBuilder/operations/InsertOperation.js b/lib/queryBuilder/operations/InsertOperation.js index 9bfa4743d..429bac827 100644 --- a/lib/queryBuilder/operations/InsertOperation.js +++ b/lib/queryBuilder/operations/InsertOperation.js @@ -1,18 +1,24 @@ -'use strict'; - -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); -const { StaticHookArguments } = require('../StaticHookArguments'); -const { after, mapAfterAllReturn } = require('../../utils/promiseUtils'); -const { isSqlite, isMySql, hasBatchInsert } = require('../../utils/knexUtils'); -const { isObject, isString } = require('../../utils/objectUtils'); -const { removeOverriddenValues } = require('../../model/modelQueryProps'); +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; +import { StaticHookArguments } from '../StaticHookArguments.js'; +import { after, mapAfterAllReturn } from '../../utils/promiseUtils/index.js'; +import { + isSqlite, + isMySql, + hasBatchInsert, + hasBatchInsertDefaults, + getMaxInsertRows, +} from '../../utils/knexUtils.js'; +import { chunk, groupBy, isObject, isPlainObject, isString } from '../../utils/objectUtils.js'; +import { removeOverriddenValues } from '../../model/modelQueryProps.js'; +import { checkFieldExpressions } from './fieldExpressions.js'; // Base class for all insert operations. -class InsertOperation extends QueryBuilderOperation { +export class InsertOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); this.models = null; + this.batches = null; this.isArray = false; this.modelOptions = Object.assign({}, this.opt.modelOptions || {}); } @@ -28,13 +34,58 @@ class InsertOperation extends QueryBuilderOperation { } async onBefore2(builder, result) { - if (this.models.length > 1 && !hasBatchInsert(builder.knex())) { - throw new Error('batch insert only works with Postgresql and SQL Server'); - } else { - await callBeforeInsert(builder, this.models); - this.models.forEach(removeOverriddenValues); - return result; + await callBeforeInsert(builder, this.models); + + // Without ids to return, rows can be inserted in batches on all databases. + // The hooks may give the models their ids, or cancel the query. + if ( + this.models.length > 1 && + !hasBatchInsert(builder.knex()) && + !builder.isExplicitlyResolvedOrRejected() && + this._hasIdsToReturn(builder) + ) { + throw new Error( + 'batch insert only works with Postgresql, SQL Server and SQLite, or with models that have their ids or no idColumn', + ); + } + + this.models.forEach(removeOverriddenValues); + return result; + } + + // Insert the models in several queries if they don't fit into one: Models + // without any column can't be inserted in batches (#1694), where the database + // doesn't fill in defaults (SQLite) the rows of a batch need the same columns, + // and a query can only bind so many values. The queries run in the caller's + // transaction, if there is one. Without it they are not atomic. + queryBatches(builder) { + const models = this.models; + this.batches = null; + + if (models.length < 2) { + return null; + } + + const knex = builder.knex(); + const columnsByModel = new Map( + models.map((model) => [model, Object.keys(model.$toDatabaseJson(builder))]), + ); + const batches = []; + + for (const group of groupBy(models, (model) => + getColumnsKey(knex, columnsByModel.get(model)), + ).values()) { + const columnCount = Math.max(...group.map((model) => columnsByModel.get(model).length)); + + if (columnCount === 0) { + batches.push(...group.map((model) => [model])); + } else { + batches.push(...chunk(group, getMaxInsertRows(knex, columnCount))); + } } + + this.batches = batches.length > 1 ? batches : null; + return this.batches; } onBuildKnex(knexBuilder, builder) { @@ -49,15 +100,37 @@ class InsertOperation extends QueryBuilderOperation { // conflict columns are returned too, so that the returned rows can // be matched to the models when some of the rows are ignored. knexBuilder = knexBuilder.returning(defaultReturning); - } else if (isSqlite(knex) && builder.has('onConflict')) { + } else if ( + isSqlite(knex) && + (builder.has('onConflict') || (this.models.length > 1 && this._hasIdsToReturn(builder))) + ) { // Without a `returning` clause, SQLite returns the id of the last // inserted row, which belongs to a previous insert if the row was - // ignored. Use `returning` to find out what was actually inserted. + // ignored, and only one id for batch inserts. Use `returning` to find + // out what was actually inserted. knexBuilder = knexBuilder.returning(defaultReturning); } } - return knexBuilder.insert(this.models.map((model) => model.$toDatabaseJson(builder))); + const modelClass = builder.modelClass(); + // knex leaves arrays unserialized in SQLite inserts, and also objects when + // it inserts several rows with `select ... union all`. + const shouldSerialize = isSqlite(knex); + + return knexBuilder.insert( + (builder._queryBatch || this.models).map((model) => { + checkFieldExpressions(modelClass, model, 'insert'); + const json = model.$toDatabaseJson(builder); + return shouldSerialize ? serializeObjects(json) : json; + }), + ); + } + + _hasIdsToReturn(builder) { + return ( + builder.modelClass().getIdColumnArray().length > 0 && + !this.models.every((model) => model.$hasId()) + ); } onAfter1(builder, ret) { @@ -67,6 +140,8 @@ class InsertOperation extends QueryBuilderOperation { } const isOnConflict = builder.has('onConflict'); + // The results of the batches are in the order of the batches. + const models = this.batches ? this.batches.flat() : this.models; if (isObject(ret[0])) { if (isOnConflict && ret.length !== this.models.length) { @@ -77,14 +152,15 @@ class InsertOperation extends QueryBuilderOperation { } else { // If the user specified a `returning` clause the result may be an array of objects. // Merge all values of the objects to our models. - for (let i = 0, l = this.models.length; i < l; ++i) { - this.models[i].$setDatabaseJson(ret[i]); + const options = { knex: builder.knex() }; + for (let i = 0, l = models.length; i < l; ++i) { + models[i].$setDatabaseJson(ret[i], options); } } } else if (builder.modelClass().getIdColumnArray().length > 0) { // If the return value is not an array of objects, we assume it is an array of identifiers. - for (let i = 0, l = this.models.length; i < l; ++i) { - const model = this.models[i]; + for (let i = 0, l = models.length; i < l; ++i) { + const model = models[i]; // Don't set the id if the model already has one. MySQL and Sqlite don't return the correct // primary key value if the id is not generated in db, but given explicitly. With @@ -98,9 +174,9 @@ class InsertOperation extends QueryBuilderOperation { return this.models; } - onAfter2(builder, models) { - const result = this.isArray ? models : models[0] || null; - return callAfterInsert(builder, this.models, result); + async onAfter2(builder, models) { + await callInstanceAfterInsert(builder, this.models); + return this.isArray ? models : models[0] || null; } toFindOperation() { @@ -111,12 +187,19 @@ class InsertOperation extends QueryBuilderOperation { const clone = super.clone(); clone.models = this.models; + clone.batches = this.batches; clone.isArray = this.isArray; return clone; } } +// Rows are only grouped where the database doesn't fill in the defaults of the +// columns that a row leaves out. +function getColumnsKey(knex, columns) { + return hasBatchInsertDefaults(knex) ? '' : JSON.stringify([...columns].sort()); +} + // Returns `null` if there's nothing to return, e.g. for models without an `idColumn`. function getDefaultReturning(builder) { const idColumns = builder.modelClass().getIdColumnArray(); @@ -180,6 +263,8 @@ function mergeMatchingRows(builder, models, rows) { } }); + const options = { knex: builder.knex() }; + for (const row of rows) { const indices = indicesByKey.get(createKey(row)); const index = indices ? indices.shift() : undefined; @@ -188,7 +273,7 @@ function mergeMatchingRows(builder, models, rows) { throw createMatchError(); } - models[index].$setDatabaseJson(row); + models[index].$setDatabaseJson(row, options); } } @@ -224,28 +309,16 @@ function callStaticBeforeInsert(builder) { return builder.modelClass().beforeInsert(args); } -function callAfterInsert(builder, models, result) { - const maybePromise = callInstanceAfterInsert(builder, models); - return after(maybePromise, () => callStaticAfterInsert(builder, result)); -} - function callInstanceAfterInsert(builder, models) { return mapAfterAllReturn(models, (model) => model.$afterInsert(builder.context()), models); } -function callStaticAfterInsert(builder, result) { - const args = StaticHookArguments.create({ builder, result }); - const maybePromise = builder.modelClass().afterInsert(args); - - return after(maybePromise, (maybeResult) => { - if (maybeResult === undefined) { - return result; - } else { - return maybeResult; +function serializeObjects(json) { + for (const [key, value] of Object.entries(json)) { + if (isPlainObject(value) || Array.isArray(value)) { + json[key] = JSON.stringify(value); } - }); -} + } -module.exports = { - InsertOperation, -}; + return json; +} diff --git a/lib/queryBuilder/operations/InstanceDeleteOperation.js b/lib/queryBuilder/operations/InstanceDeleteOperation.js index 9a1c90b51..0599ed69a 100644 --- a/lib/queryBuilder/operations/InstanceDeleteOperation.js +++ b/lib/queryBuilder/operations/InstanceDeleteOperation.js @@ -1,11 +1,8 @@ -'use strict'; +import { DeleteOperation } from './DeleteOperation.js'; +import { InstanceFindOperation } from './InstanceFindOperation.js'; +import { assertHasId } from '../../utils/assert.js'; -const { DeleteOperation } = require('./DeleteOperation'); -const { InstanceFindOperation } = require('./InstanceFindOperation'); -const { assertHasId } = require('../../utils/assert'); -const { addWhereGroup } = require('../whereGroups'); - -class InstanceDeleteOperation extends DeleteOperation { +export class InstanceDeleteOperation extends DeleteOperation { constructor(name, opt) { super(name, opt); this.instance = opt.instance; @@ -21,7 +18,7 @@ class InstanceDeleteOperation extends DeleteOperation { super.onBuild(builder); assertHasId(this.instance); - addWhereGroup(builder, () => builder.findById(this.instance.$id())); + builder.findById(this.instance.$id()); } async onAfter2(builder, result) { @@ -40,7 +37,3 @@ class InstanceDeleteOperation extends DeleteOperation { }); } } - -module.exports = { - InstanceDeleteOperation, -}; diff --git a/lib/queryBuilder/operations/InstanceFindOperation.js b/lib/queryBuilder/operations/InstanceFindOperation.js index db539fd55..059bef7bd 100644 --- a/lib/queryBuilder/operations/InstanceFindOperation.js +++ b/lib/queryBuilder/operations/InstanceFindOperation.js @@ -1,10 +1,7 @@ -'use strict'; +import { FindOperation } from './FindOperation.js'; +import { assertHasId } from '../../utils/assert.js'; -const { FindOperation } = require('./FindOperation'); -const { assertHasId } = require('../../utils/assert'); -const { addWhereGroup } = require('../whereGroups'); - -class InstanceFindOperation extends FindOperation { +export class InstanceFindOperation extends FindOperation { constructor(name, opt) { super(name, opt); this.instance = opt.instance; @@ -12,10 +9,6 @@ class InstanceFindOperation extends FindOperation { onBuild(builder) { assertHasId(this.instance); - addWhereGroup(builder, () => builder.findById(this.instance.$id())); + builder.findById(this.instance.$id()); } } - -module.exports = { - InstanceFindOperation, -}; diff --git a/lib/queryBuilder/operations/InstanceInsertOperation.js b/lib/queryBuilder/operations/InstanceInsertOperation.js index fbddb35c7..f5ca459b9 100644 --- a/lib/queryBuilder/operations/InstanceInsertOperation.js +++ b/lib/queryBuilder/operations/InstanceInsertOperation.js @@ -1,8 +1,6 @@ -'use strict'; +import { InsertOperation } from './InsertOperation.js'; -const { InsertOperation } = require('./InsertOperation'); - -class InstanceInsertOperation extends InsertOperation { +export class InstanceInsertOperation extends InsertOperation { constructor(name, opt) { super(name, opt); this.instance = opt.instance; @@ -18,7 +16,3 @@ class InstanceInsertOperation extends InsertOperation { return super.onAdd(builder, args); } } - -module.exports = { - InstanceInsertOperation, -}; diff --git a/lib/queryBuilder/operations/InstanceUpdateOperation.js b/lib/queryBuilder/operations/InstanceUpdateOperation.js index 0f83dd8a3..eeb4ce7af 100644 --- a/lib/queryBuilder/operations/InstanceUpdateOperation.js +++ b/lib/queryBuilder/operations/InstanceUpdateOperation.js @@ -1,13 +1,10 @@ -'use strict'; +import { UpdateOperation } from './UpdateOperation.js'; +import { InstanceFindOperation } from './InstanceFindOperation.js'; +import { assertHasId } from '../../utils/assert.js'; +import { isObject } from '../../utils/objectUtils.js'; +import { removeOverriddenQueryProps } from '../../model/modelQueryProps.js'; -const { UpdateOperation } = require('./UpdateOperation'); -const { InstanceFindOperation } = require('./InstanceFindOperation'); -const { assertHasId } = require('../../utils/assert'); -const { isObject } = require('../../utils/objectUtils'); -const { addWhereGroup } = require('../whereGroups'); -const { removeOverriddenQueryProps } = require('../../model/modelQueryProps'); - -class InstanceUpdateOperation extends UpdateOperation { +export class InstanceUpdateOperation extends UpdateOperation { constructor(name, opt) { super(name, opt); @@ -30,7 +27,7 @@ class InstanceUpdateOperation extends UpdateOperation { super.onBuild(builder); assertHasId(this.instance); - addWhereGroup(builder, () => builder.findById(this.instance.$id())); + builder.findById(this.instance.$id()); } async onAfter2(builder, result) { @@ -60,7 +57,3 @@ class InstanceUpdateOperation extends UpdateOperation { }); } } - -module.exports = { - InstanceUpdateOperation, -}; diff --git a/lib/queryBuilder/operations/JoinRelatedOperation.js b/lib/queryBuilder/operations/JoinRelatedOperation.js index 5c6092151..8957fe3f4 100644 --- a/lib/queryBuilder/operations/JoinRelatedOperation.js +++ b/lib/queryBuilder/operations/JoinRelatedOperation.js @@ -1,11 +1,9 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; +import { RelationExpression } from '../RelationExpression.js'; +import { RelationJoiner } from '../join/RelationJoiner.js'; +import { isString } from '../../utils/objectUtils.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); -const { RelationExpression } = require('../RelationExpression'); -const { RelationJoiner } = require('../join/RelationJoiner'); -const { isString } = require('../../utils/objectUtils'); - -class JoinRelatedOperation extends QueryBuilderOperation { +export class JoinRelatedOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); this.calls = []; @@ -97,7 +95,3 @@ function renameRelationExpressionNode(expr, oldName, newName) { childExpr.$name = newName; expr.$childNames = expr.$childNames.map((it) => (it === oldName ? newName : it)); } - -module.exports = { - JoinRelatedOperation, -}; diff --git a/lib/queryBuilder/operations/KnexOperation.js b/lib/queryBuilder/operations/KnexOperation.js index 8218c8ab6..e6aa3c26b 100644 --- a/lib/queryBuilder/operations/KnexOperation.js +++ b/lib/queryBuilder/operations/KnexOperation.js @@ -1,14 +1,8 @@ -'use strict'; - -const { ObjectionToKnexConvertingOperation } = require('./ObjectionToKnexConvertingOperation'); +import { ObjectionToKnexConvertingOperation } from './ObjectionToKnexConvertingOperation.js'; // An operation that simply calls the equivalent knex method. -class KnexOperation extends ObjectionToKnexConvertingOperation { +export class KnexOperation extends ObjectionToKnexConvertingOperation { onBuildKnex(knexBuilder, builder) { return knexBuilder[this.name].apply(knexBuilder, this.getKnexArgs(builder)); } } - -module.exports = { - KnexOperation, -}; diff --git a/lib/queryBuilder/operations/LimitPerParentOperation.js b/lib/queryBuilder/operations/LimitPerParentOperation.js index 3d1db0b00..a4c9a559e 100644 --- a/lib/queryBuilder/operations/LimitPerParentOperation.js +++ b/lib/queryBuilder/operations/LimitPerParentOperation.js @@ -1,18 +1,13 @@ -'use strict'; - -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); -const { RangeOperation } = require('./RangeOperation'); -const { FirstOperation } = require('./FirstOperation'); -const { isString, isNumber } = require('../../utils/objectUtils'); -const { hasWindowFunctions, isKnexRaw } = require('../../utils/knexUtils'); -const { Selection, isSameTable, withoutSchema } = require('./select/Selection'); - -// Without underscores, so that column name mappers don't change it. -const rowNumberColumn = 'objectiontmprownum'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; +import { RangeOperation } from './RangeOperation.js'; +import { FirstOperation } from './FirstOperation.js'; +import { isNumber } from '../../utils/objectUtils.js'; +import { withoutSchema } from './select/Selection.js'; +import { getWindowOrder, rowNumberColumn } from './windowOrder.js'; // Applies the `limit()`, `offset()` and `first()` of a relation query that // fetches the related models of many parents to each parent separately. -class LimitPerParentOperation extends QueryBuilderOperation { +export class LimitPerParentOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); this.findOperation = opt.findOperation; @@ -46,10 +41,6 @@ class LimitPerParentOperation extends QueryBuilderOperation { if (!range) { return knexBuilder; - } else if (!hasWindowFunctions(knex)) { - // `WhereInEagerOperation` fetches the related models of each parent with - // a separate query then, so only `first()` still needs to be applied: - return range.limit === null ? knexBuilder : knexBuilder.limit(range.limit); } else { const { limit, offset } = range; // Number the rows of each parent one level up, aliased as the related @@ -97,56 +88,3 @@ class LimitPerParentOperation extends QueryBuilderOperation { } } } - -// Convert the `orderBy()` and `orderByRaw()` calls of the query to the order -// of knex's window functions, which can only refer to the related table by its -// alias `table`. -function getWindowOrder(builder, knex, table) { - const order = []; - const tableRef = builder.tableRef(); - - const getAliasedColumn = (column) => { - const selection = isString(column) && Selection.create(column); - return selection?.table && isSameTable(builder, selection.table, tableRef) - ? `${table}.${selection.column}` - : column; - }; - - builder.forEachOperation(builder.constructor.OrderBySelector, (op) => { - const args = op.getKnexArgs(builder); - - if (op.name === 'orderByRaw') { - const [sql, bindings] = args; - order.push({ column: isKnexRaw(sql) ? sql : knex.raw(sql, bindings) }); - } else { - const [column, direction, nulls] = args; - const items = Array.isArray(column) - ? column.map((item) => (isString(item) ? { column: item } : item)) - : [{ column, order: direction, nulls }]; - - for (const item of items) { - const aliasedColumn = getAliasedColumn(item.column); - - if (item.nulls) { - // Not all databases support `nulls first` and `nulls last`: - order.push({ - column: knex.raw('(case when ? is null then 0 else 1 end)', [ - isString(aliasedColumn) ? knex.ref(aliasedColumn) : aliasedColumn, - ]), - order: item.nulls === 'first' ? 'asc' : 'desc', - }); - } - - // Unlike in `orderBy()`, knex adds the direction to the window as it is: - order.push({ column: aliasedColumn, order: /^desc$/i.test(item.order) ? 'desc' : 'asc' }); - } - } - }); - - // SQL Server requires an order in the window function: - return order.length ? order : [{ column: knex.raw('(select null)') }]; -} - -module.exports = { - LimitPerParentOperation, -}; diff --git a/lib/queryBuilder/operations/MergeOperation.js b/lib/queryBuilder/operations/MergeOperation.js index 7fa5c4a35..7ab13c098 100644 --- a/lib/queryBuilder/operations/MergeOperation.js +++ b/lib/queryBuilder/operations/MergeOperation.js @@ -1,10 +1,8 @@ -'use strict'; +import { isFunction, isEmpty, isObject } from '../../utils/objectUtils.js'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; +import { convertFieldExpressionsToRaw } from './fieldExpressions.js'; -const { isFunction, isEmpty, isObject } = require('../../utils/objectUtils'); -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); -const { convertFieldExpressionsToRaw } = require('./UpdateOperation'); - -class MergeOperation extends QueryBuilderOperation { +export class MergeOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); this.model = null; @@ -31,7 +29,7 @@ class MergeOperation extends QueryBuilderOperation { if (this.model) { const json = this.model.$toDatabaseJson(builder); - const convertedJson = convertFieldExpressionsToRaw(builder, this.model, json); + const convertedJson = convertFieldExpressionsToRaw(builder, json); return knexBuilder.merge(convertedJson); } @@ -50,7 +48,3 @@ class MergeOperation extends QueryBuilderOperation { return clone; } } - -module.exports = { - MergeOperation, -}; diff --git a/lib/queryBuilder/operations/NoneOperation.js b/lib/queryBuilder/operations/NoneOperation.js index ab3acd576..5a0556590 100644 --- a/lib/queryBuilder/operations/NoneOperation.js +++ b/lib/queryBuilder/operations/NoneOperation.js @@ -1,11 +1,9 @@ -'use strict'; - -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; const AggregateSelector = /^(count|countDistinct|min|max|sum|sumDistinct|avg|avgDistinct|groupBy\w*)$/; -class NoneOperation extends QueryBuilderOperation { +export class NoneOperation extends QueryBuilderOperation { onBefore1(builder, result) { // Resolve the query without executing it, like `cancelQuery()` does. if (builder.isFind()) { @@ -31,7 +29,3 @@ class NoneOperation extends QueryBuilderOperation { return knexBuilder.clearWhere().where(false); } } - -module.exports = { - NoneOperation, -}; diff --git a/lib/queryBuilder/operations/ObjectionToKnexConvertingOperation.js b/lib/queryBuilder/operations/ObjectionToKnexConvertingOperation.js index 0d5cdaee9..30c530b64 100644 --- a/lib/queryBuilder/operations/ObjectionToKnexConvertingOperation.js +++ b/lib/queryBuilder/operations/ObjectionToKnexConvertingOperation.js @@ -1,15 +1,13 @@ -'use strict'; - -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); -const { isPlainObject, isObject, isFunction, once } = require('../../utils/objectUtils'); -const { isKnexQueryBuilder, isKnexJoinBuilder } = require('../../utils/knexUtils'); -const { transformation } = require('../transformations'); -const getJoinBuilder = once(() => require('../JoinBuilder').JoinBuilder); +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; +import { isPlainObject, isObject, isFunction } from '../../utils/objectUtils.js'; +import { isKnexQueryBuilder, isKnexJoinBuilder } from '../../utils/knexUtils.js'; +import { transformation } from '../transformations/index.js'; +import { getJoinBuilder } from '../getJoinBuilder.js'; // An abstract operation base class that converts all arguments from objection types // to knex types. For example objection query builders are converted into knex query // builders and objection RawBuilder instances are converted into knex Raw instances. -class ObjectionToKnexConvertingOperation extends QueryBuilderOperation { +export class ObjectionToKnexConvertingOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); this.args = null; @@ -21,7 +19,8 @@ class ObjectionToKnexConvertingOperation extends QueryBuilderOperation { onAdd(builder, args) { this.args = Array.from(args); - return shouldBeAdded(this.name, builder, this.args); + assertNoUndefinedArgs(this.name, this.args); + return true; } clone() { @@ -31,52 +30,34 @@ class ObjectionToKnexConvertingOperation extends QueryBuilderOperation { } } -function shouldBeAdded(opName, builder, args) { - const skipUndefined = builder.internalOptions().skipUndefined; - +function assertNoUndefinedArgs(opName, args) { for (let i = 0, l = args.length; i < l; ++i) { - const arg = args[i]; - - if (isUndefined(arg)) { - if (skipUndefined) { - return false; - } else { - throw new Error( - `undefined passed as argument #${i} for '${opName}' operation. Call skipUndefined() method to ignore the undefined values.`, - ); - } + if (args[i] === undefined) { + throw new Error(`undefined passed as argument #${i} for '${opName}' operation.`); } } - - return true; } function convertArgs(opName, builder, args) { - const skipUndefined = builder.internalOptions().skipUndefined; - return args.map((arg, i) => { if (hasToKnexRawMethod(arg)) { return convertToKnexRaw(arg, builder); } else if (isObjectionQueryBuilderBase(arg)) { return convertQueryBuilderBase(arg, builder); } else if (isArray(arg)) { - return convertArray(arg, builder, i, opName, skipUndefined); + return convertArray(arg, builder, i, opName); } else if (isFunction(arg)) { return convertFunction(arg, builder); } else if (isModel(arg)) { return convertModel(arg); } else if (isPlainObject(arg)) { - return convertPlainObject(arg, builder, i, opName, skipUndefined); + return convertPlainObject(arg, builder, i, opName); } else { return arg; } }); } -function isUndefined(item) { - return item === undefined; -} - function hasToKnexRawMethod(item) { return isObject(item) && isFunction(item.toKnexRaw); } @@ -98,19 +79,15 @@ function isArray(item) { return Array.isArray(item); } -function convertArray(arr, builder, i, opName, skipUndefined) { +function convertArray(arr, builder, i, opName) { return arr.map((item) => { if (item === undefined) { - if (!skipUndefined) { - throw new Error( - `undefined passed as an item in argument #${i} for '${opName}' operation. Call skipUndefined() method to ignore the undefined values.`, - ); - } + throw new Error(`undefined passed as an item in argument #${i} for '${opName}' operation.`); } else if (opName === 'orderBy' && isObject(item)) { // knex's `orderBy()` only reads `{ column, order, nulls }` objects from arrays, // so wrap refs, raws and query builders in one and convert its properties. const obj = isPlainObject(item) ? item : { column: item }; - return convertPlainObject(obj, builder, i, opName, skipUndefined); + return convertPlainObject(obj, builder, i, opName); } else if (hasToKnexRawMethod(item)) { return convertToKnexRaw(item, builder); } else if (isObjectionQueryBuilderBase(item)) { @@ -160,16 +137,14 @@ function convertModel(model) { return model.$toDatabaseJson(); } -function convertPlainObject(obj, builder, i, opName, skipUndefined) { +function convertPlainObject(obj, builder, i, opName) { return Object.keys(obj).reduce((out, key) => { const item = obj[key]; if (item === undefined) { - if (!skipUndefined) { - throw new Error( - `undefined passed as a property in argument #${i} for '${opName}' operation. Call skipUndefined() method to ignore the undefined values.`, - ); - } + throw new Error( + `undefined passed as a property in argument #${i} for '${opName}' operation.`, + ); } else if (hasToKnexRawMethod(item)) { out[key] = convertToKnexRaw(item, builder); } else if (isObjectionQueryBuilderBase(item)) { @@ -181,7 +156,3 @@ function convertPlainObject(obj, builder, i, opName, skipUndefined) { return out; }, {}); } - -module.exports = { - ObjectionToKnexConvertingOperation, -}; diff --git a/lib/queryBuilder/operations/OnBuildKnexOperation.js b/lib/queryBuilder/operations/OnBuildKnexOperation.js index d46beacb7..b62e3bad7 100644 --- a/lib/queryBuilder/operations/OnBuildKnexOperation.js +++ b/lib/queryBuilder/operations/OnBuildKnexOperation.js @@ -1,8 +1,6 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); - -class OnBuildKnexOperation extends QueryBuilderOperation { +export class OnBuildKnexOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); this.func = null; @@ -23,7 +21,3 @@ class OnBuildKnexOperation extends QueryBuilderOperation { return clone; } } - -module.exports = { - OnBuildKnexOperation, -}; diff --git a/lib/queryBuilder/operations/OnBuildOperation.js b/lib/queryBuilder/operations/OnBuildOperation.js index 348f05497..fca5e3d61 100644 --- a/lib/queryBuilder/operations/OnBuildOperation.js +++ b/lib/queryBuilder/operations/OnBuildOperation.js @@ -1,8 +1,6 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); - -class OnBuildOperation extends QueryBuilderOperation { +export class OnBuildOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); this.func = null; @@ -23,7 +21,3 @@ class OnBuildOperation extends QueryBuilderOperation { return clone; } } - -module.exports = { - OnBuildOperation, -}; diff --git a/lib/queryBuilder/operations/OnErrorOperation.js b/lib/queryBuilder/operations/OnErrorOperation.js index 001150c30..bd17a1cb8 100644 --- a/lib/queryBuilder/operations/OnErrorOperation.js +++ b/lib/queryBuilder/operations/OnErrorOperation.js @@ -1,8 +1,6 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); - -class OnErrorOperation extends QueryBuilderOperation { +export class OnErrorOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); this.func = null; @@ -23,7 +21,3 @@ class OnErrorOperation extends QueryBuilderOperation { return clone; } } - -module.exports = { - OnErrorOperation, -}; diff --git a/lib/queryBuilder/operations/QueryBuilderOperation.js b/lib/queryBuilder/operations/QueryBuilderOperation.js index dea826222..51b8b907e 100644 --- a/lib/queryBuilder/operations/QueryBuilderOperation.js +++ b/lib/queryBuilder/operations/QueryBuilderOperation.js @@ -1,5 +1,3 @@ -'use strict'; - const hookNameToHasMethodName = { onAdd: 'hasOnAdd', onBefore1: 'hasOnBefore1', @@ -29,7 +27,7 @@ const hookNameToHasMethodName = { // certain hook is called for _all_ operations before the next hook is called. For example if // a builder has 5 operations, onBefore1 hook is called for each of them (and their results are awaited) // before onBefore2 hook is called for any of the operations. -class QueryBuilderOperation { +export class QueryBuilderOperation { constructor(name = null, opt = {}) { this.name = name; this.opt = opt; @@ -322,7 +320,3 @@ Object.defineProperties(QueryBuilderOperation, { value: true, }, }); - -module.exports = { - QueryBuilderOperation, -}; diff --git a/lib/queryBuilder/operations/RangeOperation.js b/lib/queryBuilder/operations/RangeOperation.js index ed68b77c4..1efbb1dbb 100644 --- a/lib/queryBuilder/operations/RangeOperation.js +++ b/lib/queryBuilder/operations/RangeOperation.js @@ -1,13 +1,7 @@ -'use strict'; - -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); - -class RangeOperation extends QueryBuilderOperation { - constructor(name, opt) { - super(name, opt); - this.resultSizeBuilder = null; - } +import { RunAfterOperation } from './RunAfterOperation.js'; +// Wraps the result in `{ results, total }` as a `runAfter` callback. +export class RangeOperation extends RunAfterOperation { onAdd(builder, args) { if (args.length === 2) { const start = args[0]; @@ -22,26 +16,17 @@ class RangeOperation extends QueryBuilderOperation { } onBefore1(builder, result) { - this.resultSizeBuilder = builder.clone(); - return super.onBefore1(builder, result); - } + const resultSizeBuilder = builder.clone(); - async onAfter3(_, results) { - const resultSize = await this.resultSizeBuilder.resultSize(); + this.func = async (results) => { + const resultSize = await resultSizeBuilder.resultSize(); - return { - results, - total: parseInt(resultSize), + return { + results, + total: parseInt(resultSize), + }; }; - } - clone() { - const clone = super.clone(); - clone.resultSizeBuilder = this.resultSizeBuilder; - return clone; + return super.onBefore1(builder, result); } } - -module.exports = { - RangeOperation, -}; diff --git a/lib/queryBuilder/operations/RelateOperation.js b/lib/queryBuilder/operations/RelateOperation.js index 30bbbdb5c..2244db5b3 100644 --- a/lib/queryBuilder/operations/RelateOperation.js +++ b/lib/queryBuilder/operations/RelateOperation.js @@ -1,8 +1,6 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); - -class RelateOperation extends QueryBuilderOperation { +export class RelateOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); @@ -21,7 +19,3 @@ class RelateOperation extends QueryBuilderOperation { return clone; } } - -module.exports = { - RelateOperation, -}; diff --git a/lib/queryBuilder/operations/ReturningOperation.js b/lib/queryBuilder/operations/ReturningOperation.js index a112866f2..76be0b3f0 100644 --- a/lib/queryBuilder/operations/ReturningOperation.js +++ b/lib/queryBuilder/operations/ReturningOperation.js @@ -1,7 +1,5 @@ -'use strict'; - -const { flatten, isPlainObject } = require('../../utils/objectUtils'); -const { ObjectionToKnexConvertingOperation } = require('./ObjectionToKnexConvertingOperation'); +import { flatten, isPlainObject } from '../../utils/objectUtils.js'; +import { ObjectionToKnexConvertingOperation } from './ObjectionToKnexConvertingOperation.js'; // This class's purpose is to normalize the arguments into an array and to // pass an optional trailing options object (e.g. `includeTriggerModifications`) @@ -9,7 +7,7 @@ const { ObjectionToKnexConvertingOperation } = require('./ObjectionToKnexConvert // // In knex, if a single column is given to `returning` it returns an array with the that column's value // in it. If an array is given with a one item inside, the return value is an object. -class ReturningOperation extends ObjectionToKnexConvertingOperation { +export class ReturningOperation extends ObjectionToKnexConvertingOperation { constructor(name, opt) { super(name, opt); this.options = null; @@ -49,7 +47,3 @@ class ReturningOperation extends ObjectionToKnexConvertingOperation { return clone; } } - -module.exports = { - ReturningOperation, -}; diff --git a/lib/queryBuilder/operations/RunAfterOperation.js b/lib/queryBuilder/operations/RunAfterOperation.js index 268a661a1..52b41aea8 100644 --- a/lib/queryBuilder/operations/RunAfterOperation.js +++ b/lib/queryBuilder/operations/RunAfterOperation.js @@ -1,8 +1,6 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); - -class RunAfterOperation extends QueryBuilderOperation { +export class RunAfterOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); this.func = null; @@ -13,17 +11,9 @@ class RunAfterOperation extends QueryBuilderOperation { return true; } - onAfter3(builder, result) { - return this.func.call(builder, result, builder); - } - clone() { const clone = super.clone(); clone.func = this.func; return clone; } } - -module.exports = { - RunAfterOperation, -}; diff --git a/lib/queryBuilder/operations/RunBeforeOperation.js b/lib/queryBuilder/operations/RunBeforeOperation.js index ce7c363ad..edc8334eb 100644 --- a/lib/queryBuilder/operations/RunBeforeOperation.js +++ b/lib/queryBuilder/operations/RunBeforeOperation.js @@ -1,8 +1,6 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); - -class RunBeforeOperation extends QueryBuilderOperation { +export class RunBeforeOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); this.func = null; @@ -23,7 +21,3 @@ class RunBeforeOperation extends QueryBuilderOperation { return clone; } } - -module.exports = { - RunBeforeOperation, -}; diff --git a/lib/queryBuilder/operations/UnrelateOperation.js b/lib/queryBuilder/operations/UnrelateOperation.js index 7b696daca..e127adcaf 100644 --- a/lib/queryBuilder/operations/UnrelateOperation.js +++ b/lib/queryBuilder/operations/UnrelateOperation.js @@ -1,8 +1,6 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); - -class UnrelateOperation extends QueryBuilderOperation { +export class UnrelateOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); @@ -17,7 +15,3 @@ class UnrelateOperation extends QueryBuilderOperation { return clone; } } - -module.exports = { - UnrelateOperation, -}; diff --git a/lib/queryBuilder/operations/UpdateAndFetchOperation.js b/lib/queryBuilder/operations/UpdateAndFetchOperation.js index f263d0236..159d4eddb 100644 --- a/lib/queryBuilder/operations/UpdateAndFetchOperation.js +++ b/lib/queryBuilder/operations/UpdateAndFetchOperation.js @@ -1,11 +1,8 @@ -'use strict'; +import { DelegateOperation } from './DelegateOperation.js'; +import { FindByIdOperation } from './FindByIdOperation.js'; +import { UpdateOperation } from './UpdateOperation.js'; -const { DelegateOperation } = require('./DelegateOperation'); -const { FindByIdOperation } = require('./FindByIdOperation'); -const { UpdateOperation } = require('./UpdateOperation'); -const { addWhereGroup, assignWhereGroup } = require('../whereGroups'); - -class UpdateAndFetchOperation extends DelegateOperation { +export class UpdateAndFetchOperation extends DelegateOperation { constructor(name, opt) { super(name, opt); @@ -42,7 +39,7 @@ class UpdateAndFetchOperation extends DelegateOperation { onBuild(builder) { if (!this.skipIdWhere) { - addWhereGroup(builder, () => builder.findById(this.id)); + builder.findById(this.id); } super.onBuild(builder); @@ -67,6 +64,13 @@ class UpdateAndFetchOperation extends DelegateOperation { .castTo(builder.resultModelClass()); if (fetched) { + if (!fetched.$isObjectionModel) { + throw new Error( + `Unable to merge the result of ${builder.modelClass().name}.afterFind() in ` + + `${this.name}(): expected a model.`, + ); + } + this.model.$set(fetched); // A custom `select` may not include the id. The row was fetched by // id, so make sure the result still has it. @@ -84,12 +88,9 @@ class UpdateAndFetchOperation extends DelegateOperation { // many-to-many updates move to a subquery when the query is built. const delegate = this.delegate.toFindOperation(builder); - // Keep the id condition in its own where group, like `onBuild()` does. The - // delegate isn't named `findById`, as that would put all the wheres that it - // adds in the user's group. return delegate ? new FindByIdDelegateOperation('find', { delegate, id: this.id }) - : assignWhereGroup(new FindByIdOperation('findById', { id: this.id })); + : new FindByIdOperation('findById', { id: this.id }); } clone() { @@ -107,10 +108,6 @@ class UpdateAndFetchOperation extends DelegateOperation { class FindByIdDelegateOperation extends DelegateOperation { onBuild(builder) { super.onBuild(builder); - addWhereGroup(builder, () => builder.findById(this.opt.id)); + builder.findById(this.opt.id); } } - -module.exports = { - UpdateAndFetchOperation, -}; diff --git a/lib/queryBuilder/operations/UpdateOperation.js b/lib/queryBuilder/operations/UpdateOperation.js index 2bc1dfbaa..d4ba6487a 100644 --- a/lib/queryBuilder/operations/UpdateOperation.js +++ b/lib/queryBuilder/operations/UpdateOperation.js @@ -1,20 +1,14 @@ -'use strict'; - -const { ref } = require('../../queryBuilder/ReferenceBuilder'); -const { isEmpty, isObject } = require('../../utils/objectUtils'); -const { - isFieldExpressionKey, - toPostgresJsonPathLiteral, -} = require('../../utils/parseFieldExpression'); -const { isKnexRaw, isKnexQueryBuilder } = require('../../utils/knexUtils'); -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); -const { warnIfNotPostgres } = require('./jsonApi/postgresJsonApi'); -const { StaticHookArguments } = require('../StaticHookArguments'); -const { ValidationErrorType } = require('../../model/ValidationError'); -const { removeOverriddenValues } = require('../../model/modelQueryProps'); -const { warnOnce } = require('../../utils/warnings'); - -class UpdateOperation extends QueryBuilderOperation { +import { isEmpty } from '../../utils/objectUtils.js'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; +import { StaticHookArguments } from '../StaticHookArguments.js'; +import { removeOverriddenValues } from '../../model/modelQueryProps.js'; +import { + checkFieldExpressions, + convertFieldExpressionsToRaw, + removeFieldExpressions, +} from './fieldExpressions.js'; + +export class UpdateOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); @@ -55,26 +49,38 @@ class UpdateOperation extends QueryBuilderOperation { return builder.isExplicitlyResolvedOrRejected() && !this.hasNothingToUpdate; } + // Returns true if the query was resolved without writing anything, by a hook + // or because there is nothing to update in the table. + // @overridable + isResolvedWithoutWrite(builder) { + return builder.isExplicitlyResolvedOrRejected(); + } + // knex leaves the offset out of updates, and on SQL Server the limit as well. - // Warn when the query is executed. + // Throw when the query is executed. onAfterBuild(builder) { if (builder.has('offset')) { - warnOnce( - 'offset() has no effect in update queries, so no rows are skipped, and on SQL Server ' + - 'limit() is ignored too. This will throw an error in objection 4.0.', - ); + throw new Error('offset() is not supported in update queries, as knex leaves it out.'); } } onBuildKnex(knexBuilder, builder) { const json = this.model.$toDatabaseJson(builder); - const convertedJson = convertFieldExpressionsToRaw(builder, this.model, json); - return knexBuilder.update(convertedJson); + if (this.modelOptions.patch) { + return knexBuilder.update(convertFieldExpressionsToRaw(builder, json)); + } else { + checkFieldExpressions(builder.modelClass(), this.model, 'update'); + return knexBuilder.update(json); + } } - onAfter2(builder, numUpdated) { - return callAfterUpdate(builder, this.model, this.modelOptions, numUpdated); + async onAfter2(builder, numUpdated) { + await this.model.$afterUpdate(this.modelOptions, builder.context()); + // The patched model is what the write returns, e.g. to `upsertGraph()` and + // `patchAndFetch()`, or what it merges into the instance of `$query()`: + removeFieldExpressions(builder.modelClass(), this.model); + return numUpdated; } toFindOperation() { @@ -102,109 +108,3 @@ function callStaticBeforeUpdate(builder) { const args = StaticHookArguments.create({ builder }); return builder.modelClass().beforeUpdate(args); } - -async function callAfterUpdate(builder, model, modelOptions, result) { - await callInstanceAfterUpdate(builder, model, modelOptions); - return callStaticAfterUpdate(builder, result); -} - -function callInstanceAfterUpdate(builder, model, modelOptions) { - return model.$afterUpdate(modelOptions, builder.context()); -} - -async function callStaticAfterUpdate(builder, result) { - const args = StaticHookArguments.create({ builder, result }); - const maybeResult = await builder.modelClass().afterUpdate(args); - - if (maybeResult === undefined) { - return result; - } else { - return maybeResult; - } -} - -function convertFieldExpressionsToRaw(builder, model, json) { - const knex = builder.knex(); - const modelClass = builder.modelClass(); - const declaredColumns = getDeclaredColumns(modelClass); - const convertedJson = {}; - - for (const key of Object.keys(json)) { - let val = json[key]; - - if (isFieldExpressionKey(key, declaredColumns)) { - // 'col:attr' : ref('other:lol') is transformed to - // "col" : raw(`jsonb_set("table"."col", '{attr}', to_jsonb("other"#>'{lol}'), true)`) - // with the column qualified, as it is ambiguous with `excluded` in merge(). - warnIfNotPostgres(knex); - - let parsed = ref(key); - - // The field expression would be lost or produce invalid SQL next to a - // value for the whole json column. Report it with the property names: - if (Object.prototype.hasOwnProperty.call(json, parsed.column)) { - const property = modelClass.columnNameToPropertyName(parsed.column); - throw modelClass.createValidationError({ - type: ValidationErrorType.ModelValidation, - data: { - [modelClass.columnNameToPropertyName(key)]: [ - { - message: `can't be combined with a value for the json column '${property}'`, - keyword: null, - params: { property }, - }, - ], - }, - }); - } - - let jsonPath = toPostgresJsonPathLiteral(parsed.parsedExpr.access); - let valuePlaceholder = '?'; - - if (isKnexQueryBuilder(val) || isKnexRaw(val)) { - valuePlaceholder = 'to_jsonb(?)'; - } else { - val = JSON.stringify(val); - } - - convertedJson[parsed.column] = knex.raw( - `jsonb_set(??, ${jsonPath}, ${valuePlaceholder}, true)`, - [convertedJson[parsed.column] || `${builder.tableRef()}.${parsed.column}`, val], - ); - - delete model[key]; - } else { - convertedJson[key] = val; - } - } - - return convertedJson; -} - -const declaredColumnsByModelClass = new WeakMap(); - -// The keys of `json` are column names, so map the properties with a ':' that the -// schema declares to their columns, once per model class. Properties that only -// the schema returned by `$beforeValidate()` declares aren't known here. -function getDeclaredColumns(modelClass) { - let declaredColumns = declaredColumnsByModelClass.get(modelClass); - - if (!declaredColumns) { - const jsonSchema = modelClass.getJsonSchema(); - const properties = jsonSchema && jsonSchema.properties; - declaredColumns = Object.fromEntries( - (isObject(properties) ? Object.keys(properties) : []) - .filter((key) => key.includes(':')) - .map((key) => [modelClass.propertyNameToColumnName(key), true]), - ); - declaredColumnsByModelClass.set(modelClass, declaredColumns); - } - - return declaredColumns; -} - -module.exports = { - UpdateOperation, - convertFieldExpressionsToRaw, - getDeclaredColumns, -}; diff --git a/lib/queryBuilder/operations/UpsertGraphAndFetchOperation.js b/lib/queryBuilder/operations/UpsertGraphAndFetchOperation.js index b62d5d6f0..638d14f42 100644 --- a/lib/queryBuilder/operations/UpsertGraphAndFetchOperation.js +++ b/lib/queryBuilder/operations/UpsertGraphAndFetchOperation.js @@ -1,10 +1,8 @@ -'use strict'; +import { DelegateOperation } from './DelegateOperation.js'; +import { UpsertGraphOperation } from './UpsertGraphOperation.js'; +import { RelationExpression } from '../RelationExpression.js'; -const { DelegateOperation } = require('./DelegateOperation'); -const { UpsertGraphOperation } = require('./UpsertGraphOperation'); -const { RelationExpression } = require('../RelationExpression'); - -class UpsertGraphAndFetchOperation extends DelegateOperation { +export class UpsertGraphAndFetchOperation extends DelegateOperation { constructor(name, opt) { super(name, opt); @@ -21,7 +19,10 @@ class UpsertGraphAndFetchOperation extends DelegateOperation { return this.delegate.isArray; } - async onAfter3(builder) { + // Refetch the graph before the graph operations of the query run, so that a + // `withGraphFetched()` with its modifiers and options loads its relations + // into the fetched models, like in `InsertGraphAndFetchOperation` (#839). + async onAfter2(builder) { if (this.models.length === 0) { return this.isArray ? [] : null; } @@ -39,7 +40,3 @@ class UpsertGraphAndFetchOperation extends DelegateOperation { return this.isArray ? models : models[0] || null; } } - -module.exports = { - UpsertGraphAndFetchOperation, -}; diff --git a/lib/queryBuilder/operations/UpsertGraphOperation.js b/lib/queryBuilder/operations/UpsertGraphOperation.js index c1cdf141a..71b3a3e7f 100644 --- a/lib/queryBuilder/operations/UpsertGraphOperation.js +++ b/lib/queryBuilder/operations/UpsertGraphOperation.js @@ -1,10 +1,8 @@ -'use strict'; +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; +import { GraphUpsert } from '../graph/GraphUpsert.js'; +import { RelationFindOperation } from '../../relations/RelationFindOperation.js'; -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); -const { GraphUpsert } = require('../graph/GraphUpsert'); -const { RelationFindOperation } = require('../../relations/RelationFindOperation'); - -class UpsertGraphOperation extends QueryBuilderOperation { +export class UpsertGraphOperation extends QueryBuilderOperation { constructor(name, opt) { super( name, @@ -41,14 +39,14 @@ class UpsertGraphOperation extends QueryBuilderOperation { } onBefore1(builder, result) { - GraphUpsert.warnOnConflict(builder, this.name); + GraphUpsert.checkForOnConflict(builder, this.name); return result; } onAfter1(builder) { if (hasOtherSqlModifyingQueryBuilderCalls(builder)) { throw new Error( - 'upsertGraph query should contain no other query builder calls like `findById`, `where` or `$relatedQuery` that would affect the SQL. They have no effect.', + 'upsertGraph query should contain no other query builder calls like `findById`, `where`, `$query` or `$relatedQuery` that would affect the SQL. They have no effect.', ); } @@ -65,7 +63,3 @@ class UpsertGraphOperation extends QueryBuilderOperation { function hasOtherSqlModifyingQueryBuilderCalls(builder) { return builder.has(/where/) || builder.has(RelationFindOperation); } - -module.exports = { - UpsertGraphOperation, -}; diff --git a/lib/queryBuilder/operations/WhereCompositeOperation.js b/lib/queryBuilder/operations/WhereCompositeOperation.js index f1ad9d226..3b690a5d1 100644 --- a/lib/queryBuilder/operations/WhereCompositeOperation.js +++ b/lib/queryBuilder/operations/WhereCompositeOperation.js @@ -1,9 +1,7 @@ -'use strict'; +import { ObjectionToKnexConvertingOperation } from './ObjectionToKnexConvertingOperation.js'; +import { asSingle } from '../../utils/objectUtils.js'; -const { ObjectionToKnexConvertingOperation } = require('./ObjectionToKnexConvertingOperation'); -const { asSingle } = require('../../utils/objectUtils'); - -class WhereCompositeOperation extends ObjectionToKnexConvertingOperation { +export class WhereCompositeOperation extends ObjectionToKnexConvertingOperation { onBuildKnex(knexBuilder, builder) { const args = this.getKnexArgs(builder); @@ -51,7 +49,3 @@ function buildCompositeWhereArgs(cols, op, values) { }, ]; } - -module.exports = { - WhereCompositeOperation, -}; diff --git a/lib/queryBuilder/operations/WhereGroupOperation.js b/lib/queryBuilder/operations/WhereGroupOperation.js index f6cb16abb..eed34e64b 100644 --- a/lib/queryBuilder/operations/WhereGroupOperation.js +++ b/lib/queryBuilder/operations/WhereGroupOperation.js @@ -1,10 +1,8 @@ -'use strict'; - -const { QueryBuilderOperation } = require('./QueryBuilderOperation'); +import { QueryBuilderOperation } from './QueryBuilderOperation.js'; // Builds a set of where operations inside a single knex `where(callback)` // group, i.e. wraps them in parentheses. See `getWhereGroupOperations()`. -class WhereGroupOperation extends QueryBuilderOperation { +export class WhereGroupOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); this.operations = opt.operations; @@ -29,7 +27,3 @@ class WhereGroupOperation extends QueryBuilderOperation { }); } } - -module.exports = { - WhereGroupOperation, -}; diff --git a/lib/queryBuilder/operations/eager/EagerOperation.js b/lib/queryBuilder/operations/eager/EagerOperation.js index 89d9ff03c..fe6295dec 100644 --- a/lib/queryBuilder/operations/eager/EagerOperation.js +++ b/lib/queryBuilder/operations/eager/EagerOperation.js @@ -1,9 +1,7 @@ -'use strict'; +import { QueryBuilderOperation } from '../QueryBuilderOperation.js'; +import { RelationExpression } from '../../RelationExpression.js'; -const { QueryBuilderOperation } = require('../QueryBuilderOperation'); -const { RelationExpression } = require('../../RelationExpression'); - -class EagerOperation extends QueryBuilderOperation { +export class EagerOperation extends QueryBuilderOperation { constructor(name, opt) { super(name, opt); @@ -57,7 +55,3 @@ class EagerOperation extends QueryBuilderOperation { function getModifierName(index) { return `_f${index}_`; } - -module.exports = { - EagerOperation, -}; diff --git a/lib/queryBuilder/operations/eager/JoinEagerOperation.js b/lib/queryBuilder/operations/eager/JoinEagerOperation.js index a01c8b4ab..9827e250f 100644 --- a/lib/queryBuilder/operations/eager/JoinEagerOperation.js +++ b/lib/queryBuilder/operations/eager/JoinEagerOperation.js @@ -1,12 +1,13 @@ -'use strict'; +import { EagerOperation } from './EagerOperation.js'; +import { RelationJoiner } from '../../join/RelationJoiner.js'; +import { JoinedStaticHookArguments } from '../../StaticHookArguments.js'; +import { asArray } from '../../../utils/objectUtils.js'; -const { EagerOperation } = require('./EagerOperation'); -const { RelationJoiner } = require('../../join/RelationJoiner'); - -class JoinEagerOperation extends EagerOperation { +export class JoinEagerOperation extends EagerOperation { constructor(name, opt) { super(name, opt); this.joiner = null; + this.models = null; } onAdd(builder) { @@ -43,7 +44,72 @@ class JoinEagerOperation extends EagerOperation { } onRawResult(builder, rows) { - return this.joiner.parseResult(builder, rows); + this.models = this.joiner.parseResult(builder, rows); + return this.models; + } + + // Call the static `afterFind()` hooks of the joined relations bottom-up, after + // the `$afterFind()` hooks of all models, and assign the related models that + // they return, like `withGraphFetched()` does. + async onAfter3(builder, result) { + if (Array.isArray(this.models)) { + const [rootNode, ...nodes] = this.joiner.tableTree.nodes; + const modelsByNode = new Map([[rootNode, this.models]]); + const ownerByModel = new Map(); + + // The nodes are ordered from the root down, so the owners come first: + for (const node of nodes) { + const models = []; + + for (const owner of modelsByNode.get(node.parentNode)) { + for (const model of asArray(owner[node.relationProperty])) { + if (model) { + models.push(model); + ownerByModel.set(model, owner); + } + } + } + + modelsByNode.set(node, models); + } + + for (const node of nodes.reverse()) { + const { modelClass, relation, relationProperty } = node; + const owners = modelsByNode.get(node.parentNode); + const models = modelsByNode.get(node); + const args = JoinedStaticHookArguments.create({ + builder, + result: models, + relation, + items: owners, + }); + const related = await modelClass.afterFind(args); + + if (related !== undefined) { + const relatedByOwner = new Map(owners.map((owner) => [owner, []])); + + for (const model of asArray(related)) { + const relatedOfOwner = relatedByOwner.get(ownerByModel.get(model)); + + if (!relatedOfOwner) { + throw new Error( + `Unable to assign the result of ${modelClass.name}.afterFind() to the relation ` + + `"${relation.name}" of ${relation.ownerModelClass.name}: return the models ` + + 'you got, filtered or reordered.', + ); + } + + relatedOfOwner.push(model); + } + + for (const [owner, related] of relatedByOwner) { + owner[relationProperty] = relation.isOneToOne() ? related[0] || null : related; + } + } + } + } + + return result; } clone() { @@ -52,7 +118,3 @@ class JoinEagerOperation extends EagerOperation { return clone; } } - -module.exports = { - JoinEagerOperation, -}; diff --git a/lib/queryBuilder/operations/eager/NaiveEagerOperation.js b/lib/queryBuilder/operations/eager/NaiveEagerOperation.js index 4329d5683..13edb51ba 100644 --- a/lib/queryBuilder/operations/eager/NaiveEagerOperation.js +++ b/lib/queryBuilder/operations/eager/NaiveEagerOperation.js @@ -1,13 +1,7 @@ -'use strict'; +import { WhereInEagerOperation } from './WhereInEagerOperation.js'; -const { WhereInEagerOperation } = require('./WhereInEagerOperation'); - -class NaiveEagerOperation extends WhereInEagerOperation { +export class NaiveEagerOperation extends WhereInEagerOperation { batchSize() { return 1; } } - -module.exports = { - NaiveEagerOperation, -}; diff --git a/lib/queryBuilder/operations/eager/WhereInEagerOperation.js b/lib/queryBuilder/operations/eager/WhereInEagerOperation.js index e5ad4184c..e72c4c0b0 100644 --- a/lib/queryBuilder/operations/eager/WhereInEagerOperation.js +++ b/lib/queryBuilder/operations/eager/WhereInEagerOperation.js @@ -1,17 +1,16 @@ -'use strict'; - -const promiseUtils = require('../../../utils/promiseUtils'); - -const { EagerOperation } = require('./EagerOperation'); -const { LimitPerParentOperation } = require('../LimitPerParentOperation'); -const { isMsSql, isOracle, isSqlite, hasWindowFunctions } = require('../../../utils/knexUtils'); -const { isObject, asArray, flatten, chunk } = require('../../../utils/objectUtils'); -const { ValidationErrorType } = require('../../../model/ValidationError'); -const { createModifier } = require('../../../utils/createModifier'); -const { RelationDoesNotExistError } = require('../../../model/RelationDoesNotExistError'); -const { RelationOwner } = require('../../../relations/RelationOwner'); - -class WhereInEagerOperation extends EagerOperation { +import * as promiseUtils from '../../../utils/promiseUtils/index.js'; + +import { EagerOperation } from './EagerOperation.js'; +import { LimitPerParentOperation } from '../LimitPerParentOperation.js'; +import { isMsSql, isOracle, isSqlite } from '../../../utils/knexUtils.js'; +import { isObject, asArray, flatten, chunk } from '../../../utils/objectUtils.js'; +import { ValidationErrorType } from '../../../model/ValidationError.js'; +import { createModifier } from '../../../utils/createModifier.js'; +import { RelationDoesNotExistError } from '../../../model/RelationDoesNotExistError.js'; +import { RelationOwner } from '../../../relations/RelationOwner.js'; +import { hasAliasSelection } from '../select/Selection.js'; + +export class WhereInEagerOperation extends EagerOperation { constructor(name, opt) { super(name, opt); @@ -64,10 +63,6 @@ class WhereInEagerOperation extends EagerOperation { async onAfter2(builder, result) { const modelClass = builder.resultModelClass(); - if (!result) { - return result; - } - const models = asArray(result); // Check models to be actual objects, to filter out `count` results (#2397). @@ -86,9 +81,7 @@ class WhereInEagerOperation extends EagerOperation { async fetchRelation(builder, models, relation, expr) { const modelClass = builder.resultModelClass(); - const batchSize = this.shouldFetchPerParent(builder, models, relation, expr) - ? 1 - : this.batchSize(builder.knex()); + const batchSize = this.batchSize(builder.knex()); const modelBatches = chunk(models, batchSize); const result = await promiseUtils.map( @@ -139,30 +132,16 @@ class WhereInEagerOperation extends EagerOperation { } } - if (this.graphOptions.limitPerParent) { + if (this.graphOptions.limitPerParent !== false) { queryBuilder.addOperation( new LimitPerParentOperation('limitPerParent', { findOperation }), [], ); } - return queryBuilder; - } - - // Without window functions, the related models can only be limited per - // parent with one query per parent. - shouldFetchPerParent(builder, models, relation, expr) { - if (this.graphOptions.limitPerParent && !hasWindowFunctions(builder.knex())) { - // Build the query to find its limits, also those of the relation's - // `modify` and of `onBuildKnex()` hooks. This runs its modifiers and - // build hooks once more than for fetching the relation. - const query = this.fetchRelationBatch(builder, models, relation, expr); - query.executeOnBuild(); - const knexQuery = query.executeOnBuildKnex(query.knex().queryBuilder()); - return !!query.findOperation(LimitPerParentOperation)?.getRange(knexQuery, query); - } else { - return false; - } + // The relation gets what its query returns, after its hooks and `runAfter()` + // callbacks: + return queryBuilder.then((result) => findOperation.assignResult(queryBuilder, result)); } shouldSkipFetched(models, relation, expr) { @@ -262,7 +241,11 @@ function findRelationPropsToSelect(builder, relationsToFetch) { const prop = ownerProp.props[c]; const col = ownerProp.cols[c]; - if (!builder.hasSelectionAs(fullCol, col) && selectionsToAdd.indexOf(fullCol) === -1) { + if ( + !builder.hasSelectionAs(fullCol, col) && + !hasAliasSelection(builder, col) && + selectionsToAdd.indexOf(fullCol) === -1 + ) { selectedProps.push(prop); selectionsToAdd.push(fullCol); } @@ -294,7 +277,3 @@ function getRelatedModels(models, expr) { return allRelated; } - -module.exports = { - WhereInEagerOperation, -}; diff --git a/lib/queryBuilder/operations/fieldExpressions.js b/lib/queryBuilder/operations/fieldExpressions.js new file mode 100644 index 000000000..1f4eaaad8 --- /dev/null +++ b/lib/queryBuilder/operations/fieldExpressions.js @@ -0,0 +1,122 @@ +import { ref } from '../ReferenceBuilder.js'; +import { isObject } from '../../utils/objectUtils.js'; +import { getFieldExpressionColumn } from '../../utils/parseFieldExpression.js'; +import { isKnexRaw, isKnexQueryBuilder } from '../../utils/knexUtils.js'; +import { getJsonDialect } from '../jsonDialects/index.js'; +import { ValidationErrorType } from '../../model/ValidationError.js'; +import { getQueryPropKeys } from '../../model/modelQueryProps.js'; + +// Inserts and updates write whole values, so they can't write into json +// columns with field expressions like `'meta:color'`. Keys with a ':' that don't +// parse as field expressions are columns, as in the query properties. +export function checkFieldExpressions(modelClass, model, operation) { + const keys = getFieldExpressionKeys(modelClass, [ + ...Object.keys(model), + ...getQueryPropKeys(model), + ]); + + if (keys.length > 0) { + throw modelClass.createValidationError({ + type: ValidationErrorType.ModelValidation, + data: Object.fromEntries( + keys.map((key) => [ + key, + [{ message: `field expressions can only be used in patch, not in ${operation}` }], + ]), + ), + }); + } +} + +// Field expressions only describe the writes into json columns, they aren't +// properties of the models that the writes return: +export function removeFieldExpressions(modelClass, model) { + for (const key of getFieldExpressionKeys(modelClass, Object.keys(model))) { + delete model[key]; + } +} + +export function convertFieldExpressionsToRaw(builder, json) { + const knex = builder.knex(); + const dialect = getJsonDialect(knex); + const modelClass = builder.modelClass(); + const declaredColumns = getDeclaredColumns(modelClass); + const convertedJson = {}; + + for (const key of Object.keys(json)) { + let val = json[key]; + + if (getFieldExpressionColumn(key, declaredColumns) !== null) { + // 'col:attr' : ref('other:lol') is transformed to + // "col" : raw(`jsonb_set("table"."col", '{attr}', to_jsonb("other"#>'{lol}'), true)`) + // on Postgres, with the column qualified, as it is ambiguous with + // `excluded` in merge(). + dialect.checkSupport(); + + let parsed = ref(key); + + // The field expression would be lost or produce invalid SQL next to a + // value for the whole json column. Report it with the property names: + if (Object.prototype.hasOwnProperty.call(json, parsed.column)) { + const property = modelClass.columnNameToPropertyName(parsed.column); + throw modelClass.createValidationError({ + type: ValidationErrorType.ModelValidation, + data: { + [modelClass.columnNameToPropertyName(key)]: [ + { + message: `can't be combined with a value for the json column '${property}'`, + keyword: null, + params: { property }, + }, + ], + }, + }); + } + + let valuePlaceholder; + + if (isKnexQueryBuilder(val) || isKnexRaw(val)) { + valuePlaceholder = dialect.toJson('?'); + } else { + valuePlaceholder = dialect.json('?'); + val = JSON.stringify(val); + } + + convertedJson[parsed.column] = knex.raw( + dialect.set('??', parsed.parsedExpr.access, valuePlaceholder), + [convertedJson[parsed.column] || `${builder.tableRef()}.${parsed.column}`, val], + ); + } else { + convertedJson[key] = val; + } + } + + return convertedJson; +} + +function getFieldExpressionKeys(modelClass, keys) { + const jsonSchema = modelClass.getJsonSchema(); + return keys.filter((key) => getFieldExpressionColumn(key, jsonSchema?.properties) !== null); +} + +const declaredColumnsByModelClass = new WeakMap(); + +// The keys of `json` are column names, so map the properties with a ':' that the +// schema declares to their columns, once per model class. Properties that only +// the schema returned by `$beforeValidate()` declares aren't known here. +export function getDeclaredColumns(modelClass) { + let declaredColumns = declaredColumnsByModelClass.get(modelClass); + + if (!declaredColumns) { + const jsonSchema = modelClass.getJsonSchema(); + const properties = jsonSchema && jsonSchema.properties; + declaredColumns = Object.fromEntries( + (isObject(properties) ? Object.keys(properties) : []) + .filter((key) => key.includes(':')) + .map((key) => [modelClass.propertyNameToColumnName(key), true]), + ); + declaredColumnsByModelClass.set(modelClass, declaredColumns); + } + + return declaredColumns; +} diff --git a/lib/queryBuilder/operations/jsonApi/WhereJsonHasOperation.js b/lib/queryBuilder/operations/jsonApi/WhereJsonHasOperation.js new file mode 100644 index 000000000..ce0cd4ab4 --- /dev/null +++ b/lib/queryBuilder/operations/jsonApi/WhereJsonHasOperation.js @@ -0,0 +1,20 @@ +import { WhereJsonOperation } from './WhereJsonOperation.js'; +import { parseFieldExpression } from '../../../utils/parseFieldExpression.js'; +import { asArray, isString } from '../../../utils/objectUtils.js'; + +// `whereJsonHasAny()`, `whereJsonHasAll()` and their variants. +export class WhereJsonHasOperation extends WhereJsonOperation { + // @override + buildWhere(dialect, [fieldExpression, keys]) { + const { columnName, access } = parseFieldExpression(fieldExpression); + keys = asArray(keys); + + if (keys.length === 0) { + throw new Error('At least one key to find is required.'); + } else if (!keys.every(isString)) { + throw new Error('All keys to find must be strings.'); + } + + return dialect.hasKeys(columnName, access, keys, this.opt.all); + } +} diff --git a/lib/queryBuilder/operations/jsonApi/WhereJsonHasPostgresOperation.js b/lib/queryBuilder/operations/jsonApi/WhereJsonHasPostgresOperation.js deleted file mode 100644 index 75262f389..000000000 --- a/lib/queryBuilder/operations/jsonApi/WhereJsonHasPostgresOperation.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict'; - -const jsonApi = require('./postgresJsonApi'); -const { ObjectionToKnexConvertingOperation } = require('../ObjectionToKnexConvertingOperation'); - -class WhereJsonHasPostgresOperation extends ObjectionToKnexConvertingOperation { - onBuildKnex(knexBuilder, builder) { - jsonApi.warnIfNotPostgres(builder.knex()); - const args = this.getKnexArgs(builder); - - const rawArgs = jsonApi.whereJsonFieldRightStringArrayOnLeftQuery( - builder.knex(), - args[0], - this.opt.operator, - args[1], - ); - - if (this.opt.bool === 'or') { - knexBuilder = knexBuilder.orWhereRaw(...rawArgs); - } else { - knexBuilder = knexBuilder.whereRaw(...rawArgs); - } - - return knexBuilder; - } -} - -module.exports = { - WhereJsonHasPostgresOperation, -}; diff --git a/lib/queryBuilder/operations/jsonApi/WhereJsonNotObjectPostgresOperation.js b/lib/queryBuilder/operations/jsonApi/WhereJsonNotObjectPostgresOperation.js deleted file mode 100644 index 43bf3305e..000000000 --- a/lib/queryBuilder/operations/jsonApi/WhereJsonNotObjectPostgresOperation.js +++ /dev/null @@ -1,37 +0,0 @@ -'use strict'; - -const jsonApi = require('./postgresJsonApi'); -const { ObjectionToKnexConvertingOperation } = require('../ObjectionToKnexConvertingOperation'); - -class WhereJsonNotObjectPostgresOperation extends ObjectionToKnexConvertingOperation { - onBuildKnex(knexBuilder, builder) { - jsonApi.warnIfNotPostgres(builder.knex()); - return this.whereJsonNotObject(knexBuilder, builder.knex(), this.getKnexArgs(builder)[0]); - } - - whereJsonNotObject(knexBuilder, knex, fieldExpression) { - const innerQuery = (innerQuery) => { - const builder = jsonApi.whereJsonbRefOnLeftJsonbValOrRefOnRight( - innerQuery, - fieldExpression, - '@>', - this.opt.compareValue, - 'not', - ); - - builder.orWhereRaw(...jsonApi.whereJsonFieldQuery(knex, fieldExpression, 'IS', null)); - }; - - if (this.opt.bool === 'or') { - knexBuilder = knexBuilder.orWhere(innerQuery); - } else { - knexBuilder = knexBuilder.where(innerQuery); - } - - return knexBuilder; - } -} - -module.exports = { - WhereJsonNotObjectPostgresOperation, -}; diff --git a/lib/queryBuilder/operations/jsonApi/WhereJsonOperation.js b/lib/queryBuilder/operations/jsonApi/WhereJsonOperation.js new file mode 100644 index 000000000..f371fcc4d --- /dev/null +++ b/lib/queryBuilder/operations/jsonApi/WhereJsonOperation.js @@ -0,0 +1,44 @@ +import { ObjectionToKnexConvertingOperation } from '../ObjectionToKnexConvertingOperation.js'; +import { getJsonDialect } from '../../jsonDialects/index.js'; +import { parseFieldExpression } from '../../../utils/parseFieldExpression.js'; +import { isObject, isString } from '../../../utils/objectUtils.js'; +import { isKnexRaw, isKnexQueryBuilder } from '../../../utils/knexUtils.js'; + +// `whereJsonSupersetOf()`, `whereJsonSubsetOf()` and their variants, and the +// base class of the operations of the other `whereJson*()` methods. +export class WhereJsonOperation extends ObjectionToKnexConvertingOperation { + onBuildKnex(knexBuilder, builder) { + const dialect = getJsonDialect(builder.knex()); + dialect.checkSupport(); + const [sql, bindings] = this.buildWhere(dialect, this.getKnexArgs(builder)); + + if (this.opt.bool === 'or') { + return knexBuilder.orWhereRaw(sql, bindings); + } else { + return knexBuilder.whereRaw(sql, bindings); + } + } + + // @overridable + buildWhere(dialect, args) { + const [left, right] = args.map((arg) => toJsonOperand(dialect, arg)); + const [sql, bindings] = this.opt.subset + ? dialect.subset(left, right) + : dialect.superset(left, right); + return [this.opt.not ? `not ${sql}` : sql, bindings]; + } +} + +function toJsonOperand(dialect, value) { + if (isString(value)) { + const { columnName, access } = parseFieldExpression(value); + return [dialect.jsonOperand(dialect.field('??', access)), [columnName]]; + } else if (isKnexRaw(value) || isKnexQueryBuilder(value)) { + // ref(), val(), raw() and subqueries, already converted to knex by the operation. + return [dialect.jsonOperand('?'), [value]]; + } else if (isObject(value)) { + return [dialect.jsonOperand(dialect.json('?')), [JSON.stringify(value)]]; + } else { + throw new Error('Invalid right hand expression.'); + } +} diff --git a/lib/queryBuilder/operations/jsonApi/WhereJsonPostgresOperation.js b/lib/queryBuilder/operations/jsonApi/WhereJsonPostgresOperation.js deleted file mode 100644 index 227a9d197..000000000 --- a/lib/queryBuilder/operations/jsonApi/WhereJsonPostgresOperation.js +++ /dev/null @@ -1,62 +0,0 @@ -'use strict'; - -const jsonApi = require('./postgresJsonApi'); -const { ObjectionToKnexConvertingOperation } = require('../ObjectionToKnexConvertingOperation'); -const { parseFieldExpression } = require('../../../utils/parseFieldExpression'); -const { isPlainObject, isString, isFunction } = require('../../../utils/objectUtils'); -const { isMySql } = require('../../../utils/knexUtils'); - -class WhereJsonPostgresOperation extends ObjectionToKnexConvertingOperation { - onBuildKnex(knexBuilder, builder) { - const knex = builder.knex(); - - if (isMySql(knex) && this.canDelegateToKnex(knexBuilder)) { - // On MySQL, knex's version of the method works (ditojs#113). The `or` - // variants are wrapped in `orWhere()`, as knex < 3 ignores their `or`. - const [column, value] = this.getKnexArgs(builder); - const { knexMethod } = this.opt; - - if (this.opt.bool === 'or') { - return knexBuilder.orWhere((qb) => qb[knexMethod](column, value)); - } else { - return knexBuilder[knexMethod](column, value); - } - } - - jsonApi.warnIfNotPostgres(knex); - const args = this.getKnexArgs(builder); - - const rawArgs = jsonApi.whereJsonbRefOnLeftJsonbValOrRefOnRightRawQueryParams( - args[0], - this.opt.operator, - args[1], - this.opt.prefix, - ); - - if (this.opt.bool === 'or') { - knexBuilder = knexBuilder.orWhereRaw.apply(knexBuilder, rawArgs); - } else { - knexBuilder = knexBuilder.whereRaw.apply(knexBuilder, rawArgs); - } - - return knexBuilder; - } - - // Only the simple form with a plain column and a plain object or array value - // can be passed on to knex, as knex doesn't understand field expressions. - canDelegateToKnex(knexBuilder) { - const [column, value] = this.args; - - return ( - !!this.opt.knexMethod && - isFunction(knexBuilder[this.opt.knexMethod]) && - isString(column) && - parseFieldExpression(column).access.length === 0 && - (isPlainObject(value) || Array.isArray(value)) - ); - } -} - -module.exports = { - WhereJsonPostgresOperation, -}; diff --git a/lib/queryBuilder/operations/jsonApi/WhereJsonTypeOperation.js b/lib/queryBuilder/operations/jsonApi/WhereJsonTypeOperation.js new file mode 100644 index 000000000..611d8c6ef --- /dev/null +++ b/lib/queryBuilder/operations/jsonApi/WhereJsonTypeOperation.js @@ -0,0 +1,14 @@ +import { WhereJsonOperation } from './WhereJsonOperation.js'; +import { parseFieldExpression } from '../../../utils/parseFieldExpression.js'; + +// `whereJsonIsArray()`, `whereJsonNotArray()`, `whereJsonIsObject()`, +// `whereJsonNotObject()` and their variants. +export class WhereJsonTypeOperation extends WhereJsonOperation { + // @override + buildWhere(dialect, [fieldExpression]) { + const { columnName, access } = parseFieldExpression(fieldExpression); + const sql = dialect.isType('??', access, this.opt.type); + // Missing values and json `null` are neither arrays nor objects: + return [this.opt.not ? `not coalesce(${sql}, false)` : sql, [columnName]]; + } +} diff --git a/lib/queryBuilder/operations/jsonApi/postgresJsonApi.js b/lib/queryBuilder/operations/jsonApi/postgresJsonApi.js deleted file mode 100644 index 789e3520f..000000000 --- a/lib/queryBuilder/operations/jsonApi/postgresJsonApi.js +++ /dev/null @@ -1,192 +0,0 @@ -'use strict'; - -const parser = require('../../../utils/parseFieldExpression'); -const { asArray, isObject, isString } = require('../../../utils/objectUtils'); -const { isKnexRaw, isKnexQueryBuilder, isPostgres } = require('../../../utils/knexUtils'); -const { warnOnce } = require('../../../utils/warnings'); - -/** - * @typedef {String} FieldExpression - * - * Field expressions allow one to refer to separate JSONB fields inside columns. - * - * Syntax: [:] - * - * e.g. `Person.jsonColumnName:details.names[1]` would refer to value `'Second'` - * in column `Person.jsonColumnName` which has - * `{ details: { names: ['First', 'Second', 'Last'] } }` object stored in it. - * - * First part `` is compatible with column references used in - * knex e.g. `MyFancyTable.tributeToThBestColumnNameEver`. - * - * Second part describes a path to an attribute inside the referred column. - * It is optional and it always starts with colon which follows directly with - * first path element. e.g. `Table.jsonObjectColumnName:jsonFieldName` or - * `Table.jsonArrayColumn:[321]`. - * - * Syntax supports `[]` and `.` flavors of reference - * to json keys / array indexes: - * - * e.g. both `Table.myColumn:[1][3]` and `Table.myColumn:1.3` would access correctly - * both of the following objects `[null, [null,null,null, "I was accessed"]]` and - * `{ "1": { "3" : "I was accessed" } }` - * - * Caveats when using special characters in keys: - * - * 1. `objectColumn.key` This is the most common syntax, good if you are - * not using dots or square brackets `[]` in your json object key name. - * 2. Keys containing dots `objectColumn:[keywith.dots]` Column `{ "keywith.dots" : "I was referred" }` - * 3. Keys containing square brackets `column['[]']` `{ "[]" : "This is getting ridiculous..." }` - * 4. Keys containing square brackets and quotes - * `objectColumn:['Double."Quote".[]']` and `objectColumn:["Sinlge.'Quote'.[]"]` - * Column `{ "Double.\"Quote\".[]" : "I was referred", "Single.'Quote'.[]" : "Mee too!" }` - * 5. Empty keys `objectColumn:[""]` or `objectColumn:['']` Column `{ "" : "I was referred" }` - * 99. Keys containing dots, square brackets, single quotes and double quotes in one json key is - * not currently supported - */ - -// JSON field expressions and the `whereJson*()` methods generate Postgres-only -// SQL (ditojs#113). As some of it happens to run on other databases, only warn. -// TODO: Consider throwing an error instead in objection 4.0 (#272). -function warnIfNotPostgres(knex) { - if (!isPostgres(knex)) { - warnOnce( - 'JSON field expressions and the whereJson*() methods of objection are only supported ' + - 'on PostgreSQL. Use the JSON methods of knex like whereJsonPath() or jsonExtract() ' + - 'with other databases.', - ); - } -} - -// Returns the SQL and bindings of a field expression. The column is passed as an -// identifier binding (`??`), so that knex's `wrapIdentifier` (e.g. used by -// `knexSnakeCaseMappers()`) is applied to it. -function parseFieldExpression(expression, extractAsText) { - let parsed = parser.parseFieldExpression(expression); - let bindings = [parsed.columnName]; - // Reference the bare column when there is no json path, so that Postgres can - // use indexes on it. `#>> '{}'` is still needed to extract the value as text. - if (parsed.access.length === 0 && !extractAsText) { - return ['??', bindings]; - } - let extractor = extractAsText ? '#>>' : '#>'; - return [`??${extractor}${parser.toPostgresJsonPathLiteral(parsed.access)}`, bindings]; -} - -function whereJsonbRefOnLeftJsonbValOrRefOnRight( - builder, - fieldExpression, - operator, - jsonObjectOrFieldExpression, - queryPrefix, -) { - let queryParams = whereJsonbRefOnLeftJsonbValOrRefOnRightRawQueryParams( - fieldExpression, - operator, - jsonObjectOrFieldExpression, - queryPrefix, - ); - return builder.whereRaw.apply(builder, queryParams); -} - -function whereJsonbRefOnLeftJsonbValOrRefOnRightRawQueryParams( - fieldExpression, - operator, - jsonObjectOrFieldExpression, - queryPrefix, -) { - let [fieldReference, bindings] = parseFieldExpression(fieldExpression); - let [rightHandSql, rightHandBindings] = rightHandExpression(jsonObjectOrFieldExpression); - let query = ['(', fieldReference, ')::jsonb', operator, rightHandSql]; - - if (queryPrefix) { - query.unshift(queryPrefix); - } - - return [query.join(' '), [...bindings, ...rightHandBindings]]; -} - -function rightHandExpression(jsonObjectOrFieldExpression) { - if (isString(jsonObjectOrFieldExpression)) { - let [rightHandReference, bindings] = parseFieldExpression(jsonObjectOrFieldExpression); - return [`( ${rightHandReference} )::jsonb`, bindings]; - } else if ( - isKnexRaw(jsonObjectOrFieldExpression) || - isKnexQueryBuilder(jsonObjectOrFieldExpression) - ) { - // ref(), val(), raw() and subqueries, already converted to knex by the operation. - return ['( ? )::jsonb', [jsonObjectOrFieldExpression]]; - } else if (isObject(jsonObjectOrFieldExpression)) { - return ['?::jsonb', [JSON.stringify(jsonObjectOrFieldExpression)]]; - } - - throw new Error('Invalid right hand expression.'); -} - -function whereJsonFieldRightStringArrayOnLeftQuery(knex, fieldExpression, operator, keys) { - let [fieldReference, bindings] = parseFieldExpression(fieldExpression); - keys = asArray(keys); - - let questionMarksArray = keys.map((key) => { - if (!isString(key)) { - throw new Error('All keys to find must be strings.'); - } - return '?'; - }); - - return [ - `${fieldReference} ${operator.replace('?', '\\?')} array[${questionMarksArray.join(',')}]`, - [...bindings, ...keys], - ]; -} - -function whereJsonFieldQuery(knex, fieldExpression, operator, value) { - let [fieldReference, bindings] = parseFieldExpression(fieldExpression, true); - let normalizedOperator = normalizeOperator(knex, operator); - - // json type comparison takes json type in string format - let cast; - let valuePlaceholder = '?'; - let type = typeof value; - - if (type === 'number') { - cast = '::NUMERIC'; - } else if (type === 'boolean') { - cast = '::BOOLEAN'; - } else if (type === 'string') { - cast = '::TEXT'; - } else if (value === null) { - cast = '::TEXT'; - valuePlaceholder = 'NULL'; - } else { - throw new Error('Value must be string, number, boolean or null.'); - } - - // Bind the value so that `?` in it isn't read as a binding placeholder. - return [ - `(${fieldReference})${cast} ${normalizedOperator} ${valuePlaceholder}`, - value === null ? bindings : [...bindings, value], - ]; -} - -function normalizeOperator(knex, operator) { - let trimmedLowerCase = operator.trim().toLowerCase(); - - switch (trimmedLowerCase) { - case 'is': - case 'is not': - return trimmedLowerCase; - default: - return knex.client.formatter().operator(operator); - } -} - -module.exports = { - warnIfNotPostgres, - parseFieldExpression: parseFieldExpression, - whereJsonbRefOnLeftJsonbValOrRefOnRight: whereJsonbRefOnLeftJsonbValOrRefOnRight, - whereJsonbRefOnLeftJsonbValOrRefOnRightRawQueryParams: - whereJsonbRefOnLeftJsonbValOrRefOnRightRawQueryParams, - whereJsonFieldRightStringArrayOnLeftQuery: whereJsonFieldRightStringArrayOnLeftQuery, - whereJsonFieldQuery: whereJsonFieldQuery, -}; diff --git a/lib/queryBuilder/operations/select/SelectOperation.js b/lib/queryBuilder/operations/select/SelectOperation.js index b3dba7409..7c05550bd 100644 --- a/lib/queryBuilder/operations/select/SelectOperation.js +++ b/lib/queryBuilder/operations/select/SelectOperation.js @@ -1,8 +1,6 @@ -'use strict'; - -const { flatten, isString, isPlainObject } = require('../../../utils/objectUtils'); -const { Selection } = require('./Selection'); -const { ObjectionToKnexConvertingOperation } = require('../ObjectionToKnexConvertingOperation'); +import { flatten, isString, isPlainObject } from '../../../utils/objectUtils.js'; +import { Selection } from './Selection.js'; +import { ObjectionToKnexConvertingOperation } from '../ObjectionToKnexConvertingOperation.js'; const COUNT_REGEX = /count/i; const ALIAS_REGEX = /\s+as\s+/i; @@ -18,13 +16,12 @@ const AGGREGATE_METHODS = new Set([ 'avgDistinct', ]); -class SelectOperation extends ObjectionToKnexConvertingOperation { +export class SelectOperation extends ObjectionToKnexConvertingOperation { constructor(name, opt) { super(name, opt); this.selections = []; - // The selections that aggregates select, by their aliases. `null` for - // non-aggregate operations. - this.aggregateSelections = AGGREGATE_METHODS.has(name) ? [] : null; + // Aggregates select their aliases, not the columns they're passed. + this.isAggregate = AGGREGATE_METHODS.has(name); // `distinctOn()` makes the rows distinct on its columns, it doesn't select them. this.isDistinctOn = name === 'distinctOn'; } @@ -40,7 +37,11 @@ class SelectOperation extends ObjectionToKnexConvertingOperation { const ret = super.onAdd(builder, selections); - if (!this.isDistinctOn) { + if (this.isAggregate) { + this.selections = getAggregateAliases(selections).map( + (alias) => new Selection(null, null, alias), + ); + } else if (!this.isDistinctOn) { for (const selection of selections) { const selectionInstance = Selection.create(selection); @@ -50,12 +51,6 @@ class SelectOperation extends ObjectionToKnexConvertingOperation { } } - if (this.aggregateSelections) { - this.aggregateSelections = getAggregateAliases(selections).map( - (alias) => new Selection(null, null, alias), - ); - } - return ret; } @@ -82,7 +77,6 @@ class SelectOperation extends ObjectionToKnexConvertingOperation { clone() { const clone = super.clone(); clone.selections = this.selections.slice(); - clone.aggregateSelections = this.aggregateSelections && this.aggregateSelections.slice(); return clone; } } @@ -102,7 +96,3 @@ function getAggregateAliases([column, options]) { return []; } } - -module.exports = { - SelectOperation, -}; diff --git a/lib/queryBuilder/operations/select/Selection.js b/lib/queryBuilder/operations/select/Selection.js index a9d05fbd2..b950c6a94 100644 --- a/lib/queryBuilder/operations/select/Selection.js +++ b/lib/queryBuilder/operations/select/Selection.js @@ -1,7 +1,5 @@ -'use strict'; - -const { isString, isObject, isPlainObject } = require('../../../utils/objectUtils'); -const { isKnexQueryBuilder, isKnexRaw } = require('../../../utils/knexUtils'); +import { isString, isObject, isPlainObject } from '../../../utils/objectUtils.js'; +import { isKnexQueryBuilder, isKnexRaw } from '../../../utils/knexUtils.js'; const ALIAS_REGEX = /\s+as\s+/i; @@ -10,7 +8,7 @@ const ALIAS_REGEX = /\s+as\s+/i; const RAW_ALIAS_REGEX = /\s+as\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\?\?)|:(\w+):|([a-z_][\w$]*))\s*$/i; -class Selection { +export class Selection { constructor(table, column, alias) { this.table = table; this.column = column; @@ -97,10 +95,22 @@ Object.defineProperties(Selection.prototype, { }, }); +// A selection without a column, of an aggregate, raw SQL or a subquery, selects +// the column that its alias names, e.g. `max('ownerId as ownerId')` the join +// column that the related models are assigned by. Selecting the column once +// more would replace its value. +export function hasAliasSelection(builder, alias) { + return builder + .findAllSelections() + .some( + (selection) => selection.column === null && isSameIdentifier(builder, selection.alias, alias), + ); +} + // Identifiers that only differ in a way that knex's `wrapIdentifier` mapping // (for example `knexSnakeCaseMappers()`) cancels out are the same identifier: // `dataExtension` and `data_extension` both end up as `data_extension`. -function isSameIdentifier(builder, identifier1, identifier2) { +export function isSameIdentifier(builder, identifier1, identifier2) { if (identifier1 === identifier2) { return true; } else if (!identifier1 || !identifier2) { @@ -112,7 +122,7 @@ function isSameIdentifier(builder, identifier1, identifier2) { // As in SQL, a table name without a schema refers to the table with a schema: // `persons` and `public.persons` are the same table. -function isSameTable(builder, table1, table2) { +export function isSameTable(builder, table1, table2) { if (isSameIdentifier(builder, table1, table2)) { return true; } else if (!table1 || !table2 || hasSchema(table1) === hasSchema(table2)) { @@ -126,11 +136,11 @@ function hasSchema(table) { return table.includes('.'); } -function withoutSchema(table) { +export function withoutSchema(table) { return table.slice(table.lastIndexOf('.') + 1); } -function mapIdentifier(builder, identifier) { +export function mapIdentifier(builder, identifier) { const knex = builder.unsafeKnex(); const wrapIdentifier = knex && knex.client && knex.client.config.wrapIdentifier; @@ -227,11 +237,3 @@ function createSelectionFromString(selection) { return new Selection(table, column, alias); } - -module.exports = { - Selection, - isSameIdentifier, - isSameTable, - mapIdentifier, - withoutSchema, -}; diff --git a/lib/queryBuilder/operations/whereInComposite/WhereInCompositeMsSqlOperation.js b/lib/queryBuilder/operations/whereInComposite/WhereInCompositeMsSqlOperation.js index c5d0d786d..3f1e7dc3c 100644 --- a/lib/queryBuilder/operations/whereInComposite/WhereInCompositeMsSqlOperation.js +++ b/lib/queryBuilder/operations/whereInComposite/WhereInCompositeMsSqlOperation.js @@ -1,10 +1,8 @@ -'use strict'; +import { ObjectionToKnexConvertingOperation } from '../ObjectionToKnexConvertingOperation.js'; +import { flatten, zipObject, isString } from '../../../utils/objectUtils.js'; +import { getTempColumn } from '../../../utils/tmpColumnUtils.js'; -const { ObjectionToKnexConvertingOperation } = require('../ObjectionToKnexConvertingOperation'); -const { flatten, zipObject, isString } = require('../../../utils/objectUtils'); -const { getTempColumn } = require('../../../utils/tmpColumnUtils'); - -class WhereInCompositeMsSqlOperation extends ObjectionToKnexConvertingOperation { +export class WhereInCompositeMsSqlOperation extends ObjectionToKnexConvertingOperation { constructor(name, opt) { super(name, opt); this.prefix = this.opt.prefix || null; @@ -119,7 +117,3 @@ function pickNonNull(values, output) { return output; } - -module.exports = { - WhereInCompositeMsSqlOperation, -}; diff --git a/lib/queryBuilder/operations/whereInComposite/WhereInCompositeOperation.js b/lib/queryBuilder/operations/whereInComposite/WhereInCompositeOperation.js index 93dfd2853..993951a4a 100644 --- a/lib/queryBuilder/operations/whereInComposite/WhereInCompositeOperation.js +++ b/lib/queryBuilder/operations/whereInComposite/WhereInCompositeOperation.js @@ -1,10 +1,8 @@ -'use strict'; +import { ObjectionToKnexConvertingOperation } from '../ObjectionToKnexConvertingOperation.js'; +import { isObject, asSingle } from '../../../utils/objectUtils.js'; +import { isKnexQueryBuilder } from '../../../utils/knexUtils.js'; -const { ObjectionToKnexConvertingOperation } = require('../ObjectionToKnexConvertingOperation'); -const { isObject, asSingle } = require('../../../utils/objectUtils'); -const { isKnexQueryBuilder } = require('../../../utils/knexUtils'); - -class WhereInCompositeOperation extends ObjectionToKnexConvertingOperation { +export class WhereInCompositeOperation extends ObjectionToKnexConvertingOperation { constructor(name, opt) { super(name, opt); this.prefix = this.opt.prefix || null; @@ -98,7 +96,3 @@ function pickNonNull(values, output) { return output; } - -module.exports = { - WhereInCompositeOperation, -}; diff --git a/lib/queryBuilder/operations/whereInComposite/WhereInCompositeSqliteOperation.js b/lib/queryBuilder/operations/whereInComposite/WhereInCompositeSqliteOperation.js index 5f9d7d3ca..9175b8847 100644 --- a/lib/queryBuilder/operations/whereInComposite/WhereInCompositeSqliteOperation.js +++ b/lib/queryBuilder/operations/whereInComposite/WhereInCompositeSqliteOperation.js @@ -1,10 +1,8 @@ -'use strict'; +import { ObjectionToKnexConvertingOperation } from '../ObjectionToKnexConvertingOperation.js'; +import { isKnexQueryBuilder } from '../../../utils/knexUtils.js'; +import { asSingle } from '../../../utils/objectUtils.js'; -const { ObjectionToKnexConvertingOperation } = require('../ObjectionToKnexConvertingOperation'); -const { isKnexQueryBuilder } = require('../../../utils/knexUtils'); -const { asSingle } = require('../../../utils/objectUtils'); - -class WhereInCompositeSqliteOperation extends ObjectionToKnexConvertingOperation { +export class WhereInCompositeSqliteOperation extends ObjectionToKnexConvertingOperation { constructor(name, opt) { super(name, opt); this.prefix = this.opt.prefix || null; @@ -102,7 +100,3 @@ function pickNonNull(values, output) { return output; } - -module.exports = { - WhereInCompositeSqliteOperation, -}; diff --git a/lib/queryBuilder/operations/windowOrder.js b/lib/queryBuilder/operations/windowOrder.js new file mode 100644 index 000000000..4cc5d0b97 --- /dev/null +++ b/lib/queryBuilder/operations/windowOrder.js @@ -0,0 +1,56 @@ +import { isString } from '../../utils/objectUtils.js'; +import { isKnexRaw } from '../../utils/knexUtils.js'; +import { Selection, isSameTable } from './select/Selection.js'; + +// Without underscores, so that column name mappers don't change it. +export const rowNumberColumn = 'objectiontmprownum'; + +// Convert the `orderBy()` and `orderByRaw()` calls of the query to the order +// of knex's window functions, which refer to the query's table by `table`. +// Bare columns are qualified with it too, as the window can be in a query that +// joins tables with the same columns. +export function getWindowOrder(builder, knex, table) { + const order = []; + const tableRef = builder.tableRef(); + + const getAliasedColumn = (column) => { + const selection = isString(column) && Selection.create(column); + return selection && (!selection.table || isSameTable(builder, selection.table, tableRef)) + ? `${table}.${selection.column}` + : column; + }; + + builder.forEachOperation(builder.constructor.OrderBySelector, (op) => { + const args = op.getKnexArgs(builder); + + if (op.name === 'orderByRaw') { + const [sql, bindings] = args; + order.push({ column: isKnexRaw(sql) ? sql : knex.raw(sql, bindings) }); + } else { + const [column, direction, nulls] = args; + const items = Array.isArray(column) + ? column.map((item) => (isString(item) ? { column: item } : item)) + : [{ column, order: direction, nulls }]; + + for (const item of items) { + const aliasedColumn = getAliasedColumn(item.column); + + if (item.nulls) { + // Not all databases support `nulls first` and `nulls last`: + order.push({ + column: knex.raw('(case when ? is null then 0 else 1 end)', [ + isString(aliasedColumn) ? knex.ref(aliasedColumn) : aliasedColumn, + ]), + order: item.nulls === 'first' ? 'asc' : 'desc', + }); + } + + // Unlike in `orderBy()`, knex adds the direction to the window as it is: + order.push({ column: aliasedColumn, order: /^desc$/i.test(item.order) ? 'desc' : 'asc' }); + } + } + }); + + // SQL Server requires an order in the window function: + return order.length ? order : [{ column: knex.raw('(select null)') }]; +} diff --git a/lib/queryBuilder/parsers/jsonFieldExpressionParser.js b/lib/queryBuilder/parsers/jsonFieldExpressionParser.js index 823950ba6..dcdd50a40 100644 --- a/lib/queryBuilder/parsers/jsonFieldExpressionParser.js +++ b/lib/queryBuilder/parsers/jsonFieldExpressionParser.js @@ -2,8 +2,6 @@ // // https://peggyjs.org/ -'use strict'; - class peg$SyntaxError extends SyntaxError { constructor(message, expected, found, location) { super(message); @@ -987,8 +985,6 @@ function peg$parse(input, options) { } } -module.exports = { - StartRules: ['start'], - SyntaxError: peg$SyntaxError, - parse: peg$parse, -}; +const peg$allowedStartRules = ['start']; + +export { peg$allowedStartRules as StartRules, peg$SyntaxError as SyntaxError, peg$parse as parse }; diff --git a/lib/queryBuilder/parsers/relationExpressionParser.js b/lib/queryBuilder/parsers/relationExpressionParser.js index 8aa1639d4..f9da9a877 100644 --- a/lib/queryBuilder/parsers/relationExpressionParser.js +++ b/lib/queryBuilder/parsers/relationExpressionParser.js @@ -2,10 +2,6 @@ // // https://peggyjs.org/ -'use strict'; - -const { warnOnce } = require('../../utils/warnings'); - class peg$SyntaxError extends SyntaxError { constructor(message, expected, found, location) { super(message); @@ -216,12 +212,11 @@ function peg$parse(input, options) { function peg$f0(expr) { const node = newNode(); - if (expr.$name === '*') { + if (expr.$relation === '*') { + assertPlainToken(expr); node.$allRecursive = true; } else { - assertDuplicateRelation(node, expr); - node[expr.$name] = expr; - node.$childNames.push(expr.$name); + addChild(node, expr); } return node; @@ -229,11 +224,7 @@ function peg$parse(input, options) { function peg$f1(list) { const node = newNode(); - list.forEach((expr) => { - assertDuplicateRelation(node, expr); - node[expr.$name] = expr; - node.$childNames.push(expr.$name); - }); + list.forEach((expr) => addChild(node, expr)); return node; } @@ -244,11 +235,7 @@ function peg$parse(input, options) { node.$relation = name; node.$modify = args || []; - list.forEach((expr) => { - assertDuplicateRelation(node, expr); - node[expr.$name] = expr; - node.$childNames.push(expr.$name); - }); + list.forEach((expr) => addChild(node, expr)); return node; } @@ -260,20 +247,14 @@ function peg$parse(input, options) { node.$modify = args || []; if (expr) { - const match = /^\^(\d*)$/.exec(expr.$name); - - if (match) { - if (match[1]) { - node.$recursive = parseInt(match[1], 10); - } else { - node.$recursive = true; - } - } else if (expr.$name === '*') { + if (expr.$relation === '*') { + assertPlainToken(expr); node.$allRecursive = true; + } else if (isToken(expr.$relation)) { + assertPlainToken(expr); + node.$recursive = expr.$relation === '^' ? true : parseInt(expr.$relation.slice(1), 10); } else { - assertDuplicateRelation(node, expr); - node[expr.$name] = expr; - node.$childNames.push(expr.$name); + addChild(node, expr); } } @@ -944,19 +925,39 @@ function peg$parse(input, options) { return s0; } - function assertDuplicateRelation(node, expr) { + function addChild(node, expr) { + if (isToken(expr.$relation)) { + throwMisplacedToken(expr.$relation); + } + if (Object.prototype.hasOwnProperty.call(node, expr.$name)) { - warnOnce( - `Duplicate relation "${expr.$name}" in a relation expression, only the last one is used. Use "a.[b, c]" instead of "[a.b, a.c]". This will throw an error in objection 4.0.`, - ); + const err = new Error(); + err.duplicateRelationName = expr.$name; + throw err; + } - // Throws in objection 4.0, see ditojs/objection#53. - // const err = new Error(); - // err.duplicateRelationName = expr.$name; - // throw err; + node[expr.$name] = expr; + node.$childNames.push(expr.$name); + } + + // `*` and `^` end a relation path, without modifiers, aliases or relations + // after them. + function isToken(name) { + return /^(\*|\^\d*)$/.test(name); + } + + function assertPlainToken(expr) { + if (expr.$childNames.length > 0 || expr.$modify.length > 0 || expr.$name !== expr.$relation) { + throwMisplacedToken(expr.$relation); } } + function throwMisplacedToken(token) { + const err = new Error(); + err.misplacedToken = token; + throw err; + } + function newNode() { return { $name: null, @@ -1002,8 +1003,6 @@ function peg$parse(input, options) { } } -module.exports = { - StartRules: ['start'], - SyntaxError: peg$SyntaxError, - parse: peg$parse, -}; +const peg$allowedStartRules = ['start']; + +export { peg$allowedStartRules as StartRules, peg$SyntaxError as SyntaxError, peg$parse as parse }; diff --git a/lib/queryBuilder/parsers/relationExpressionParser.pegjs b/lib/queryBuilder/parsers/relationExpressionParser.pegjs index d9f90dfe0..2ed7f9005 100644 --- a/lib/queryBuilder/parsers/relationExpressionParser.pegjs +++ b/lib/queryBuilder/parsers/relationExpressionParser.pegjs @@ -1,19 +1,37 @@ -{{ - const { warnOnce } = require('../../utils/warnings'); -}} - { - function assertDuplicateRelation(node, expr) { + function addChild(node, expr) { + if (isToken(expr.$relation)) { + throwMisplacedToken(expr.$relation); + } + if (Object.prototype.hasOwnProperty.call(node, expr.$name)) { - warnOnce(`Duplicate relation "${expr.$name}" in a relation expression, only the last one is used. Use "a.[b, c]" instead of "[a.b, a.c]". This will throw an error in objection 4.0.`); + const err = new Error(); + err.duplicateRelationName = expr.$name; + throw err; + } + + node[expr.$name] = expr; + node.$childNames.push(expr.$name); + } + + // `*` and `^` end a relation path, without modifiers, aliases or relations + // after them. + function isToken(name) { + return /^(\*|\^\d*)$/.test(name); + } - // Throws in objection 4.0, see ditojs/objection#53. - // const err = new Error(); - // err.duplicateRelationName = expr.$name; - // throw err; + function assertPlainToken(expr) { + if (expr.$childNames.length > 0 || expr.$modify.length > 0 || expr.$name !== expr.$relation) { + throwMisplacedToken(expr.$relation); } } + function throwMisplacedToken(token) { + const err = new Error(); + err.misplacedToken = token; + throw err; + } + function newNode() { return { $name: null, @@ -30,12 +48,11 @@ start = expr:expression { const node = newNode() - if (expr.$name === '*') { + if (expr.$relation === '*') { + assertPlainToken(expr); node.$allRecursive = true; } else { - assertDuplicateRelation(node, expr); - node[expr.$name] = expr; - node.$childNames.push(expr.$name) + addChild(node, expr); } return node; @@ -44,11 +61,7 @@ start = list:listExpression { const node = newNode() - list.forEach(expr => { - assertDuplicateRelation(node, expr); - node[expr.$name] = expr; - node.$childNames.push(expr.$name) - }); + list.forEach(expr => addChild(node, expr)); return node; } @@ -61,11 +74,7 @@ expression = node.$relation = name; node.$modify = args || []; - list.forEach(expr => { - assertDuplicateRelation(node, expr); - node[expr.$name] = expr; - node.$childNames.push(expr.$name) - }); + list.forEach(expr => addChild(node, expr)); return node; } @@ -78,20 +87,14 @@ expression = node.$modify = args || []; if (expr) { - const match = /^\^(\d*)$/.exec(expr.$name); - - if (match) { - if (match[1]) { - node.$recursive = parseInt(match[1], 10); - } else { - node.$recursive = true; - } - } else if (expr.$name === '*') { + if (expr.$relation === '*') { + assertPlainToken(expr); node.$allRecursive = true; + } else if (isToken(expr.$relation)) { + assertPlainToken(expr); + node.$recursive = expr.$relation === '^' ? true : parseInt(expr.$relation.slice(1), 10); } else { - assertDuplicateRelation(node, expr); - node[expr.$name] = expr; - node.$childNames.push(expr.$name) + addChild(node, expr); } } diff --git a/lib/queryBuilder/transformations/CompositeQueryTransformation.js b/lib/queryBuilder/transformations/CompositeQueryTransformation.js index 61abc1c80..b4b57c5a7 100644 --- a/lib/queryBuilder/transformations/CompositeQueryTransformation.js +++ b/lib/queryBuilder/transformations/CompositeQueryTransformation.js @@ -1,8 +1,6 @@ -'use strict'; +import { QueryTransformation } from './QueryTransformation.js'; -const { QueryTransformation } = require('./QueryTransformation'); - -class CompositeQueryTransformation extends QueryTransformation { +export class CompositeQueryTransformation extends QueryTransformation { constructor(transformations) { super(); this.transformations = transformations; @@ -16,7 +14,3 @@ class CompositeQueryTransformation extends QueryTransformation { return item; } } - -module.exports = { - CompositeQueryTransformation, -}; diff --git a/lib/queryBuilder/transformations/QueryTransformation.js b/lib/queryBuilder/transformations/QueryTransformation.js index a9962bb36..703b9ce9e 100644 --- a/lib/queryBuilder/transformations/QueryTransformation.js +++ b/lib/queryBuilder/transformations/QueryTransformation.js @@ -1,11 +1,5 @@ -'use strict'; - -class QueryTransformation { +export class QueryTransformation { onConvertQueryBuilderBase(item, builder) { return item; } } - -module.exports = { - QueryTransformation, -}; diff --git a/lib/queryBuilder/transformations/WrapMysqlModifySubqueryTransformation.js b/lib/queryBuilder/transformations/WrapMysqlModifySubqueryTransformation.js index 86a0b34c5..f3d575a72 100644 --- a/lib/queryBuilder/transformations/WrapMysqlModifySubqueryTransformation.js +++ b/lib/queryBuilder/transformations/WrapMysqlModifySubqueryTransformation.js @@ -1,7 +1,5 @@ -'use strict'; - -const { QueryTransformation } = require('./QueryTransformation'); -const { isMySql } = require('../../utils/knexUtils'); +import { QueryTransformation } from './QueryTransformation.js'; +import { isMySql } from '../../utils/knexUtils.js'; /** * Mysql doesn't allow queries like this: @@ -15,7 +13,7 @@ const { isMySql } = require('../../utils/knexUtils'); * * update foo set bar = 1 where id in (select * from (select id from foo)) */ -class WrapMysqlModifySubqueryTransformation extends QueryTransformation { +export class WrapMysqlModifySubqueryTransformation extends QueryTransformation { onConvertQueryBuilderBase(query, parentQuery) { const knex = parentQuery.unsafeKnex(); @@ -66,7 +64,3 @@ function hasJoinsToTable(query, tableName) { return found; } - -module.exports = { - WrapMysqlModifySubqueryTransformation, -}; diff --git a/lib/queryBuilder/transformations/index.js b/lib/queryBuilder/transformations/index.js index fac3de799..b2e3cfcbf 100644 --- a/lib/queryBuilder/transformations/index.js +++ b/lib/queryBuilder/transformations/index.js @@ -1,15 +1,7 @@ -'use strict'; +import { CompositeQueryTransformation } from './CompositeQueryTransformation.js'; -const { CompositeQueryTransformation } = require('./CompositeQueryTransformation'); +import { WrapMysqlModifySubqueryTransformation } from './WrapMysqlModifySubqueryTransformation.js'; -const { - WrapMysqlModifySubqueryTransformation, -} = require('./WrapMysqlModifySubqueryTransformation'); - -const transformation = new CompositeQueryTransformation([ +export const transformation = new CompositeQueryTransformation([ new WrapMysqlModifySubqueryTransformation(), ]); - -module.exports = { - transformation, -}; diff --git a/lib/queryBuilder/whereGroups.js b/lib/queryBuilder/whereGroups.js index 0cbc23d75..cb03f25d5 100644 --- a/lib/queryBuilder/whereGroups.js +++ b/lib/queryBuilder/whereGroups.js @@ -1,15 +1,13 @@ -'use strict'; - -const { WhereGroupOperation } = require('./operations/WhereGroupOperation'); -const { isObject, isFunction } = require('../utils/objectUtils'); -const { isKnexRaw } = require('../utils/knexUtils'); +import { WhereGroupOperation } from './operations/WhereGroupOperation.js'; +import { isObject, isFunction } from '../utils/objectUtils.js'; +import { isKnexRaw } from '../utils/knexUtils.js'; // Where operations of a query can come from different sources: the user, a // relation's owner condition, the relation's `modify`/`filter`, the id -// condition of `patchAndFetchById()` and `$query()` and so on. Each source -// forms its own "where group". All operations the user adds belong to the -// default group, and `addWhereGroup` assigns the operations added inside its -// callback to a new, separate group. +// condition of `findById()`, `findByIds()` and `$query()` and so on. Each +// source forms its own "where group". All other operations the user adds +// belong to the default group, and `addWhereGroup` assigns the operations added +// inside its callback to a new, separate group. // // Knex simply joins all where clauses with their `and` or `or` boolean. Since // `and` binds tighter than `or`, a user's `orWhere` would escape the relation's @@ -22,7 +20,7 @@ const { isKnexRaw } = require('../utils/knexUtils'); // always ANDed together: `where "ownerId" in (1) and (a or b)`. Groups without // an `or` are left alone so that their SQL doesn't change. The operations // aren't moved, so that a built query can be cloned and built again. -function addWhereGroup(builder, callback) { +export function addWhereGroup(builder, callback) { const parentWhereGroup = builder._whereGroup; builder._whereGroup = {}; @@ -33,17 +31,10 @@ function addWhereGroup(builder, callback) { } } -// Assign an operation that isn't added through a builder, like the result of -// `toFindOperation()`, to a new, separate group. -function assignWhereGroup(operation) { - operation.whereGroup = {}; - return operation; -} - // Return a map from each where operation that needs to be wrapped in // parentheses to the `WhereGroupOperation` that builds its group in place of // the group's first operation, or to null for the group's other operations. -function getWhereGroupOperations(builder) { +export function getWhereGroupOperations(builder) { const WhereSelector = builder.constructor.WhereSelector; const whereOps = new Set(); const groups = new Map(); @@ -112,9 +103,3 @@ function mayContainTopLevelOr(op) { function isRaw(arg) { return isKnexRaw(arg) || (isObject(arg) && isFunction(arg.toKnexRaw)); } - -module.exports = { - addWhereGroup, - assignWhereGroup, - getWhereGroupOperations, -}; diff --git a/lib/queryBuilder/withoutOrder.js b/lib/queryBuilder/withoutOrder.js index 27a67b6aa..6bbacbfaa 100644 --- a/lib/queryBuilder/withoutOrder.js +++ b/lib/queryBuilder/withoutOrder.js @@ -1,19 +1,17 @@ -'use strict'; - -const { FirstOperation } = require('./operations/FirstOperation'); -const { isMsSql } = require('../utils/knexUtils'); +import { FirstOperation } from './operations/FirstOperation.js'; +import { isMsSql } from '../utils/knexUtils.js'; // Removes the order of a subquery that only selects or joins rows. The order // can come from a relation's `modify` or an `onBuild` hook of the context, so // it's removed when the subquery is built. -function withoutOrder(subquery) { +export function withoutOrder(subquery) { return subquery.onBuild(clearUnsupportedOrder); } // Without a limit, an order doesn't matter in subqueries and updates, and SQL // Server rejects it there. With a limit it decides the rows, so we keep it, and // SQL Server rejects an update rather than updating arbitrary rows. -function clearUnsupportedOrder(builder) { +export function clearUnsupportedOrder(builder) { if (isMsSql(builder.knex()) && !hasLimit(builder)) { builder.clearOrder(); } @@ -26,8 +24,3 @@ function hasLimit(builder) { builder.findOperation(FirstOperation)?.shouldLimit(builder) ); } - -module.exports = { - withoutOrder, - clearUnsupportedOrder, -}; diff --git a/lib/relations/Relation.js b/lib/relations/Relation.js index 82a7625b0..c095a5fe9 100644 --- a/lib/relations/Relation.js +++ b/lib/relations/Relation.js @@ -1,19 +1,17 @@ -'use strict'; +import { RelationProperty } from './RelationProperty.js'; -const { RelationProperty } = require('./RelationProperty'); +import { RelationFindOperation } from './RelationFindOperation.js'; +import { RelationUpdateOperation } from './RelationUpdateOperation.js'; +import { RelationDeleteOperation } from './RelationDeleteOperation.js'; -const { RelationFindOperation } = require('./RelationFindOperation'); -const { RelationUpdateOperation } = require('./RelationUpdateOperation'); -const { RelationDeleteOperation } = require('./RelationDeleteOperation'); +import { resolveModel } from '../utils/resolveModel.js'; +import { get, isFunction } from '../utils/objectUtils.js'; +import { mapAfterAllReturn } from '../utils/promiseUtils/index.js'; +import { createModifier } from '../utils/createModifier.js'; +import { addWhereGroup } from '../queryBuilder/whereGroups.js'; +import { withoutOrder } from '../queryBuilder/withoutOrder.js'; -const { resolveModel } = require('../utils/resolveModel'); -const { get, isFunction } = require('../utils/objectUtils'); -const { mapAfterAllReturn } = require('../utils/promiseUtils'); -const { createModifier } = require('../utils/createModifier'); -const { addWhereGroup } = require('../queryBuilder/whereGroups'); -const { withoutOrder } = require('../queryBuilder/withoutOrder'); - -class Relation { +export class Relation { constructor(relationName, OwnerClass) { this.name = relationName; this.ownerModelClass = OwnerClass; @@ -84,10 +82,11 @@ class Relation { return false; } - // Whether owner models may legitimately lack the owner join property at - // `index`, so that it isn't reported as missing (#132). - isOptionalOwnerProp(_index) { - return false; + // Where the keys that connect owners and related models are stored: in the + // `'owner'` rows, the `'related'` rows or the rows of a `'join'` table. Owners + // of relations that aren't one-to-one hold arrays of keys. + get keySide() { + throw new Error('not implemented'); } clone() { @@ -151,6 +150,7 @@ class Relation { relatedJoinSelectQuery = this.relatedModelClass.query().childQueryOf(builder), relatedTable = builder.tableNameFor(this.relatedModelClass), ownerTable = builder.tableRefFor(this.ownerModelClass), + modifyJoin, } = {}, ) { let relatedJoinSelect = this.applyModify(relatedJoinSelectQuery).as(relatedTableAlias); @@ -163,18 +163,41 @@ class Relation { } return builder[joinOperation](relatedJoinSelect, (join) => { - const relatedProp = this.relatedProp; - const ownerProp = this.ownerProp; + this.joinOn(builder, join, { relatedTableAlias, ownerTable }); - relatedProp.forEach((i) => { - const relatedRef = relatedProp.ref(builder, i).table(relatedTableAlias); - const ownerRef = ownerProp.ref(builder, i).table(ownerTable); + if (modifyJoin) { + this._applyModifyJoin(join, modifyJoin, { owner: ownerTable, related: relatedTableAlias }); + } + }); + } - join.on(relatedRef, ownerRef); - }); + // @overridable + joinOn(builder, join, { relatedTableAlias, ownerTable }) { + const relatedProp = this.relatedProp; + const ownerProp = this.ownerProp; + + relatedProp.forEach((i) => { + const relatedRef = relatedProp.ref(builder, i).table(relatedTableAlias); + const ownerRef = ownerProp.ref(builder, i).table(ownerTable); + + join.on(relatedRef, ownerRef); }); } + // Group the conditions of `modifyJoin()`, so that an `orOn` in them can't + // escape the key conditions of the relation. Knex renders an empty group as + // a dangling `and`, so we only add it if there are conditions. + _applyModifyJoin(join, modifyJoin, aliases) { + const group = join.clone().clear(true); + modifyJoin(group, aliases); + + if (group.has(true)) { + join.andOn(function () { + this.copyFrom(group, true); + }); + } + } + find(_, owner) { return new RelationFindOperation('find', { relation: this, @@ -406,7 +429,3 @@ function aliasedTableName(tableName, alias) { return `${tableName} as ${alias}`; } } - -module.exports = { - Relation, -}; diff --git a/lib/relations/RelationDeleteOperation.js b/lib/relations/RelationDeleteOperation.js index 2e66b7676..f4ed98b67 100644 --- a/lib/relations/RelationDeleteOperation.js +++ b/lib/relations/RelationDeleteOperation.js @@ -1,9 +1,6 @@ -'use strict'; +import { DeleteOperation } from '../queryBuilder/operations/DeleteOperation.js'; -const { DeleteOperation } = require('../queryBuilder/operations/DeleteOperation'); -const { RelationFindOperation } = require('./RelationFindOperation'); - -class RelationDeleteOperation extends DeleteOperation { +export class RelationDeleteOperation extends DeleteOperation { constructor(name, opt) { super(name, opt); @@ -16,14 +13,7 @@ class RelationDeleteOperation extends DeleteOperation { this.relation.findQuery(builder, this.owner); } - toFindOperation() { - return new RelationFindOperation('find', { - relation: this.relation, - owner: this.owner, - }); + toFindOperation(builder) { + return this.relation.find(builder, this.owner); } } - -module.exports = { - RelationDeleteOperation, -}; diff --git a/lib/relations/RelationFindOperation.js b/lib/relations/RelationFindOperation.js index 2b219fa69..7636b3906 100644 --- a/lib/relations/RelationFindOperation.js +++ b/lib/relations/RelationFindOperation.js @@ -1,9 +1,9 @@ -'use strict'; +import { FindOperation } from '../queryBuilder/operations/FindOperation.js'; +import { RangeOperation } from '../queryBuilder/operations/RangeOperation.js'; +import { asArray } from '../utils/objectUtils.js'; +import { hasAliasSelection } from '../queryBuilder/operations/select/Selection.js'; -const { FindOperation } = require('../queryBuilder/operations/FindOperation'); -const { warnOnce } = require('../utils/warnings'); - -class RelationFindOperation extends FindOperation { +export class RelationFindOperation extends FindOperation { constructor(name, opt) { super(name, opt); @@ -13,6 +13,8 @@ class RelationFindOperation extends FindOperation { this.assignResultToOwner = true; this.relationProperty = opt.relationProperty || this.relation.name; this.alias = null; + // Shared with the clones that execute the query, for `assignResult()`. + this.ownerKeyByRelated = new Map(); } onBuild(builder) { @@ -20,7 +22,7 @@ class RelationFindOperation extends FindOperation { this.relation.findQuery(builder, this.owner); if (this.owner.isModels) { - this.warnOnInvalidOwnerProps(); + this.checkOwnerProps(); if (this.assignResultToOwner) { this.selectMissingJoinColumns(builder); @@ -29,51 +31,82 @@ class RelationFindOperation extends FindOperation { } onAfter2(_, related) { - const isOneToOne = this.relation.isOneToOne(); - if (this.assignResultToOwner && this.owner.isModels) { - const owners = this.owner.modelArray; - const relatedByOwnerId = new Map(); - - for (let i = 0, l = related.length; i < l; ++i) { - const rel = related[i]; - const key = this.relation.relatedProp.propKey(rel); - let arr = relatedByOwnerId.get(key); + this.checkRelatedProps(related); + // Read the owner keys before the join columns that were only selected + // for them are omitted from the related models. + for (const rel of related) { + this.ownerKeyByRelated.set(rel, this.getOwnerKey(rel)); + } + } - if (!arr) { - arr = []; - relatedByOwnerId.set(key, arr); - } + return related; + } - arr.push(rel); - } + onAfter3(builder, related) { + const isOneToOne = this.relation.isOneToOne(); - this.warnOnMissingRelatedProps(related); + if (!this.alwaysReturnArray && isOneToOne && related.length <= 1) { + related = related[0] || undefined; + } - for (let i = 0, l = owners.length; i < l; ++i) { - const own = owners[i]; - const key = this.relation.ownerProp.propKey(own); - const related = relatedByOwnerId.get(key); + return super.onAfter3(builder, related); + } - if (isOneToOne) { - own[this.relationProperty] = (related && related[0]) || null; - } else { - own[this.relationProperty] = related || []; + // Assign what the query returns to the owners, once it has resolved, after its + // `afterFind()` hook and `runAfter()` callbacks, which may filter or reorder + // the related models. `range()` and `page()` wrap the models in `{ results, total }`. + assignResult(builder, result) { + if (this.assignResultToOwner && this.owner.isModels) { + const related = asArray(builder.has(RangeOperation) ? result.results : result); + + for (const rel of related) { + if (!this.ownerKeyByRelated.has(rel)) { + throw new Error( + `Unable to assign the result to relation "${this.relation.name}" of ` + + `${this.relation.ownerModelClass.name}: afterFind() and runAfter() can only ` + + 'return the related models that the query found, filtered or reordered.', + ); } } + + this.assignRelated(related); } - return related; + return result; } - onAfter3(builder, related) { + // @overridable + assignRelated(related) { const isOneToOne = this.relation.isOneToOne(); + const relatedByOwnerKey = new Map(); - if (!this.alwaysReturnArray && isOneToOne && related.length <= 1) { - related = related[0] || undefined; + for (const rel of related) { + const key = this.ownerKeyByRelated.get(rel); + let arr = relatedByOwnerKey.get(key); + + if (!arr) { + arr = []; + relatedByOwnerKey.set(key, arr); + } + + arr.push(rel); } - return super.onAfter3(builder, related); + for (const owner of this.owner.modelArray) { + const related = relatedByOwnerKey.get(this.relation.ownerProp.propKey(owner)); + + if (isOneToOne) { + owner[this.relationProperty] = (related && related[0]) || null; + } else { + owner[this.relationProperty] = related || []; + } + } + } + + // The key of the owner that a related model belongs to: + getOwnerKey(related) { + return this.relation.relatedProp.propKey(related); } // The expressions of the result rows in `table` that hold the keys of their @@ -84,74 +117,69 @@ class RelationFindOperation extends FindOperation { .map((ref) => ref.table(table).toKnexRaw(builder)); } - warnOnInvalidOwnerProps() { - this.warnOnMissingOwnerProps(); - - if (this.assignResultToOwner) { - this.warnOnArrayOwnerProps(); - } - } - - // Owner models without the join columns, e.g. loaded with a partial - // `select()`, silently get no related models (#1832). Only `undefined` - // is reported, as `null` is a legit value for a missing relation, and - // owner props that may legitimately be unset are skipped (#132). - // TODO: Consider throwing an error instead in objection 4.0 (#272). - warnOnMissingOwnerProps() { - const { ownerProp } = this.relation; + checkOwnerProps() { + const { name, ownerModelClass, ownerProp } = this.relation; + const owners = this.owner.modelArray; for (let c = 0, lc = ownerProp.size; c < lc; ++c) { - if (this.relation.isOptionalOwnerProp(c)) { - continue; - } - - if (this.owner.modelArray.some((owner) => ownerProp.getProp(owner, c) === undefined)) { - warnOnce( - `Fetching relation "${this.relation.name}" of ${this.relation.ownerModelClass.name}: ` + - `some owner models are missing the join property "${ownerProp.propDescription(c)}", ` + - 'so no related models are found for them. Select the column in the owner query.', + // Owner models loaded without the join columns, e.g. with a partial + // `select()`, would silently get no related models (#1832). Models built + // in memory, e.g. returned by `insert()`, may not have them yet (#132). + // Check the columns, not the values of JSON field expressions in them, + // which a JSON document may leave out. + const prop = ownerProp.props[c]; + + if (owners.some((owner) => owner.$$isFromDatabase && owner[prop] === undefined)) { + throw new Error( + `Unable to fetch relation "${name}" of ${ownerModelClass.name}: some owner models ` + + 'loaded from the database are missing the join property ' + + `"${ownerProp.propDescription(c)}". Select the column in the owner query.`, ); } } + + if (this.assignResultToOwner) { + this.checkOwnerKeys(); + } } - // Owner join properties holding an array of keys, e.g. a JSON array column, - // aren't supported: The query finds the related models, but they can't be - // assigned to their owners, so all owners silently get no related models - // (#2667). `$relatedQuery()` without assigning the results isn't affected. - warnOnArrayOwnerProps() { - const { ownerProp, joinTable } = this.relation; - // Relations through a join table already are the alternative to arrays. - const advice = joinTable - ? 'Map the relation from a column that holds a single key instead.' - : 'Use a ManyToManyRelation through a join table instead.'; + // Related models can't be assigned to owner props holding an array of keys, + // e.g. a JSON array column (#2667). `$relatedQuery()` without assigning the + // results isn't affected. + // @overridable + checkOwnerKeys() { + const { name, ownerModelClass, ownerProp, joinTable } = this.relation; + const owners = this.owner.modelArray; for (let c = 0, lc = ownerProp.size; c < lc; ++c) { - if (this.owner.modelArray.some((owner) => Array.isArray(ownerProp.getProp(owner, c)))) { - warnOnce( - `Fetching relation "${this.relation.name}" of ${this.relation.ownerModelClass.name}: ` + - `the join property "${ownerProp.propDescription(c)}" of some owner models holds ` + - 'an array, which is not supported, so no related models are found for them. ' + - advice, + if (owners.some((owner) => Array.isArray(ownerProp.getProp(owner, c)))) { + throw new Error( + `Unable to fetch relation "${name}" of ${ownerModelClass.name}: the join property ` + + `"${ownerProp.propDescription(c)}" of some owner models holds an array, which is ` + + `not supported. ${ + joinTable + ? 'Map the relation from a column that holds a single key instead.' + : 'Use a BelongsToManyRelation or a ManyToManyRelation through a join table instead.' + }`, ); } } } // Related models without the join properties can't be assigned to their - // owners and are silently dropped (#2258). This happens for example when the - // relation mapping uses column names but the rows come back with different - // property names, e.g. snake_case mappings with `knexSnakeCaseMappers`. - warnOnMissingRelatedProps(related) { - const { relatedProp } = this.relation; + // owners (#2258). This happens for example when the relation mapping uses + // column names but the rows come back with different property names, e.g. + // snake_case mappings with `knexSnakeCaseMappers`. + checkRelatedProps(related) { + const { name, ownerModelClass, relatedProp } = this.relation; for (let c = 0, lc = relatedProp.size; c < lc; ++c) { if (related.some((rel) => relatedProp.getProp(rel, c) === undefined)) { - warnOnce( - `Fetching relation "${this.relation.name}" of ${this.relation.ownerModelClass.name}: ` + - `some related models are missing the join property "${relatedProp.propDescription(c)}", ` + - 'so they are not assigned to any owner. Check that the relation mapping uses the ' + - 'property names of the models, e.g. camelCase names when using knexSnakeCaseMappers.', + throw new Error( + `Unable to fetch relation "${name}" of ${ownerModelClass.name}: some related models ` + + `are missing the join property "${relatedProp.propDescription(c)}". Check that the ` + + 'relation mapping uses the property names of the models, e.g. camelCase names when ' + + 'using knexSnakeCaseMappers.', ); } } @@ -166,7 +194,11 @@ class RelationFindOperation extends FindOperation { const prop = relatedProp.props[c]; const col = relatedProp.cols[c]; - if (!builder.hasSelectionAs(fullCol, col) && addedSelects.indexOf(fullCol) === -1) { + if ( + !builder.hasSelectionAs(fullCol, col) && + !hasAliasSelection(builder, col) && + addedSelects.indexOf(fullCol) === -1 + ) { this.omitFromResult(builder, prop); addedSelects.push(fullCol); } @@ -198,11 +230,8 @@ class RelationFindOperation extends FindOperation { clone.assignResultToOwner = this.assignResultToOwner; clone.relationProperty = this.relationProperty; clone.alias = this.alias; + clone.ownerKeyByRelated = this.ownerKeyByRelated; return clone; } } - -module.exports = { - RelationFindOperation, -}; diff --git a/lib/relations/RelationInsertOperation.js b/lib/relations/RelationInsertOperation.js index 77203946e..a8be95d81 100644 --- a/lib/relations/RelationInsertOperation.js +++ b/lib/relations/RelationInsertOperation.js @@ -1,8 +1,6 @@ -'use strict'; +import { InsertOperation } from '../queryBuilder/operations/InsertOperation.js'; -const { InsertOperation } = require('../queryBuilder/operations/InsertOperation'); - -class RelationInsertOperation extends InsertOperation { +export class RelationInsertOperation extends InsertOperation { constructor(name, opt) { super(name, opt); @@ -39,7 +37,3 @@ class RelationInsertOperation extends InsertOperation { return clone; } } - -module.exports = { - RelationInsertOperation, -}; diff --git a/lib/relations/RelationOwner.js b/lib/relations/RelationOwner.js index 089f46b6e..e7ef75661 100644 --- a/lib/relations/RelationOwner.js +++ b/lib/relations/RelationOwner.js @@ -1,8 +1,6 @@ -'use strict'; - -const { isObject, asArray, asSingle, uniqBy } = require('../utils/objectUtils'); -const { normalizeIds } = require('../utils/normalizeIds'); -const { valuesKey } = require('../model/modelValues'); +import { isObject, asArray, asSingle, uniqBy } from '../utils/objectUtils.js'; +import { normalizeIds } from '../utils/normalizeIds.js'; +import { valuesKey } from '../model/modelValues.js'; const Type = { Models: 'Models', @@ -11,7 +9,7 @@ const Type = { Identifiers: 'Identifiers', }; -class RelationOwner { +export class RelationOwner { constructor(owner) { this.owner = owner; this.type = detectType(owner); @@ -224,10 +222,6 @@ function isIdProp(relationProp) { return idProp.props.every((prop, i) => prop === relationProp.props[i]); } -function isOwnerModelClassQuery(query, relation) { +export function isOwnerModelClassQuery(query, relation) { return query.modelClass().getTableName() === relation.ownerModelClass.getTableName(); } - -module.exports = { - RelationOwner, -}; diff --git a/lib/relations/RelationProperty.js b/lib/relations/RelationProperty.js index e4ee99af9..c5082218a 100644 --- a/lib/relations/RelationProperty.js +++ b/lib/relations/RelationProperty.js @@ -1,8 +1,6 @@ -'use strict'; - -const { asArray, isObject, uniqBy, get, set } = require('../utils/objectUtils'); -const { ref: createRef } = require('../queryBuilder/ReferenceBuilder'); -const { valuesKey } = require('../model/modelValues'); +import { asArray, isObject, uniqBy, get, set } from '../utils/objectUtils.js'; +import { ref as createRef } from '../queryBuilder/ReferenceBuilder.js'; +import { valuesKey } from '../model/modelValues.js'; class ModelNotFoundError extends Error { constructor(tableName) { @@ -25,7 +23,7 @@ class InvalidReferenceError extends Error { // A relation property can be a single column, an array of columns // (composite key) a json column reference, an array of json column // references or any combination of the above. -class RelationProperty { +export class RelationProperty { // references must be a reference string like `Table.column:maybe.some.json[1].path`. // or an array of such references (composite key). // @@ -235,7 +233,3 @@ function createPatcher(ref, path) { return (patch, value) => (patch[ref.expression] = value); } } - -module.exports = { - RelationProperty, -}; diff --git a/lib/relations/RelationUpdateOperation.js b/lib/relations/RelationUpdateOperation.js index 2ec17d64b..8b9f2cd0f 100644 --- a/lib/relations/RelationUpdateOperation.js +++ b/lib/relations/RelationUpdateOperation.js @@ -1,9 +1,6 @@ -'use strict'; +import { UpdateOperation } from '../queryBuilder/operations/UpdateOperation.js'; -const { UpdateOperation } = require('../queryBuilder/operations/UpdateOperation'); -const { RelationFindOperation } = require('./RelationFindOperation'); - -class RelationUpdateOperation extends UpdateOperation { +export class RelationUpdateOperation extends UpdateOperation { constructor(name, opt) { super(name, opt); @@ -17,14 +14,7 @@ class RelationUpdateOperation extends UpdateOperation { this.relation.findQuery(builder, this.owner); } - toFindOperation() { - return new RelationFindOperation('find', { - relation: this.relation, - owner: this.owner, - }); + toFindOperation(builder) { + return this.relation.find(builder, this.owner); } } - -module.exports = { - RelationUpdateOperation, -}; diff --git a/lib/relations/RelationWriteResultOperation.js b/lib/relations/RelationWriteResultOperation.js new file mode 100644 index 000000000..d4ab66132 --- /dev/null +++ b/lib/relations/RelationWriteResultOperation.js @@ -0,0 +1,15 @@ +import { QueryBuilderOperation } from '../queryBuilder/operations/QueryBuilderOperation.js'; + +// Resolves the rows that writes through `$relatedQuery()` of a to-one relation +// return with `returning()` like its finds: one model, or `undefined`. +export class RelationWriteResultOperation extends QueryBuilderOperation { + onAfter3(_, result) { + return Array.isArray(result) && result.length <= 1 ? result[0] : result; + } + + // Find queries of the write, e.g. `asFindQuery()` in hooks, resolve to all + // rows that it writes. + toFindOperation() { + return null; + } +} diff --git a/lib/relations/belongsToMany/BelongsToManyDeleteOperation.js b/lib/relations/belongsToMany/BelongsToManyDeleteOperation.js new file mode 100644 index 000000000..c0025d562 --- /dev/null +++ b/lib/relations/belongsToMany/BelongsToManyDeleteOperation.js @@ -0,0 +1,23 @@ +import { RelationDeleteOperation } from '../RelationDeleteOperation.js'; + +export class BelongsToManyDeleteOperation extends RelationDeleteOperation { + // Find the keys of the rows to delete first, to remove them from the owners + // after the delete. + async onBefore2(builder, result) { + result = await super.onBefore2(builder, result); + + if (builder.isExecutable()) { + this.keys = await this.relation.findKeys(builder, this.owner); + } + + return result; + } + + async onAfter1(builder, result) { + if (this.keys) { + await this.relation.updateKeys(builder, this.owner, { remove: this.keys }); + } + + return result; + } +} diff --git a/lib/relations/belongsToMany/BelongsToManyFindOperation.js b/lib/relations/belongsToMany/BelongsToManyFindOperation.js new file mode 100644 index 000000000..4d56a7464 --- /dev/null +++ b/lib/relations/belongsToMany/BelongsToManyFindOperation.js @@ -0,0 +1,35 @@ +import { RelationFindOperation } from '../RelationFindOperation.js'; +import { valuesKey } from '../../model/modelValues.js'; +import { groupBy } from '../../utils/objectUtils.js'; + +export class BelongsToManyFindOperation extends RelationFindOperation { + // Arrays of keys are what the owner props hold here, see `getKeys()`. + // @override + checkOwnerKeys() {} + + // A related model belongs to every owner that holds its key. They keep the + // order of the query, not the order of the keys in the arrays. + // @override + assignRelated(related) { + const indexByRelated = new Map(related.map((rel, index) => [rel, index])); + const relatedByKey = groupBy(related, (rel) => this.ownerKeyByRelated.get(rel)); + + for (const owner of this.owner.modelArray) { + const keys = new Set(this.relation.getKeys(owner).map((key) => valuesKey([key]))); + + owner[this.relationProperty] = [...keys] + .flatMap((key) => relatedByKey.get(key) || []) + .sort((a, b) => indexByRelated.get(a) - indexByRelated.get(b)); + } + } + + // The related models of all owners come back in one list, without a column + // to partition them by owner. + // @override + ownerKeys() { + throw this.relation.createError( + 'limit(), offset() and first() are not supported for each owner. Pass ' + + '`limitPerParent: false` to the graph options to limit all owners together.', + ); + } +} diff --git a/lib/relations/belongsToMany/BelongsToManyInsertOperation.js b/lib/relations/belongsToMany/BelongsToManyInsertOperation.js new file mode 100644 index 000000000..abe69ddd5 --- /dev/null +++ b/lib/relations/belongsToMany/BelongsToManyInsertOperation.js @@ -0,0 +1,28 @@ +import { RelationInsertOperation } from '../RelationInsertOperation.js'; +import { InsertGraphOperation } from '../../queryBuilder/operations/InsertGraphOperation.js'; +import { assignRelated } from '../../model/modelSet.js'; + +export class BelongsToManyInsertOperation extends RelationInsertOperation { + async onAfter1(builder, ret) { + const inserted = await super.onAfter1(builder, ret); + + // A query resolved without executing it, e.g. with `resolve()`, inserted + // nothing. `insertGraph()` resolves the query itself, but always inserts + // the graph. + if (!builder.isExecutable() && !builder.has(InsertGraphOperation)) { + return inserted; + } + + if (this.assignResultToOwner && this.owner.isModels) { + for (const owner of this.owner.modelArray) { + assignRelated(owner, this.relation, inserted, { append: true }); + } + } + + await this.relation.updateKeys(builder, this.owner, { + add: this.models.map((model) => this.relation.relatedProp.getProp(model, 0)), + }); + + return inserted; + } +} diff --git a/lib/relations/belongsToMany/BelongsToManyRelateOperation.js b/lib/relations/belongsToMany/BelongsToManyRelateOperation.js new file mode 100644 index 000000000..6dc499001 --- /dev/null +++ b/lib/relations/belongsToMany/BelongsToManyRelateOperation.js @@ -0,0 +1,16 @@ +import { normalizeIds } from '../../utils/normalizeIds.js'; +import { RelateOperation } from '../../queryBuilder/operations/RelateOperation.js'; + +export class BelongsToManyRelateOperation extends RelateOperation { + onAdd(_, args) { + this.input = args[0]; + this.ids = normalizeIds(args[0], this.relation.relatedProp, { arrayOutput: true }); + return true; + } + + queryExecutor(builder) { + return this.relation.updateKeys(builder, this.owner, { + add: this.ids.map(([key]) => key), + }); + } +} diff --git a/lib/relations/belongsToMany/BelongsToManyRelation.js b/lib/relations/belongsToMany/BelongsToManyRelation.js new file mode 100644 index 000000000..8cf404138 --- /dev/null +++ b/lib/relations/belongsToMany/BelongsToManyRelation.js @@ -0,0 +1,276 @@ +import { Relation } from '../Relation.js'; +import { BelongsToManyFindOperation } from './BelongsToManyFindOperation.js'; +import { BelongsToManyInsertOperation } from './BelongsToManyInsertOperation.js'; +import { BelongsToManyDeleteOperation } from './BelongsToManyDeleteOperation.js'; +import { BelongsToManyRelateOperation } from './BelongsToManyRelateOperation.js'; +import { BelongsToManyUnrelateOperation } from './BelongsToManyUnrelateOperation.js'; + +import { raw } from '../../queryBuilder/RawBuilder.js'; +import { ref } from '../../queryBuilder/ReferenceBuilder.js'; +import { addWhereGroup } from '../../queryBuilder/whereGroups.js'; +import { isOwnerModelClassQuery } from '../RelationOwner.js'; +import { valuesKey } from '../../model/modelValues.js'; +import { isPostgres, isMySql, isSqlite, isMsSql, whereInKeys } from '../../utils/knexUtils.js'; +import { jsonEquals } from '../../utils/objectUtils.js'; +import * as promiseUtils from '../../utils/promiseUtils/index.js'; + +// The alias of the owner rows in `findQuery()` when the owners are given as ids +// or as a query: +const ownersAlias = 'objectiontmpowners'; + +export class BelongsToManyRelation extends Relation { + // The owner holds the keys, in an array. + get keySide() { + return 'owner'; + } + + setMapping(mapping) { + const retVal = super.setMapping(mapping); + + if (this.ownerProp.size !== 1) { + throw this.createError('composite keys are not supported.'); + } + + return retVal; + } + + // The keys of the related models that `owner` holds in its array: + getKeys(owner) { + const keys = this.ownerProp.getProp(owner, 0); + + if (keys == null) { + return []; + } else if (Array.isArray(keys)) { + return keys.filter((key) => key != null); + } else { + throw this.createError( + `the join property "${this.ownerProp.propDescription(0)}" must hold an array of keys, ` + + `not ${JSON.stringify(keys)}. Add it to the jsonAttributes of ${this.ownerModelClass.name} ` + + 'if it holds a JSON array.', + ); + } + } + + // Remove the duplicate keys, keeping the first of each: + normalizeKeys(keys) { + const seen = new Set(); + + return keys.filter((key) => { + const id = valuesKey([key]); + const isNew = !seen.has(id); + seen.add(id); + return isNew; + }); + } + + // Remove the keys in `remove` from `keys` and add the ones in `add`, leaving + // out empty keys. Keys that are already there keep their place and value: + changeKeys(keys, { add = [], remove = [] }) { + const removedKeys = new Set(remove.map((key) => valuesKey([key]))); + + return this.normalizeKeys([ + ...keys.filter((key) => !removedKeys.has(valuesKey([key]))), + ...add.filter((key) => key != null), + ]); + } + + // Create the patch that writes `keys` to the array of the `owner` row: only + // the array inside a JSON column, unless the column is empty. + createKeysPatch(owner, keys) { + const patch = {}; + + if (owner[this.ownerProp.props[0]] == null) { + this.ownerProp.setProp(patch, 0, keys); + } else { + this.ownerProp.patch(patch, 0, keys); + } + + return patch; + } + + findQuery(builder, owner) { + const relatedRef = this.relatedProp.ref(builder, 0); + + addWhereGroup(builder, () => { + if (owner.isReference) { + builder.where(containsKey(builder, owner.reference[0], relatedRef)); + } else if (owner.isModels) { + const keys = this.normalizeKeys(owner.modelArray.flatMap((model) => this.getKeys(model))); + // Empty arrays are normal here, so we don't resolve the query early, + // to keep the result of updates, deletes and aggregates. + if (keys.length) { + whereInKeys(builder, [relatedRef], keys); + } else { + builder.where(false); + } + } else if (owner.isIdentifiers || owner.isQueryBuilder) { + // Look for the related rows in the arrays of the owner rows. The owner + // query becomes a derived table, so that its `limit()` keeps applying + // to the owners. The arrays are selected under the name of the owner + // column, also for JSON field expressions. + const owners = owner.getProps(this); + const keysColumn = this.ownerProp.cols[0]; + + if (isOwnerModelClassQuery(owners, this)) { + owners.clearSelect().select(this.ownerProp.ref(owners, 0).as(keysColumn)); + } + + builder.whereExists( + this.ownerModelClass + .query() + .childQueryOf(builder) + .select(1) + .from(owners.as(ownersAlias)) + .where(containsKey(builder, ref(`${ownersAlias}.${keysColumn}`), relatedRef)), + ); + } else { + builder.where(false).resolve([]); + } + }); + + return this.applyModify(builder); + } + + // @override + joinOn(builder, join, { relatedTableAlias, ownerTable }) { + const relatedRef = this.relatedProp.ref(builder, 0).table(relatedTableAlias); + const keysRef = this.ownerProp.ref(builder, 0).table(ownerTable); + + join.on(containsKey(builder, keysRef, relatedRef)); + } + + find(_, owner) { + return new BelongsToManyFindOperation('find', { + relation: this, + owner, + }); + } + + insert(_, owner) { + return new BelongsToManyInsertOperation('insert', { + relation: this, + owner, + }); + } + + delete(_, owner) { + return new BelongsToManyDeleteOperation('delete', { + relation: this, + owner, + }); + } + + relate(_, owner) { + return new BelongsToManyRelateOperation('relate', { + relation: this, + owner, + }); + } + + unrelate(_, owner) { + return new BelongsToManyUnrelateOperation('unrelate', { + relation: this, + owner, + }); + } + + // Find the keys of the related rows that `builder` filters, for `unrelate()` + // and `delete()`. Like all internal reads of the relation, it calls no find + // hooks. + findKeys(builder, owner) { + const query = this.relatedModelClass + .query() + .childQueryOf(builder) + .findOptions({ dontCallFindHooks: true }); + + return query + .copyFrom(builder, builder.constructor.JoinSelector) + .copyFrom(builder, builder.constructor.WhereSelector) + .modify((query) => this.findQuery(query, owner)) + .select(this.relatedProp.ref(query, 0)) + .runAfter((related) => related.map((rel) => this.relatedProp.getProp(rel, 0))); + } + + // Add and remove keys in the arrays of the owner rows, and in the owner + // models. Resolves to the number of owner rows that changed. The arrays are + // read without the find hooks and written back by separate queries. + updateKeys(builder, owner, { add = [], remove = [] }) { + const { ownerModelClass, ownerProp } = this; + const idProp = ownerModelClass.getIdRelationProperty(); + const ownerIds = owner.getProps(this, idProp); + const query = ownerModelClass + .query() + .childQueryOf(builder) + .findOptions({ dontCallFindHooks: true }); + const idRefs = idProp.refs(query); + + const updateRow = async (row) => { + const keys = this.getKeys(row); + const newKeys = this.changeKeys(keys, { add, remove }); + const isChanged = !jsonEquals(keys, newKeys); + + if (isChanged) { + const patch = this.createKeysPatch(row, newKeys); + + await ownerModelClass.query().childQueryOf(builder).patch(patch).findById(row.$id()); + } + + if (owner.isModels) { + const id = row.$idKey(); + + for (const model of owner.modelArray) { + if (model.$hasId() && model.$idKey() === id) { + ownerProp.setProp(model, 0, newKeys); + } + } + } + + return isChanged; + }; + + // Select the whole column of the arrays, also for JSON field expressions: + return query + .select([...idRefs, `${query.tableRefFor(ownerModelClass)}.${ownerProp.cols[0]}`]) + .modify((query) => { + if (ownerIds) { + query.whereInComposite(idRefs, ownerIds); + } else { + query.resolve([]); + } + }) + .runAfter(async (rows) => { + const results = await promiseUtils.map(rows, updateRow, { + concurrency: ownerModelClass.getConcurrency(builder.unsafeKnex()), + }); + + return results.filter(Boolean).length; + }); + } +} + +Object.defineProperties(BelongsToManyRelation.prototype, { + isObjectionBelongsToManyRelation: { + enumerable: false, + writable: false, + value: true, + }, +}); + +// The condition for the array of keys in `keysRef` to contain the key in +// `keyRef`. Postgres converts both to jsonb, which supports json, jsonb and +// native array columns alike. +function containsKey(builder, keysRef, keyRef) { + const knex = builder.knex(); + + if (isPostgres(knex)) { + return raw('to_jsonb(?) @> to_jsonb(?)', [keysRef, keyRef]); + } else if (isMySql(knex)) { + return raw('? member of (?)', [keyRef, keysRef]); + } else if (isSqlite(knex)) { + return raw('exists (select 1 from json_each(?) where value = ?)', [keysRef, keyRef]); + } else if (isMsSql(knex)) { + return raw('exists (select 1 from openjson(?) where value = ?)', [keysRef, keyRef]); + } else { + throw new Error(`Unsupported database for BelongsToManyRelation: '${knex.client.dialect}'.`); + } +} diff --git a/lib/relations/belongsToMany/BelongsToManyUnrelateOperation.js b/lib/relations/belongsToMany/BelongsToManyUnrelateOperation.js new file mode 100644 index 000000000..abb0ada97 --- /dev/null +++ b/lib/relations/belongsToMany/BelongsToManyUnrelateOperation.js @@ -0,0 +1,11 @@ +import { UnrelateOperation } from '../../queryBuilder/operations/UnrelateOperation.js'; + +export class BelongsToManyUnrelateOperation extends UnrelateOperation { + // Remove the keys of the related rows that the query finds, so that its + // `where` clauses and the relation's `modify` filter the related rows. + queryExecutor(builder) { + return this.relation + .findKeys(builder, this.owner) + .runAfter((keys) => this.relation.updateKeys(builder, this.owner, { remove: keys })); + } +} diff --git a/lib/relations/belongsToOne/BelongsToOneDeleteOperation.js b/lib/relations/belongsToOne/BelongsToOneDeleteOperation.js index f30eb0139..a3d34e48d 100644 --- a/lib/relations/belongsToOne/BelongsToOneDeleteOperation.js +++ b/lib/relations/belongsToOne/BelongsToOneDeleteOperation.js @@ -1,8 +1,6 @@ -'use strict'; +import { RelationDeleteOperation } from '../RelationDeleteOperation.js'; -const { RelationDeleteOperation } = require('../RelationDeleteOperation'); - -class BelongsToOneDeleteOperation extends RelationDeleteOperation { +export class BelongsToOneDeleteOperation extends RelationDeleteOperation { onAfter1(builder, result) { // A query resolved without executing it, e.g. with `none()`, deleted // nothing, so the owners are left untouched. @@ -19,7 +17,3 @@ class BelongsToOneDeleteOperation extends RelationDeleteOperation { return result; } } - -module.exports = { - BelongsToOneDeleteOperation, -}; diff --git a/lib/relations/belongsToOne/BelongsToOneInsertOperation.js b/lib/relations/belongsToOne/BelongsToOneInsertOperation.js index 74d56c860..ab0a1b863 100644 --- a/lib/relations/belongsToOne/BelongsToOneInsertOperation.js +++ b/lib/relations/belongsToOne/BelongsToOneInsertOperation.js @@ -1,10 +1,9 @@ -'use strict'; +import { RelationInsertOperation } from '../RelationInsertOperation.js'; +import { BelongsToOneRelateOperation } from './BelongsToOneRelateOperation.js'; +import { InsertGraphOperation } from '../../queryBuilder/operations/InsertGraphOperation.js'; +import { assignRelated } from '../../model/modelSet.js'; -const { RelationInsertOperation } = require('../RelationInsertOperation'); -const { BelongsToOneRelateOperation } = require('./BelongsToOneRelateOperation'); -const { InsertGraphOperation } = require('../../queryBuilder/operations/InsertGraphOperation'); - -class BelongsToOneInsertOperation extends RelationInsertOperation { +export class BelongsToOneInsertOperation extends RelationInsertOperation { onAdd(builder, args) { const retVal = super.onAdd(builder, args); @@ -33,7 +32,7 @@ class BelongsToOneInsertOperation extends RelationInsertOperation { if (this.assignResultToOwner && this.owner.isModels) { for (const owner of this.owner.modelArray) { - owner.$setRelated(this.relation, inserted); + assignRelated(owner, this.relation, inserted); } } @@ -43,7 +42,3 @@ class BelongsToOneInsertOperation extends RelationInsertOperation { return inserted; } } - -module.exports = { - BelongsToOneInsertOperation, -}; diff --git a/lib/relations/belongsToOne/BelongsToOneRelateOperation.js b/lib/relations/belongsToOne/BelongsToOneRelateOperation.js index fca689ba8..6c5cd5113 100644 --- a/lib/relations/belongsToOne/BelongsToOneRelateOperation.js +++ b/lib/relations/belongsToOne/BelongsToOneRelateOperation.js @@ -1,9 +1,7 @@ -'use strict'; +import { normalizeIds } from '../../utils/normalizeIds.js'; +import { RelateOperation } from '../../queryBuilder/operations/RelateOperation.js'; -const { normalizeIds } = require('../../utils/normalizeIds'); -const { RelateOperation } = require('../../queryBuilder/operations/RelateOperation'); - -class BelongsToOneRelateOperation extends RelateOperation { +export class BelongsToOneRelateOperation extends RelateOperation { onAdd(_, args) { this.input = args[0]; this.ids = normalizeIds(args[0], this.relation.relatedProp, { arrayOutput: true }); @@ -49,7 +47,3 @@ function assertOneId(ids) { throw new Error('can only relate one model to a BelongsToOneRelation'); } } - -module.exports = { - BelongsToOneRelateOperation, -}; diff --git a/lib/relations/belongsToOne/BelongsToOneRelation.js b/lib/relations/belongsToOne/BelongsToOneRelation.js index 584798ab1..883bfef19 100644 --- a/lib/relations/belongsToOne/BelongsToOneRelation.js +++ b/lib/relations/belongsToOne/BelongsToOneRelation.js @@ -1,23 +1,16 @@ -'use strict'; +import { Relation } from '../Relation.js'; +import { BelongsToOneInsertOperation } from './BelongsToOneInsertOperation.js'; +import { BelongsToOneDeleteOperation } from './BelongsToOneDeleteOperation.js'; +import { BelongsToOneRelateOperation } from './BelongsToOneRelateOperation.js'; +import { BelongsToOneUnrelateOperation } from './BelongsToOneUnrelateOperation.js'; -const { Relation } = require('../Relation'); -const { BelongsToOneInsertOperation } = require('./BelongsToOneInsertOperation'); -const { BelongsToOneDeleteOperation } = require('./BelongsToOneDeleteOperation'); -const { BelongsToOneRelateOperation } = require('./BelongsToOneRelateOperation'); -const { BelongsToOneUnrelateOperation } = require('./BelongsToOneUnrelateOperation'); - -class BelongsToOneRelation extends Relation { +export class BelongsToOneRelation extends Relation { isOneToOne() { return true; } - // The foreign key is unset when no related model was ever assigned, e.g. on - // models returned by `insert()` or created with `fromJson()`, where no - // related model is the right result. This can't be told apart from a - // foreign key left out of a partial `select()`. Foreign keys that are part - // of the id are always expected to be set (#132). - isOptionalOwnerProp(index) { - return !this.ownerModelClass.getIdPropertyArray().includes(this.ownerProp.props[index]); + get keySide() { + return 'owner'; } insert(_, owner) { @@ -56,7 +49,3 @@ Object.defineProperties(BelongsToOneRelation.prototype, { value: true, }, }); - -module.exports = { - BelongsToOneRelation, -}; diff --git a/lib/relations/belongsToOne/BelongsToOneUnrelateOperation.js b/lib/relations/belongsToOne/BelongsToOneUnrelateOperation.js index 4bc0f11b4..53c33f90d 100644 --- a/lib/relations/belongsToOne/BelongsToOneUnrelateOperation.js +++ b/lib/relations/belongsToOne/BelongsToOneUnrelateOperation.js @@ -1,8 +1,6 @@ -'use strict'; +import { UnrelateOperation } from '../../queryBuilder/operations/UnrelateOperation.js'; -const { UnrelateOperation } = require('../../queryBuilder/operations/UnrelateOperation'); - -class BelongsToOneUnrelateOperation extends UnrelateOperation { +export class BelongsToOneUnrelateOperation extends UnrelateOperation { onAdd() { const ids = new Array(this.relation.ownerProp.size); @@ -15,47 +13,75 @@ class BelongsToOneUnrelateOperation extends UnrelateOperation { } queryExecutor(builder) { + const { relation, owner } = this; + const { ownerProp, ownerModelClass } = relation; const patch = {}; - const ownerProp = this.relation.ownerProp; ownerProp.forEach((i) => { - const relatedValue = this.ids[0][i]; - - if (this.owner.isModels) { - for (const owner of this.owner.modelArray) { - ownerProp.setProp(owner, i, relatedValue); - } - } - - ownerProp.patch(patch, i, relatedValue); + ownerProp.patch(patch, i, this.ids[0][i]); }); - const ownerIdProp = this.relation.ownerModelClass.getIdRelationProperty(); + const ownerIdProp = ownerModelClass.getIdRelationProperty(); const ownerRefs = ownerIdProp.refs(builder); - const ownerIds = this.owner.getProps(this.relation, ownerIdProp); + const ownerIds = owner.getProps(relation, ownerIdProp); + const isFiltered = !!relation.modify || builder.has(builder.constructor.WhereSelector); + + const clearOwnerKeys = (owners) => { + for (const owner of owners) { + ownerProp.forEach((i) => ownerProp.setProp(owner, i, null)); + } + }; - const query = this.relation.ownerModelClass + const query = ownerModelClass .query() .childQueryOf(builder) .patch(patch) .whereInComposite(ownerRefs, ownerIds); - // We are creating a query to the related items. So any `where` statements - // must filter the *related* items, not the root query above, which is actually - // for the owners. - if (builder.has(builder.constructor.WhereSelector)) { + if (!isFiltered) { + if (owner.isModels) { + clearOwnerKeys(owner.modelArray); + } + } else { + // We are creating a query to the related items. So any `where` statements + // and the relation's `modify` must filter the *related* items, not the + // root query above, which is actually for the owners. query.whereExists( - this.relation.ownerModelClass - .relatedQuery(this.relation.name) + ownerModelClass + .relatedQuery(relation.name) + .childQueryOf(builder) .copyFrom(builder, builder.constructor.JoinSelector) .copyFrom(builder, builder.constructor.WhereSelector), ); + + if (owner.isModels) { + // Only clear the keys of the owner models whose related row matched. + // Unless all or none did, read back which ones the query unrelated: + query.runAfter(async (numUnrelated) => { + if (numUnrelated === ownerIds.length) { + clearOwnerKeys(owner.modelArray); + } else if (numUnrelated > 0) { + const keysQuery = ownerModelClass + .query() + .childQueryOf(builder) + .findOptions({ dontCallFindHooks: true }); + const table = keysQuery.tableRefFor(ownerModelClass); + // Select the whole columns, also for JSON field expressions: + const rows = await keysQuery + .select([...ownerRefs, ...ownerProp.cols.map((col) => `${table}.${col}`)]) + .whereInComposite(ownerRefs, ownerIds); + const unrelatedIds = new Set( + rows.filter((row) => !ownerProp.hasProps(row)).map((row) => row.$idKey()), + ); + + clearOwnerKeys(owner.modelArray.filter((owner) => unrelatedIds.has(owner.$idKey()))); + } + + return numUnrelated; + }); + } } return query; } } - -module.exports = { - BelongsToOneUnrelateOperation, -}; diff --git a/lib/relations/hasMany/HasManyInsertOperation.js b/lib/relations/hasMany/HasManyInsertOperation.js index f50f90dfd..93d02ef33 100644 --- a/lib/relations/hasMany/HasManyInsertOperation.js +++ b/lib/relations/hasMany/HasManyInsertOperation.js @@ -1,9 +1,8 @@ -'use strict'; +import { RelationInsertOperation } from '../RelationInsertOperation.js'; +import { removeOverriddenQueryProps } from '../../model/modelQueryProps.js'; +import { assignRelated } from '../../model/modelSet.js'; -const { RelationInsertOperation } = require('../RelationInsertOperation'); -const { removeOverriddenQueryProps } = require('../../model/modelQueryProps'); - -class HasManyInsertOperation extends RelationInsertOperation { +export class HasManyInsertOperation extends RelationInsertOperation { onAdd(builder, args) { const retVal = super.onAdd(builder, args); assertOwnerIsSingleItem(this.owner, this.relation); @@ -32,7 +31,7 @@ class HasManyInsertOperation extends RelationInsertOperation { if (this.owner.isModels) { for (const owner of this.owner.modelArray) { - owner.$appendRelated(this.relation, inserted); + assignRelated(owner, this.relation, inserted, { append: true }); } } @@ -59,7 +58,3 @@ function assertOwnerIsSingleItem(owner, relation) { ); } } - -module.exports = { - HasManyInsertOperation, -}; diff --git a/lib/relations/hasMany/HasManyRelateOperation.js b/lib/relations/hasMany/HasManyRelateOperation.js index 52d07c66c..fc60bebd2 100644 --- a/lib/relations/hasMany/HasManyRelateOperation.js +++ b/lib/relations/hasMany/HasManyRelateOperation.js @@ -1,10 +1,8 @@ -'use strict'; +import { normalizeIds } from '../../utils/normalizeIds.js'; +import { RelateOperation } from '../../queryBuilder/operations/RelateOperation.js'; +import { addWhereGroup } from '../../queryBuilder/whereGroups.js'; -const { normalizeIds } = require('../../utils/normalizeIds'); -const { RelateOperation } = require('../../queryBuilder/operations/RelateOperation'); -const { addWhereGroup } = require('../../queryBuilder/whereGroups'); - -class HasManyRelateOperation extends RelateOperation { +export class HasManyRelateOperation extends RelateOperation { onAdd(_, args) { this.input = args[0]; @@ -33,7 +31,7 @@ class HasManyRelateOperation extends RelateOperation { .copyFrom(builder, builder.constructor.JoinSelector) .copyFrom(builder, builder.constructor.WhereSelector) .modify((query) => { - addWhereGroup(query, () => query.findByIds(this.ids)); + query.findByIds(this.ids); addWhereGroup(query, () => query.modify(this.relation.modify)); }); } @@ -58,7 +56,3 @@ function assertOwnerIsSingleItem(owner, relation) { ); } } - -module.exports = { - HasManyRelateOperation, -}; diff --git a/lib/relations/hasMany/HasManyRelation.js b/lib/relations/hasMany/HasManyRelation.js index efb154ee4..ce142b0dd 100644 --- a/lib/relations/hasMany/HasManyRelation.js +++ b/lib/relations/hasMany/HasManyRelation.js @@ -1,11 +1,13 @@ -'use strict'; - -const { Relation } = require('../Relation'); -const { HasManyInsertOperation } = require('./HasManyInsertOperation'); -const { HasManyRelateOperation } = require('./HasManyRelateOperation'); -const { HasManyUnrelateOperation } = require('./HasManyUnrelateOperation'); +import { Relation } from '../Relation.js'; +import { HasManyInsertOperation } from './HasManyInsertOperation.js'; +import { HasManyRelateOperation } from './HasManyRelateOperation.js'; +import { HasManyUnrelateOperation } from './HasManyUnrelateOperation.js'; + +export class HasManyRelation extends Relation { + get keySide() { + return 'related'; + } -class HasManyRelation extends Relation { insert(_, owner) { return new HasManyInsertOperation('insert', { relation: this, @@ -43,7 +45,3 @@ Object.defineProperties(HasManyRelation.prototype, { value: true, }, }); - -module.exports = { - HasManyRelation, -}; diff --git a/lib/relations/hasMany/HasManyUnrelateOperation.js b/lib/relations/hasMany/HasManyUnrelateOperation.js index 94f91a617..11390d6ce 100644 --- a/lib/relations/hasMany/HasManyUnrelateOperation.js +++ b/lib/relations/hasMany/HasManyUnrelateOperation.js @@ -1,9 +1,7 @@ -'use strict'; +import { UnrelateOperation } from '../../queryBuilder/operations/UnrelateOperation.js'; +import { addWhereGroup } from '../../queryBuilder/whereGroups.js'; -const { UnrelateOperation } = require('../../queryBuilder/operations/UnrelateOperation'); -const { addWhereGroup } = require('../../queryBuilder/whereGroups'); - -class HasManyUnrelateOperation extends UnrelateOperation { +export class HasManyUnrelateOperation extends UnrelateOperation { queryExecutor(builder) { const patch = {}; const relatedProp = this.relation.relatedProp; @@ -26,7 +24,3 @@ class HasManyUnrelateOperation extends UnrelateOperation { }); } } - -module.exports = { - HasManyUnrelateOperation, -}; diff --git a/lib/relations/hasOne/HasOneRelation.js b/lib/relations/hasOne/HasOneRelation.js index b197f8a6f..fc12479ab 100644 --- a/lib/relations/hasOne/HasOneRelation.js +++ b/lib/relations/hasOne/HasOneRelation.js @@ -1,13 +1,7 @@ -'use strict'; +import { HasManyRelation } from '../hasMany/HasManyRelation.js'; -const { HasManyRelation } = require('../hasMany/HasManyRelation'); - -class HasOneRelation extends HasManyRelation { +export class HasOneRelation extends HasManyRelation { isOneToOne() { return true; } } - -module.exports = { - HasOneRelation, -}; diff --git a/lib/relations/hasOneThrough/HasOneThroughRelation.js b/lib/relations/hasOneThrough/HasOneThroughRelation.js index 13a6b921f..ea88dff8e 100644 --- a/lib/relations/hasOneThrough/HasOneThroughRelation.js +++ b/lib/relations/hasOneThrough/HasOneThroughRelation.js @@ -1,13 +1,7 @@ -'use strict'; +import { ManyToManyRelation } from '../manyToMany/ManyToManyRelation.js'; -const { ManyToManyRelation } = require('../manyToMany/ManyToManyRelation'); - -class HasOneThroughRelation extends ManyToManyRelation { +export class HasOneThroughRelation extends ManyToManyRelation { isOneToOne() { return true; } } - -module.exports = { - HasOneThroughRelation, -}; diff --git a/lib/relations/manyToMany/ManyToManyModifyMixin.js b/lib/relations/manyToMany/ManyToManyModifyMixin.js index c1d820454..d5697aa09 100644 --- a/lib/relations/manyToMany/ManyToManyModifyMixin.js +++ b/lib/relations/manyToMany/ManyToManyModifyMixin.js @@ -1,8 +1,7 @@ -'use strict'; - -const { ManyToManyFindOperation } = require('./find/ManyToManyFindOperation'); -const { addWhereGroup } = require('../../queryBuilder/whereGroups'); -const { withoutOrder } = require('../../queryBuilder/withoutOrder'); +import { ManyToManyFindOperation } from './find/ManyToManyFindOperation.js'; +import { addWhereGroup } from '../../queryBuilder/whereGroups.js'; +import { withoutOrder } from '../../queryBuilder/withoutOrder.js'; +import { whereInKeys } from '../../utils/knexUtils.js'; // This mixin contains the shared code for all modify operations (update, delete, relate, unrelate) // for ManyToManyRelation operations. @@ -12,7 +11,7 @@ const { withoutOrder } = require('../../queryBuilder/withoutOrder'); // that we are able to `innerJoin` the join table to the query. Most SQL engines don't allow // joins in updates or deletes. Join table is joined so that queries can reference the join // table columns. -const ManyToManyModifyMixin = (Operation) => { +export const ManyToManyModifyMixin = (Operation) => { return class extends Operation { constructor(...args) { super(...args); @@ -42,13 +41,13 @@ const ManyToManyModifyMixin = (Operation) => { this.removeFiltersFromMainQuery(builder); // Add a single where clause that uses the created subquery. Updates - // that modify the join rows too are executed in batches of the ids - // that `onAfterBuild` fetches, see `queryBatches()` (#161). + // that modify the join rows too filter by the ids that `onAfterBuild` + // fetches (#161). addWhereGroup(builder, () => this.modifiesJoinTable ? builder.where((whereBuilder) => - builder._queryBatch - ? this.applyIdFilterForRelatedTable(whereBuilder, builder._queryBatch) + this.relatedIds + ? this.applyIdFilterForRelatedTable(whereBuilder, this.relatedIds) : this.applyModifyFilterForRelatedTable(whereBuilder), ) : this.applyModifyFilterForRelatedTable(builder), @@ -93,7 +92,7 @@ const ManyToManyModifyMixin = (Operation) => { applyIdFilterForRelatedTable(builder, ids) { const idRefs = this.relation.relatedModelClass.getIdRelationProperty().refs(builder); - return builder.whereInComposite(idRefs, ids); + return whereInKeys(builder, idRefs, ids); } // Only modifies the join rows that match the filters (#1853). @@ -171,7 +170,3 @@ class ManyToManyModifyFindOperation extends ManyToManyFindOperation { ); } } - -module.exports = { - ManyToManyModifyMixin, -}; diff --git a/lib/relations/manyToMany/ManyToManyMySqlModifyMixin.js b/lib/relations/manyToMany/ManyToManyMySqlModifyMixin.js index 7cc287330..7ad0b8d1d 100644 --- a/lib/relations/manyToMany/ManyToManyMySqlModifyMixin.js +++ b/lib/relations/manyToMany/ManyToManyMySqlModifyMixin.js @@ -1,6 +1,4 @@ -'use strict'; - -const { ManyToManyModifyMixin } = require('./ManyToManyModifyMixin'); +import { ManyToManyModifyMixin } from './ManyToManyModifyMixin.js'; // We need to override this mixin for mysql because mysql doesn't allow a // statement that modifies the join table to read the join table in a @@ -14,7 +12,7 @@ const { ManyToManyModifyMixin } = require('./ManyToManyModifyMixin'); // join rows that only differ in their other columns (#1853). // // https://stackoverflow.com/a/2314264/3729316 -const ManyToManyMySqlModifyMixin = (Operation) => { +export const ManyToManyMySqlModifyMixin = (Operation) => { return class extends ManyToManyModifyMixin(Operation) { applyModifyFilterForJoinTable(builder) { const { joinTableOwnerProp, joinTableRelatedProp } = this.relation; @@ -49,7 +47,3 @@ const ManyToManyMySqlModifyMixin = (Operation) => { } }; }; - -module.exports = { - ManyToManyMySqlModifyMixin, -}; diff --git a/lib/relations/manyToMany/ManyToManyRelation.js b/lib/relations/manyToMany/ManyToManyRelation.js index c2c92b6ab..a940fc995 100644 --- a/lib/relations/manyToMany/ManyToManyRelation.js +++ b/lib/relations/manyToMany/ManyToManyRelation.js @@ -1,36 +1,43 @@ -'use strict'; - -const { getModel } = require('../../model/getModel'); -const { Relation } = require('../Relation'); -const { RelationProperty } = require('../RelationProperty'); - -const { isSqlite, isMySql } = require('../../utils/knexUtils'); -const { inheritModel } = require('../../model/inheritModel'); -const { resolveModel } = require('../../utils/resolveModel'); -const { mapAfterAllReturn } = require('../../utils/promiseUtils'); -const { isFunction, isObject, isString } = require('../../utils/objectUtils'); -const { createModifier } = require('../../utils/createModifier'); -const { addWhereGroup } = require('../../queryBuilder/whereGroups'); -const { withoutOrder } = require('../../queryBuilder/withoutOrder'); - -const { ManyToManyFindOperation } = require('./find/ManyToManyFindOperation'); -const { ManyToManyInsertOperation } = require('./insert/ManyToManyInsertOperation'); -const { ManyToManyRelateOperation } = require('./relate/ManyToManyRelateOperation'); - -const { ManyToManyUnrelateOperation } = require('./unrelate/ManyToManyUnrelateOperation'); -const { - ManyToManyUnrelateSqliteOperation, -} = require('./unrelate/ManyToManyUnrelateSqliteOperation'); -const { ManyToManyUnrelateMySqlOperation } = require('./unrelate/ManyToManyUnrelateMySqlOperation'); - -const { ManyToManyUpdateOperation } = require('./update/ManyToManyUpdateOperation'); -const { ManyToManyUpdateSqliteOperation } = require('./update/ManyToManyUpdateSqliteOperation'); -const { ManyToManyUpdateMySqlOperation } = require('./update/ManyToManyUpdateMySqlOperation'); - -const { ManyToManyDeleteOperation } = require('./delete/ManyToManyDeleteOperation'); -const { ManyToManyDeleteSqliteOperation } = require('./delete/ManyToManyDeleteSqliteOperation'); - -class ManyToManyRelation extends Relation { +import { getModel } from '../../model/getModel.js'; +import { Relation } from '../Relation.js'; +import { RelationProperty } from '../RelationProperty.js'; + +import { isSqlite, isMySql } from '../../utils/knexUtils.js'; +import { inheritModel } from '../../model/inheritModel.js'; +import { resolveModel } from '../../utils/resolveModel.js'; +import { mapAfterAllReturn } from '../../utils/promiseUtils/index.js'; +import { isFunction, isObject, isString } from '../../utils/objectUtils.js'; +import { createModifier } from '../../utils/createModifier.js'; +import { addWhereGroup } from '../../queryBuilder/whereGroups.js'; +import { withoutOrder } from '../../queryBuilder/withoutOrder.js'; +import { raw } from '../../queryBuilder/RawBuilder.js'; + +import { ManyToManyFindOperation } from './find/ManyToManyFindOperation.js'; +import { ManyToManyInsertOperation } from './insert/ManyToManyInsertOperation.js'; +import { ManyToManyRelateOperation } from './relate/ManyToManyRelateOperation.js'; + +import { ManyToManyUnrelateOperation } from './unrelate/ManyToManyUnrelateOperation.js'; +import { ManyToManyUnrelateSqliteOperation } from './unrelate/ManyToManyUnrelateSqliteOperation.js'; +import { ManyToManyUnrelateMySqlOperation } from './unrelate/ManyToManyUnrelateMySqlOperation.js'; + +import { ManyToManyUpdateOperation } from './update/ManyToManyUpdateOperation.js'; +import { ManyToManyUpdateSqliteOperation } from './update/ManyToManyUpdateSqliteOperation.js'; +import { ManyToManyUpdateMySqlOperation } from './update/ManyToManyUpdateMySqlOperation.js'; + +import { ManyToManyDeleteOperation } from './delete/ManyToManyDeleteOperation.js'; +import { ManyToManyDeleteSqliteOperation } from './delete/ManyToManyDeleteSqliteOperation.js'; + +const nestedJoinTypes = { + rightJoin: 'right join', + rightOuterJoin: 'right outer join', + fullOuterJoin: 'full outer join', +}; + +export class ManyToManyRelation extends Relation { + get keySide() { + return 'join'; + } + setMapping(mapping) { const retVal = super.setMapping(mapping); @@ -140,6 +147,7 @@ class ManyToManyRelation extends Relation { relatedTable = defaultRelatedTable(this, builder), ownerTable = defaultOwnerTable(this, builder), joinTableAlias = defaultJoinTableAlias(this, relatedTableAlias, builder), + modifyJoin, } = {}, ) { let relatedJoinSelect = this.applyModify(relatedJoinSelectQuery).as(relatedTableAlias); @@ -162,27 +170,50 @@ class ManyToManyRelation extends Relation { withoutOrder(joinTableSelect); } - return builder[joinOperation](joinTableSelect, (join) => { - const ownerProp = this.ownerProp; - const joinTableOwnerProp = this.joinTableOwnerProp; - + const { ownerProp, relatedProp, joinTableOwnerProp, joinTableRelatedProp } = this; + const addOwnerConditions = (join) => { ownerProp.forEach((i) => { const joinTableOwnerRef = joinTableOwnerProp.ref(builder, i).table(joinTableAlias); const ownerRef = ownerProp.ref(builder, i).table(ownerTable); join.on(joinTableOwnerRef, ownerRef); }); - })[joinOperation](relatedJoinSelect, (join) => { - const relatedProp = this.relatedProp; - const joinTableRelatedProp = this.joinTableRelatedProp; - - relatedProp.forEach((i) => { - const joinTableRelatedRef = joinTableRelatedProp.ref(builder, i).table(joinTableAlias); - const relatedRef = relatedProp.ref(builder, i).table(relatedTableAlias); + }; + const getRelatedRefs = (i) => [ + joinTableRelatedProp.ref(builder, i).table(joinTableAlias), + relatedProp.ref(builder, i).table(relatedTableAlias), + ]; + + if (modifyJoin) { + // Nest the join of the related table in the join of the join table, so + // that the conditions of `modifyJoin()` cover both tables in one ON + // clause, and outer joins don't keep the join table rows they exclude. + // The nested join keeps the related rows that a right or full join keeps: + const nestedJoinType = nestedJoinTypes[joinOperation] || 'inner join'; + const on = relatedProp.cols.map(() => '? = ?').join(' and '); + const nestedJoin = raw( + `(${getBinding(joinTableSelect)} ${nestedJoinType} ${getBinding(relatedJoinSelect)} on ${on})`, + [ + joinTableSelect, + relatedJoinSelect, + ...relatedProp.cols.flatMap((_, i) => getRelatedRefs(i)), + ], + ); - join.on(joinTableRelatedRef, relatedRef); + return builder[joinOperation](nestedJoin, (join) => { + addOwnerConditions(join); + this._applyModifyJoin(join, modifyJoin, { + owner: ownerTable, + related: relatedTableAlias, + joinTable: joinTableAlias, + }); }); - }); + } else { + builder[joinOperation](joinTableSelect, addOwnerConditions); + return builder[joinOperation](relatedJoinSelect, (join) => { + relatedProp.forEach((i) => join.on(...getRelatedRefs(i))); + }); + } } find(_, owner) { @@ -334,6 +365,11 @@ function defaultJoinTableAlias(relation, relatedTableAlias, builder) { } } +// Table names go in as identifiers, subqueries as values. +function getBinding(select) { + return isString(select) ? '??' : '?'; +} + function aliasedTableName(tableName, alias) { if (tableName === alias) { return tableName; @@ -554,7 +590,3 @@ function getDeleteOperationClass(builder) { other: ManyToManyDeleteOperation, }); } - -module.exports = { - ManyToManyRelation, -}; diff --git a/lib/relations/manyToMany/ManyToManySqliteModifyMixin.js b/lib/relations/manyToMany/ManyToManySqliteModifyMixin.js index 9dedf5a21..23e77314a 100644 --- a/lib/relations/manyToMany/ManyToManySqliteModifyMixin.js +++ b/lib/relations/manyToMany/ManyToManySqliteModifyMixin.js @@ -1,12 +1,10 @@ -'use strict'; - -const { ManyToManyModifyMixin } = require('./ManyToManyModifyMixin'); +import { ManyToManyModifyMixin } from './ManyToManyModifyMixin.js'; const SQLITE_BUILTIN_ROW_ID = '_rowid_'; // We need to override this mixin for sqlite because sqlite doesn't support // multi-column where in statements with subqueries. We need to use the // internal _rowid_ column instead. -const ManyToManySqliteModifyMixin = (Operation) => { +export const ManyToManySqliteModifyMixin = (Operation) => { return class extends ManyToManyModifyMixin(Operation) { applyModifyFilterForRelatedTable(builder) { const tableRef = builder.tableRefFor(this.relation.relatedModelClass); @@ -17,7 +15,3 @@ const ManyToManySqliteModifyMixin = (Operation) => { } }; }; - -module.exports = { - ManyToManySqliteModifyMixin, -}; diff --git a/lib/relations/manyToMany/delete/ManyToManyDeleteOperation.js b/lib/relations/manyToMany/delete/ManyToManyDeleteOperation.js index b71200639..8132f1358 100644 --- a/lib/relations/manyToMany/delete/ManyToManyDeleteOperation.js +++ b/lib/relations/manyToMany/delete/ManyToManyDeleteOperation.js @@ -1,10 +1,6 @@ -'use strict'; +import { ManyToManyDeleteOperationBase } from './ManyToManyDeleteOperationBase.js'; +import { ManyToManyModifyMixin } from '../ManyToManyModifyMixin.js'; -const { ManyToManyDeleteOperationBase } = require('./ManyToManyDeleteOperationBase'); -const { ManyToManyModifyMixin } = require('../ManyToManyModifyMixin'); - -class ManyToManyDeleteOperation extends ManyToManyModifyMixin(ManyToManyDeleteOperationBase) {} - -module.exports = { - ManyToManyDeleteOperation, -}; +export class ManyToManyDeleteOperation extends ManyToManyModifyMixin( + ManyToManyDeleteOperationBase, +) {} diff --git a/lib/relations/manyToMany/delete/ManyToManyDeleteOperationBase.js b/lib/relations/manyToMany/delete/ManyToManyDeleteOperationBase.js index a41a482a1..89f6a9d70 100644 --- a/lib/relations/manyToMany/delete/ManyToManyDeleteOperationBase.js +++ b/lib/relations/manyToMany/delete/ManyToManyDeleteOperationBase.js @@ -1,8 +1,6 @@ -'use strict'; +import { DeleteOperation } from '../../../queryBuilder/operations/DeleteOperation.js'; -const { DeleteOperation } = require('../../../queryBuilder/operations/DeleteOperation'); - -class ManyToManyDeleteOperationBase extends DeleteOperation { +export class ManyToManyDeleteOperationBase extends DeleteOperation { constructor(name, opt) { super(name, opt); @@ -20,7 +18,3 @@ class ManyToManyDeleteOperationBase extends DeleteOperation { throw new Error('not implemented'); } } - -module.exports = { - ManyToManyDeleteOperationBase, -}; diff --git a/lib/relations/manyToMany/delete/ManyToManyDeleteSqliteOperation.js b/lib/relations/manyToMany/delete/ManyToManyDeleteSqliteOperation.js index 01cfaaa8d..7ae867bf9 100644 --- a/lib/relations/manyToMany/delete/ManyToManyDeleteSqliteOperation.js +++ b/lib/relations/manyToMany/delete/ManyToManyDeleteSqliteOperation.js @@ -1,12 +1,6 @@ -'use strict'; +import { ManyToManyDeleteOperationBase } from './ManyToManyDeleteOperationBase.js'; +import { ManyToManySqliteModifyMixin } from '../ManyToManySqliteModifyMixin.js'; -const { ManyToManyDeleteOperationBase } = require('./ManyToManyDeleteOperationBase'); -const { ManyToManySqliteModifyMixin } = require('../ManyToManySqliteModifyMixin'); - -class ManyToManyDeleteSqliteOperation extends ManyToManySqliteModifyMixin( +export class ManyToManyDeleteSqliteOperation extends ManyToManySqliteModifyMixin( ManyToManyDeleteOperationBase, ) {} - -module.exports = { - ManyToManyDeleteSqliteOperation, -}; diff --git a/lib/relations/manyToMany/find/ManyToManyFindOperation.js b/lib/relations/manyToMany/find/ManyToManyFindOperation.js index e24bd6699..a2befadca 100644 --- a/lib/relations/manyToMany/find/ManyToManyFindOperation.js +++ b/lib/relations/manyToMany/find/ManyToManyFindOperation.js @@ -1,10 +1,7 @@ -'use strict'; +import { RelationFindOperation } from '../../RelationFindOperation.js'; +import { getTempColumn } from '../../../utils/tmpColumnUtils.js'; -const { RelationFindOperation } = require('../../RelationFindOperation'); -const { getTempColumn } = require('../../../utils/tmpColumnUtils'); -const { warnOnce } = require('../../../utils/warnings'); - -class ManyToManyFindOperation extends RelationFindOperation { +export class ManyToManyFindOperation extends RelationFindOperation { constructor(name, opt) { super(name, opt); @@ -36,7 +33,7 @@ class ManyToManyFindOperation extends RelationFindOperation { } if (this.owner.isModels) { - this.warnOnInvalidOwnerProps(); + this.checkOwnerProps(); if (this.assignResultToOwner) { this.selectMissingJoinColumns(builder); @@ -44,43 +41,9 @@ class ManyToManyFindOperation extends RelationFindOperation { } } - onAfter2(_, related) { - const isOneToOne = this.relation.isOneToOne(); - - if (this.assignResultToOwner && this.owner.isModels) { - const owners = this.owner.modelArray; - const ownerProp = this.relation.ownerProp; - const relatedByOwnerId = new Map(); - - for (let i = 0, l = related.length; i < l; ++i) { - const rel = related[i]; - const key = rel.$propKey(this.ownerJoinColumnAlias); - let arr = relatedByOwnerId.get(key); - - if (!arr) { - arr = []; - relatedByOwnerId.set(key, arr); - } - - arr.push(rel); - } - - this.warnOnMissingRelatedProps(related); - - for (let i = 0, l = owners.length; i < l; ++i) { - const own = owners[i]; - const key = ownerProp.propKey(own); - const related = relatedByOwnerId.get(key); - - if (isOneToOne) { - own[this.relationProperty] = (related && related[0]) || null; - } else { - own[this.relationProperty] = related || []; - } - } - } - - return related; + // @override + getOwnerKey(related) { + return related.$propKey(this.ownerJoinColumnAlias); } // @override @@ -89,21 +52,21 @@ class ManyToManyFindOperation extends RelationFindOperation { } // The related models are assigned to their owners by the owner join columns - // selected from the join table. If they get lost, e.g. in a custom - // `$parseDatabaseJson()`, the related models are silently dropped (#2258). - warnOnMissingRelatedProps(related) { - const { joinTableOwnerProp } = this.relation; + // selected from the join table. They can get lost, e.g. in a custom + // `$parseDatabaseJson()` (#2258). + // @override + checkRelatedProps(related) { + const { name, ownerModelClass, joinTableOwnerProp } = this.relation; for (let c = 0, lc = this.ownerJoinColumnAlias.length; c < lc; ++c) { const alias = this.ownerJoinColumnAlias[c]; if (related.some((rel) => rel[alias] === undefined)) { - warnOnce( - `Fetching relation "${this.relation.name}" of ${this.relation.ownerModelClass.name}: ` + - `some related models are missing the join table column ` + - `"${joinTableOwnerProp.propDescription(c)}" selected as "${alias}", so they are not ` + - 'assigned to any owner. Check that $parseDatabaseJson() and the column name mappers ' + - 'of the related model keep it.', + throw new Error( + `Unable to fetch relation "${name}" of ${ownerModelClass.name}: some related models ` + + `are missing the join table column "${joinTableOwnerProp.propDescription(c)}" ` + + `selected as "${alias}". Check that $parseDatabaseJson() and the column name ` + + 'mappers of the related model keep it.', ); } } @@ -127,13 +90,11 @@ class ManyToManyFindOperation extends RelationFindOperation { const propName = relatedModelClass.columnNameToPropertyName(this.ownerJoinColumnAlias[i]); builder.select(joinTableOwnerRef.as(this.ownerJoinColumnAlias[i])); - this.omitFromResult(builder, propName); + // Unlike the join props, these aliases are never kept, as they aren't + // props of the related model. + builder.addInternalSelection(propName); }); super.selectMissingJoinColumns(builder); } } - -module.exports = { - ManyToManyFindOperation, -}; diff --git a/lib/relations/manyToMany/insert/ManyToManyInsertOperation.js b/lib/relations/manyToMany/insert/ManyToManyInsertOperation.js index 632e1ca95..46ef22716 100644 --- a/lib/relations/manyToMany/insert/ManyToManyInsertOperation.js +++ b/lib/relations/manyToMany/insert/ManyToManyInsertOperation.js @@ -1,10 +1,9 @@ -'use strict'; +import { hasBatchInsert } from '../../../utils/knexUtils.js'; +import { RelationInsertOperation } from '../../RelationInsertOperation.js'; +import { ManyToManyRelateOperation } from '../relate/ManyToManyRelateOperation.js'; +import { assignRelated } from '../../../model/modelSet.js'; -const { hasBatchInsert } = require('../../../utils/knexUtils'); -const { RelationInsertOperation } = require('../../RelationInsertOperation'); -const { ManyToManyRelateOperation } = require('../relate/ManyToManyRelateOperation'); - -class ManyToManyInsertOperation extends RelationInsertOperation { +export class ManyToManyInsertOperation extends RelationInsertOperation { onAdd(builder, args) { const retVal = super.onAdd(builder, args); @@ -26,7 +25,7 @@ class ManyToManyInsertOperation extends RelationInsertOperation { if (this.assignResultToOwner && this.owner.isModels) { for (const owner of this.owner.modelArray) { - owner.$appendRelated(this.relation, inserted); + assignRelated(owner, this.relation, inserted, { append: true }); } } @@ -53,7 +52,3 @@ class ManyToManyInsertOperation extends RelationInsertOperation { return inserted; } } - -module.exports = { - ManyToManyInsertOperation, -}; diff --git a/lib/relations/manyToMany/relate/ManyToManyRelateOperation.js b/lib/relations/manyToMany/relate/ManyToManyRelateOperation.js index 6e55237e2..accea6910 100644 --- a/lib/relations/manyToMany/relate/ManyToManyRelateOperation.js +++ b/lib/relations/manyToMany/relate/ManyToManyRelateOperation.js @@ -1,10 +1,8 @@ -'use strict'; +import { isPostgres } from '../../../utils/knexUtils.js'; +import { normalizeIds } from '../../../utils/normalizeIds.js'; +import { RelateOperation } from '../../../queryBuilder/operations/RelateOperation.js'; -const { isPostgres } = require('../../../utils/knexUtils'); -const { normalizeIds } = require('../../../utils/normalizeIds'); -const { RelateOperation } = require('../../../queryBuilder/operations/RelateOperation'); - -class ManyToManyRelateOperation extends RelateOperation { +export class ManyToManyRelateOperation extends RelateOperation { onAdd(builder, args) { this.input = args[0]; this.ids = normalizeIds(args[0], this.relation.relatedProp); @@ -81,7 +79,3 @@ function assertOwnerIsSingleItem(builder, owner, relation) { } } } - -module.exports = { - ManyToManyRelateOperation, -}; diff --git a/lib/relations/manyToMany/unrelate/ManyToManyUnrelateMySqlOperation.js b/lib/relations/manyToMany/unrelate/ManyToManyUnrelateMySqlOperation.js index ca8e9e5c8..4d3453132 100644 --- a/lib/relations/manyToMany/unrelate/ManyToManyUnrelateMySqlOperation.js +++ b/lib/relations/manyToMany/unrelate/ManyToManyUnrelateMySqlOperation.js @@ -1,12 +1,6 @@ -'use strict'; +import { ManyToManyUnrelateOperationBase } from './ManyToManyUnrelateOperationBase.js'; +import { ManyToManyMySqlModifyMixin } from '../ManyToManyMySqlModifyMixin.js'; -const { ManyToManyUnrelateOperationBase } = require('./ManyToManyUnrelateOperationBase'); -const { ManyToManyMySqlModifyMixin } = require('../ManyToManyMySqlModifyMixin'); - -class ManyToManyUnrelateMySqlOperation extends ManyToManyMySqlModifyMixin( +export class ManyToManyUnrelateMySqlOperation extends ManyToManyMySqlModifyMixin( ManyToManyUnrelateOperationBase, ) {} - -module.exports = { - ManyToManyUnrelateMySqlOperation, -}; diff --git a/lib/relations/manyToMany/unrelate/ManyToManyUnrelateOperation.js b/lib/relations/manyToMany/unrelate/ManyToManyUnrelateOperation.js index c78228360..88e239ad0 100644 --- a/lib/relations/manyToMany/unrelate/ManyToManyUnrelateOperation.js +++ b/lib/relations/manyToMany/unrelate/ManyToManyUnrelateOperation.js @@ -1,10 +1,6 @@ -'use strict'; +import { ManyToManyUnrelateOperationBase } from './ManyToManyUnrelateOperationBase.js'; +import { ManyToManyModifyMixin } from '../ManyToManyModifyMixin.js'; -const { ManyToManyUnrelateOperationBase } = require('./ManyToManyUnrelateOperationBase'); -const { ManyToManyModifyMixin } = require('../ManyToManyModifyMixin'); - -class ManyToManyUnrelateOperation extends ManyToManyModifyMixin(ManyToManyUnrelateOperationBase) {} - -module.exports = { - ManyToManyUnrelateOperation, -}; +export class ManyToManyUnrelateOperation extends ManyToManyModifyMixin( + ManyToManyUnrelateOperationBase, +) {} diff --git a/lib/relations/manyToMany/unrelate/ManyToManyUnrelateOperationBase.js b/lib/relations/manyToMany/unrelate/ManyToManyUnrelateOperationBase.js index 810cd9107..ffb3eca6e 100644 --- a/lib/relations/manyToMany/unrelate/ManyToManyUnrelateOperationBase.js +++ b/lib/relations/manyToMany/unrelate/ManyToManyUnrelateOperationBase.js @@ -1,9 +1,7 @@ -'use strict'; +import { UnrelateOperation } from '../../../queryBuilder/operations/UnrelateOperation.js'; +import { addWhereGroup } from '../../../queryBuilder/whereGroups.js'; -const { UnrelateOperation } = require('../../../queryBuilder/operations/UnrelateOperation'); -const { addWhereGroup } = require('../../../queryBuilder/whereGroups'); - -class ManyToManyUnrelateOperationBase extends UnrelateOperation { +export class ManyToManyUnrelateOperationBase extends UnrelateOperation { get modifiesJoinTable() { return true; } @@ -36,7 +34,3 @@ class ManyToManyUnrelateOperationBase extends UnrelateOperation { throw new Error('not implemented'); } } - -module.exports = { - ManyToManyUnrelateOperationBase, -}; diff --git a/lib/relations/manyToMany/unrelate/ManyToManyUnrelateSqliteOperation.js b/lib/relations/manyToMany/unrelate/ManyToManyUnrelateSqliteOperation.js index 00b9c3156..4f353c64f 100644 --- a/lib/relations/manyToMany/unrelate/ManyToManyUnrelateSqliteOperation.js +++ b/lib/relations/manyToMany/unrelate/ManyToManyUnrelateSqliteOperation.js @@ -1,12 +1,6 @@ -'use strict'; +import { ManyToManyUnrelateOperationBase } from './ManyToManyUnrelateOperationBase.js'; +import { ManyToManySqliteModifyMixin } from '../ManyToManySqliteModifyMixin.js'; -const { ManyToManyUnrelateOperationBase } = require('./ManyToManyUnrelateOperationBase'); -const { ManyToManySqliteModifyMixin } = require('../ManyToManySqliteModifyMixin'); - -class ManyToManyUnrelateSqliteOperation extends ManyToManySqliteModifyMixin( +export class ManyToManyUnrelateSqliteOperation extends ManyToManySqliteModifyMixin( ManyToManyUnrelateOperationBase, ) {} - -module.exports = { - ManyToManyUnrelateSqliteOperation, -}; diff --git a/lib/relations/manyToMany/update/ManyToManyUpdateMySqlOperation.js b/lib/relations/manyToMany/update/ManyToManyUpdateMySqlOperation.js index 1cd0602b4..a156e1ba5 100644 --- a/lib/relations/manyToMany/update/ManyToManyUpdateMySqlOperation.js +++ b/lib/relations/manyToMany/update/ManyToManyUpdateMySqlOperation.js @@ -1,12 +1,6 @@ -'use strict'; +import { ManyToManyUpdateOperationBase } from './ManyToManyUpdateOperationBase.js'; +import { ManyToManyMySqlModifyMixin } from '../ManyToManyMySqlModifyMixin.js'; -const { ManyToManyUpdateOperationBase } = require('./ManyToManyUpdateOperationBase'); -const { ManyToManyMySqlModifyMixin } = require('../ManyToManyMySqlModifyMixin'); - -class ManyToManyUpdateMySqlOperation extends ManyToManyMySqlModifyMixin( +export class ManyToManyUpdateMySqlOperation extends ManyToManyMySqlModifyMixin( ManyToManyUpdateOperationBase, ) {} - -module.exports = { - ManyToManyUpdateMySqlOperation, -}; diff --git a/lib/relations/manyToMany/update/ManyToManyUpdateOperation.js b/lib/relations/manyToMany/update/ManyToManyUpdateOperation.js index 6ed60f997..9497fe8f0 100644 --- a/lib/relations/manyToMany/update/ManyToManyUpdateOperation.js +++ b/lib/relations/manyToMany/update/ManyToManyUpdateOperation.js @@ -1,10 +1,6 @@ -'use strict'; +import { ManyToManyUpdateOperationBase } from './ManyToManyUpdateOperationBase.js'; +import { ManyToManyModifyMixin } from '../ManyToManyModifyMixin.js'; -const { ManyToManyUpdateOperationBase } = require('./ManyToManyUpdateOperationBase'); -const { ManyToManyModifyMixin } = require('../ManyToManyModifyMixin'); - -class ManyToManyUpdateOperation extends ManyToManyModifyMixin(ManyToManyUpdateOperationBase) {} - -module.exports = { - ManyToManyUpdateOperation, -}; +export class ManyToManyUpdateOperation extends ManyToManyModifyMixin( + ManyToManyUpdateOperationBase, +) {} diff --git a/lib/relations/manyToMany/update/ManyToManyUpdateOperationBase.js b/lib/relations/manyToMany/update/ManyToManyUpdateOperationBase.js index 2f5ccf609..ea5aa13d0 100644 --- a/lib/relations/manyToMany/update/ManyToManyUpdateOperationBase.js +++ b/lib/relations/manyToMany/update/ManyToManyUpdateOperationBase.js @@ -1,17 +1,13 @@ -'use strict'; - -const { UpdateOperation } = require('../../../queryBuilder/operations/UpdateOperation'); -const { addWhereGroup } = require('../../../queryBuilder/whereGroups'); -const { chunk } = require('../../../utils/objectUtils'); -const { isMySql, isMsSql } = require('../../../utils/knexUtils'); - -// Stay below the limits of bound values per statement of SQLite before 3.32 -// (999) and SQL Server (2100), leaving room for the patched values: -const maxBatchValues = 500; +import { UpdateOperation } from '../../../queryBuilder/operations/UpdateOperation.js'; +import { addWhereGroup } from '../../../queryBuilder/whereGroups.js'; +import { isMySql, isMsSql, whereInKeys } from '../../../utils/knexUtils.js'; +import { getWindowOrder, rowNumberColumn } from '../../../queryBuilder/operations/windowOrder.js'; const LimitSelector = /^(orderBy\w*|limit|offset)$/; +// Without underscores, so that column name mappers don't change it. +const relatedPropColumn = 'objectiontmprelated'; -class ManyToManyUpdateOperationBase extends UpdateOperation { +export class ManyToManyUpdateOperationBase extends UpdateOperation { constructor(name, opt) { super(name, opt); @@ -37,9 +33,15 @@ class ManyToManyUpdateOperationBase extends UpdateOperation { // table. On MySQL, concurrent updates with that subquery deadlock (#135). this.skipRelationFilter = builder.internalOptions().skipRelationFilter; - // Copy all extra properties to the `joinTablePatch` object. + // Copy the extra properties to the `joinTablePatch` object, or the ones + // that `upsertGraph()` writes: + const { joinTableExtras } = builder.internalOptions(); + for (const extra of this.relation.joinTableExtras) { - if (extra.aliasProp in obj) { + if ( + extra.aliasProp in obj && + (!joinTableExtras || joinTableExtras.includes(extra.aliasProp)) + ) { this.hasExtraProps = true; this.joinTablePatch[extra.joinTableProp] = obj[extra.aliasProp]; } @@ -74,7 +76,6 @@ class ManyToManyUpdateOperationBase extends UpdateOperation { // `onAfterBuild()`. this.relatedIdsQuery = this.createRelatedIdsQuery(builder, hasLimit); if (hasLimit) { - this.relatedIdsQuery.copyFrom(builder, LimitSelector); builder.clear(LimitSelector); } } @@ -82,24 +83,47 @@ class ManyToManyUpdateOperationBase extends UpdateOperation { return super.onBuild(builder); } - // Selects the ids of the related rows to update. Fetch raw rows, so that no - // hooks run for them. + // Select the ids of the related rows to update from the filter subquery + // itself. Filtering the related table by them again lets Postgres pick plans + // that run the subquery once per related row, which takes minutes for a few + // thousand (#161). Fetch raw rows, so that no hooks run for them. createRelatedIdsQuery(builder, hasLimit) { - const { relatedModelClass } = this.relation; + const { relatedModelClass, relatedProp } = this.relation; + const idProperty = relatedModelClass.getIdRelationProperty(); + const filterQuery = this.modifyFilterSubquery.clone(); + const idRefs = idProperty.refs(filterQuery); if (hasLimit) { - // The limit picks related rows, not join rows, so filter the related - // table by the ids of the matching join rows: - const relatedIdsQuery = relatedModelClass.query().childQueryOf(builder); - const idRefs = relatedModelClass.getIdRelationProperty().refs(relatedIdsQuery); - return this.applyModifyFilterForRelatedTable(relatedIdsQuery).select(idRefs); + // The join rows refer to the related rows by their `relatedProp`, which + // isn't necessarily their id (#360). We select it after the ids, aliased + // as it can be the same columns: + const relatedPropColumns = relatedProp.cols.map((_, index) => `${relatedPropColumn}${index}`); + const columns = [...idProperty.cols, ...relatedPropColumns]; + + // The filter subquery returns a row per join row, and `distinct` can't + // order by columns that it doesn't select. So we number the rows in the + // order of the query, and the limit picks the related rows by their + // first row: + const order = getWindowOrder(builder, builder.knex(), builder.tableRef()); + const numberedQuery = filterQuery + .select([ + ...idRefs, + ...relatedProp.refs(filterQuery).map((ref, index) => ref.as(relatedPropColumns[index])), + ]) + .onBuildKnex((knexBuilder) => + knexBuilder.rowNumber(rowNumberColumn, (window) => window.orderBy(order)), + ); + + return relatedModelClass + .query() + .childQueryOf(builder) + .from(numberedQuery.as(rowNumberColumn)) + .select(columns) + .groupBy(columns) + .orderByRaw('min(??)', [rowNumberColumn]) + .copyFrom(builder, /^(limit|offset)$/); } else { - // Select the ids from the filter subquery itself. Filtering the related - // table by them again lets Postgres pick plans that run the subquery - // once per related row, which takes minutes for a few thousand (#161). - const relatedIdsQuery = this.modifyFilterSubquery.clone(); - const idRefs = relatedModelClass.getIdRelationProperty().refs(relatedIdsQuery); - return relatedIdsQuery.distinct(idRefs); + return filterQuery.distinct(idRefs); } } @@ -108,27 +132,27 @@ class ManyToManyUpdateOperationBase extends UpdateOperation { // `none()`, leaves the join rows untouched too. async onAfterBuild(builder) { // An offset that `onBuild()` moved to the ids query is honoured, and - // doesn't warn. + // doesn't throw. super.onAfterBuild(builder); if (this.hasExtraProps && !this.isResolvedWithoutUpdate(builder)) { + let relatedValues = null; if (this.relatedIdsQuery) { // The filters can also reference the extras that the join rows change, // so identify the related rows to modify first. const rows = await this.relatedIdsQuery.toKnexQuery(); - this.relatedIds = rows.map((row) => Object.values(row)); - } - - let numJoinRowsUpdated; - if (this.relatedIdsQuery?.has(/^(limit|offset)$/)) { - // The filters match the join rows of more related rows than the limit - // picks, so only update the join rows of the picked ones. - for (const ids of this.relatedIdBatches()) { - await this.updateJoinRows(builder, ids); + const values = rows.map((row) => Object.values(row)); + const { size } = this.relation.relatedModelClass.getIdRelationProperty(); + this.relatedIds = values.map((row) => row.slice(0, size)); + // With a limit, the filters match the join rows of more related rows + // than the limit picks, so only update the join rows of the picked + // ones, by the `relatedProp` values that follow their ids. + if (this.relatedIdsQuery.has(/^(limit|offset)$/)) { + relatedValues = values.map((row) => row.slice(size)); } - } else { - numJoinRowsUpdated = await this.updateJoinRows(builder); } + const numJoinRowsUpdated = await this.updateJoinRows(builder, relatedValues); + if (this.hasNothingToUpdate) { // Only the join rows change, so count the related rows that they // belong to, as the update of the related rows does. Without their @@ -139,7 +163,15 @@ class ManyToManyUpdateOperationBase extends UpdateOperation { } } - updateJoinRows(builder, relatedIds = null) { + // Changes of only the extras still write the join rows, see `onAfterBuild()`. + // @override + isResolvedWithoutWrite(builder) { + return ( + super.isResolvedWithoutWrite(builder) && !(this.hasExtraProps && this.hasNothingToUpdate) + ); + } + + updateJoinRows(builder, relatedValues = null) { const joinTableUpdateQuery = this.relation .getJoinModelClass(builder.knex()) .query() @@ -151,11 +183,12 @@ class ManyToManyUpdateOperationBase extends UpdateOperation { ? this.applyIdFilterForJoinTable(joinTableUpdateQuery) : this.applyModifyFilterForJoinTable(joinTableUpdateQuery), ); - if (relatedIds) { + if (relatedValues) { addWhereGroup(joinTableUpdateQuery, () => - joinTableUpdateQuery.whereInComposite( + whereInKeys( + joinTableUpdateQuery, this.relation.joinTableRelatedProp.refs(joinTableUpdateQuery), - relatedIds, + relatedValues, ), ); } @@ -166,23 +199,6 @@ class ManyToManyUpdateOperationBase extends UpdateOperation { return joinTableUpdateQuery; } - // Update the related rows in batches of their ids, as databases limit the - // number of values in one statement. - queryBatches(builder) { - if (this.relatedIds) { - const batches = this.relatedIdBatches(); - // Without ids, still execute the query once for the dialect's result: - return batches.length > 0 ? batches : [[]]; - } else { - return null; - } - } - - relatedIdBatches() { - const idSize = this.relation.relatedModelClass.getIdRelationProperty().size; - return chunk(this.relatedIds, Math.floor(maxBatchValues / idSize)); - } - // Filters the join rows by the ids of the owner and the updated model. applyIdFilterForJoinTable(builder) { return builder @@ -218,7 +234,3 @@ class ManyToManyUpdateOperationBase extends UpdateOperation { return clone; } } - -module.exports = { - ManyToManyUpdateOperationBase, -}; diff --git a/lib/relations/manyToMany/update/ManyToManyUpdateSqliteOperation.js b/lib/relations/manyToMany/update/ManyToManyUpdateSqliteOperation.js index be2a465c1..ac603f485 100644 --- a/lib/relations/manyToMany/update/ManyToManyUpdateSqliteOperation.js +++ b/lib/relations/manyToMany/update/ManyToManyUpdateSqliteOperation.js @@ -1,12 +1,6 @@ -'use strict'; +import { ManyToManyUpdateOperationBase } from './ManyToManyUpdateOperationBase.js'; +import { ManyToManySqliteModifyMixin } from '../ManyToManySqliteModifyMixin.js'; -const { ManyToManyUpdateOperationBase } = require('./ManyToManyUpdateOperationBase'); -const { ManyToManySqliteModifyMixin } = require('../ManyToManySqliteModifyMixin'); - -class ManyToManyUpdateSqliteOperation extends ManyToManySqliteModifyMixin( +export class ManyToManyUpdateSqliteOperation extends ManyToManySqliteModifyMixin( ManyToManyUpdateOperationBase, ) {} - -module.exports = { - ManyToManyUpdateSqliteOperation, -}; diff --git a/lib/transaction.js b/lib/transaction.js index dbb53cd9a..4b4fb79fb 100644 --- a/lib/transaction.js +++ b/lib/transaction.js @@ -1,9 +1,7 @@ -'use strict'; +import * as promiseUtils from './utils/promiseUtils/index.js'; +import { isFunction } from './utils/objectUtils.js'; -const promiseUtils = require('./utils/promiseUtils'); -const { isFunction } = require('./utils/objectUtils'); - -function transaction() { +export function transaction() { // There must be at least one model class and the callback. if (arguments.length < 2) { return Promise.reject( @@ -90,7 +88,3 @@ transaction.start = function (modelClassOrKnex) { function isModelClass(maybeModel) { return isFunction(maybeModel) && maybeModel.isObjectionModelClass; } - -module.exports = { - transaction, -}; diff --git a/lib/utils/assert.js b/lib/utils/assert.js index d1da579d8..7c1039d26 100644 --- a/lib/utils/assert.js +++ b/lib/utils/assert.js @@ -1,12 +1,10 @@ -'use strict'; - -function assertHasIdColumn(modelClass) { +export function assertHasIdColumn(modelClass) { if (modelClass.getIdColumnArray().length === 0) { throw new Error(`model ${modelClass.name} has no idColumn`); } } -function assertHasId(model) { +export function assertHasId(model) { assertHasIdColumn(model.constructor); if (!model.$hasId()) { @@ -19,16 +17,10 @@ function assertHasId(model) { } } -function assertIdNotUndefined(id, message) { +export function assertIdNotUndefined(id, message) { if (Array.isArray(id)) { id.forEach((id) => assertIdNotUndefined(id, message)); } else if (id === undefined) { throw Error(message); } } - -module.exports = { - assertHasId, - assertHasIdColumn, - assertIdNotUndefined, -}; diff --git a/lib/utils/buildUtils.js b/lib/utils/buildUtils.js index 1dfda28b4..e54321dc2 100644 --- a/lib/utils/buildUtils.js +++ b/lib/utils/buildUtils.js @@ -1,8 +1,6 @@ -'use strict'; +import { isObject, isFunction } from './objectUtils.js'; -const { isObject, isFunction } = require('./objectUtils'); - -function buildArg(arg, builder) { +export function buildArg(arg, builder) { if (!isObject(arg)) { return arg; } @@ -15,7 +13,3 @@ function buildArg(arg, builder) { return arg; } } - -module.exports = { - buildArg, -}; diff --git a/lib/utils/classUtils.js b/lib/utils/classUtils.js index bb1310f7b..deeb6f2cc 100644 --- a/lib/utils/classUtils.js +++ b/lib/utils/classUtils.js @@ -1,13 +1,7 @@ -'use strict'; - -function inherit(Constructor, BaseConstructor) { +export function inherit(Constructor, BaseConstructor) { Constructor.prototype = Object.create(BaseConstructor.prototype); Constructor.prototype.constructor = BaseConstructor; Object.setPrototypeOf(Constructor, BaseConstructor); return Constructor; } - -module.exports = { - inherit, -}; diff --git a/lib/utils/createModifier.js b/lib/utils/createModifier.js index 28518f810..57a230b86 100644 --- a/lib/utils/createModifier.js +++ b/lib/utils/createModifier.js @@ -1,8 +1,6 @@ -'use strict'; +import { asArray, isString, isFunction, isPlainObject } from './objectUtils.js'; -const { asArray, isString, isFunction, isPlainObject } = require('./objectUtils'); - -function createModifier({ modelClass, modifier, modifiers }) { +export function createModifier({ modelClass, modifier, modifiers }) { const modelModifiers = modelClass ? modelClass.getModifiers() : {}; const modifierFunctions = asArray(modifier).map((modifier) => { @@ -36,7 +34,3 @@ function createModifier({ modelClass, modifier, modifiers }) { } }; } - -module.exports = { - createModifier, -}; diff --git a/lib/utils/identifierMapping.js b/lib/utils/identifierMapping.js index 54d616622..eae718034 100644 --- a/lib/utils/identifierMapping.js +++ b/lib/utils/identifierMapping.js @@ -1,6 +1,5 @@ -'use strict'; - -const { isObject } = require('./objectUtils'); +import { isObject } from './objectUtils.js'; +import { isFieldExpressionKey } from './parseFieldExpression.js'; // Super fast memoize for single argument functions. function memoize(func) { @@ -21,7 +20,7 @@ function memoize(func) { // camelCase to snake_case converter that also works with non-ascii characters // This is needed especially so that aliases containing the `:` character, // objection uses internally, work. -function snakeCase( +export function snakeCase( str, { upperCase = false, @@ -90,7 +89,7 @@ function snakeCase( // snake_case to camelCase converter that simply reverses // the actions done by `snakeCase` function. -function camelCase(str, { upperCase = false } = {}) { +export function camelCase(str, { upperCase = false } = {}) { if (str.length === 0) { return str; } @@ -149,9 +148,10 @@ function mapLastPart(mapper, separator) { } // Returns a function that takes an object as an input and maps the object's keys -// using `mapper`. If the input is not an object, the input is returned unchanged. +// using `mapper`, passing on `arg`. If the input is not an object, the input is +// returned unchanged. function keyMapper(mapper) { - return (obj) => { + return (obj, arg) => { if (!isObject(obj) || Array.isArray(obj)) { return obj; } @@ -161,7 +161,7 @@ function keyMapper(mapper) { for (let i = 0, l = keys.length; i < l; ++i) { const key = keys[i]; - out[mapper(key)] = obj[key]; + out[mapper(key, arg)] = obj[key]; } return out; @@ -171,8 +171,7 @@ function keyMapper(mapper) { // Returns a function that only calls `mapper` for the part of the input string // before the first `separator`, and leaves the rest untouched. For field // expressions like `jsonColumn:some.jsonPath` this maps the column but keeps -// the json path as is. The mapper doesn't know the schema, so the first `:` -// marks the boundary also in a declared column name like `ns:name`. +// the json path as is. function mapFirstPart(mapper, separator) { return (str) => { const idx = str.indexOf(separator); @@ -180,16 +179,22 @@ function mapFirstPart(mapper, separator) { }; } -function snakeCaseMappers(opt = {}) { - const format = (str) => snakeCase(str, opt); +export function snakeCaseMappers(opt = {}) { + const { preserveJsonKeys = true } = opt; + const formatColumn = memoize((str) => snakeCase(str, opt)); + const formatFieldExpression = memoize(mapFirstPart(formatColumn, ':')); return { parse: keyMapper(memoize((str) => camelCase(str, opt))), - format: keyMapper(memoize(opt.preserveJsonKeys ? mapFirstPart(format, ':') : format)), + // Models pass `isFieldExpression()`, which knows the columns with a `:` that + // their schema declares, like `ns:fooBar`. These are converted as a whole. + format: keyMapper((key, isFieldExpression = isFieldExpressionKey) => + preserveJsonKeys && isFieldExpression(key) ? formatFieldExpression(key) : formatColumn(key), + ), }; } -function knexIdentifierMappers({ parse, format, idSeparator = ':' } = {}) { +export function knexIdentifierMappers({ parse, format, idSeparator = ':' } = {}) { const formatId = memoize(mapLastPart(format, idSeparator)); const parseId = memoize(mapLastPart(parse, idSeparator)); const parseKeys = keyMapper(parseId); @@ -215,14 +220,14 @@ function knexIdentifierMappers({ parse, format, idSeparator = ':' } = {}) { }; } -function knexSnakeCaseMappers(opt = {}) { +export function knexSnakeCaseMappers(opt = {}) { return knexIdentifierMappers({ parse: (str) => camelCase(str, opt), format: (str) => snakeCase(str, opt), }); } -function knexIdentifierMapping(colToProp) { +export function knexIdentifierMapping(colToProp) { const propToCol = Object.keys(colToProp).reduce((propToCol, column) => { propToCol[colToProp[column]] = column; return propToCol; @@ -234,14 +239,5 @@ function knexIdentifierMapping(colToProp) { }); } -module.exports = { - snakeCase, - camelCase, - snakeCaseMappers, - knexSnakeCaseMappers, - knexIdentifierMappers, - knexIdentifierMapping, - - camelCaseKeys: keyMapper(memoize(camelCase)), - snakeCaseKeys: keyMapper(memoize(snakeCase)), -}; +export const camelCaseKeys = keyMapper(memoize(camelCase)); +export const snakeCaseKeys = keyMapper(memoize(snakeCase)); diff --git a/lib/utils/internalPropUtils.js b/lib/utils/internalPropUtils.js index ec5c1a3df..dc244df2d 100644 --- a/lib/utils/internalPropUtils.js +++ b/lib/utils/internalPropUtils.js @@ -1,11 +1,5 @@ -'use strict'; - const INTERNAL_PROP_PREFIX = '$'; -function isInternalProp(propName) { +export function isInternalProp(propName) { return propName[0] === INTERNAL_PROP_PREFIX; } - -module.exports = { - isInternalProp, -}; diff --git a/lib/utils/knexUtils.js b/lib/utils/knexUtils.js index 276bc6e7e..f2200433d 100644 --- a/lib/utils/knexUtils.js +++ b/lib/utils/knexUtils.js @@ -1,8 +1,6 @@ -'use strict'; +import { isObject, isFunction, isString, isNumber, asSingle } from '../utils/objectUtils.js'; -const { isObject, isFunction } = require('../utils/objectUtils'); - -function getDialect(knex) { +export function getDialect(knex) { const type = typeof knex; return ( @@ -14,48 +12,109 @@ function getDialect(knex) { ); } -function isPostgres(knex) { +export function isPostgres(knex) { return getDialect(knex) === 'postgresql'; } -function isOracle(knex) { +export function isOracle(knex) { const dialect = getDialect(knex); return dialect === 'oracle' || dialect === 'oracledb'; } -function isMySql(knex) { +export function isMySql(knex) { const dialect = getDialect(knex); return dialect === 'mysql' || dialect === 'mysql2'; } -function isSqlite(knex) { +export function isSqlite(knex) { return getDialect(knex) === 'sqlite3'; } -function isMsSql(knex) { +export function isMsSql(knex) { return getDialect(knex) === 'mssql'; } -// Only Postgres and SQL Server return the identifiers of all the rows inserted -// by one query. -function hasBatchInsert(knex) { - return isPostgres(knex) || isMsSql(knex); +// Postgres, SQL Server and SQLite (through `returning`, since SQLite 3.35) +// return the identifiers of all the rows inserted by one query. +export function hasBatchInsert(knex) { + return isPostgres(knex) || isMsSql(knex) || isSqlite(knex); } -// MySQL supports window functions since 8.0, MariaDB since 10.2. Without a -// version, knex assumes the latest one, and so do we. -function hasWindowFunctions(knex) { - const { version, isMariaDB } = knex.client; +// knex inserts the rows of a SQLite batch with `select ... union all`, which +// fills the columns that a row leaves out with NULL, not with their defaults. +export function hasBatchInsertDefaults(knex) { + return !isSqlite(knex); +} - if (isMySql(knex) && version) { - const [major, minor] = version.split('.').map(Number); - return isMariaDB ? major > 10 || (major === 10 && minor >= 2) : major >= 8; +// The number of rows that fit into one query if each binds `parameters` values. +// SQL Server binds 2100 values, Postgres 65535 and SQLite 999 (32766 since 3.32, +// but builds may be older), and we leave a margin for the values that aren't +// rows. The MySQL drivers inline the values in the query. +export function getMaxRows(knex, parameters) { + const maxParameters = isMsSql(knex) + ? 2000 + : isPostgres(knex) + ? 65000 + : isSqlite(knex) + ? 900 + : Infinity; + return Math.max(1, Math.floor(maxParameters / parameters)); +} + +// Filter the columns of `refs` by a list of keys, which are arrays of values for +// composite keys, with as few bound values as possible, see `getMaxRows()`: +// Postgres binds single keys as one array, SQLite and SQL Server (2016 or newer +// for `openjson`) all keys as one JSON array, and the MySQL drivers inline the +// values. Composite keys on Postgres and keys that JSON can't represent, like +// binary ids, bind one value per column. +export function whereInKeys(builder, refs, keys) { + const knex = builder.knex(); + const isComposite = refs.length > 1; + const values = isComposite ? keys : keys.map(asSingle); + const json = values.flat().every((value) => isString(value) || isNumber(value)) + ? JSON.stringify(values) + : null; + + if (isPostgres(knex) && !isComposite) { + return builder.whereRaw('? = any(?)', [refs[0], values]); + } else if (json && isSqlite(knex) && isComposite) { + const columns = refs.map(() => '?').join(', '); + const selects = refs.map((_, index) => `json_extract(value, '$[${index}]')`).join(', '); + return builder.whereRaw(`(${columns}) in (select ${selects} from json_each(?))`, [ + ...refs, + json, + ]); + } else if (json && isSqlite(knex)) { + return builder.whereRaw('? in (select value from json_each(?))', [refs[0], json]); + } else if (json && isMsSql(knex) && isComposite) { + // SQL Server doesn't support row values in `in`: + const conditions = refs.map((_, index) => `? = json_value(value, '$[${index}]')`).join(' and '); + return builder.whereRaw(`exists (select 1 from openjson(?) where ${conditions})`, [ + json, + ...refs, + ]); + } else if (json && isMsSql(knex)) { + return builder.whereRaw('? in (select value from openjson(?))', [refs[0], json]); } else { - return true; + return builder.whereInComposite(refs, keys); } } -function isKnexQueryBuilder(value) { +// The number of rows that one insert query can take: `getMaxRows()`, and the +// limits on rows: SQLite compiles the rows to `select ... union all`, and limits +// a compound select to 500 terms, and SQL Server limits `values` to 1000 rows. +export function getMaxInsertRows(knex, parameters) { + const maxRows = isSqlite(knex) ? 500 : isMsSql(knex) ? 1000 : Infinity; + return Math.min(maxRows, getMaxRows(knex, parameters)); +} + +// The `pg` drivers return `json` and `jsonb` columns parsed, and `text` columns +// as text. +export function isJsonParsedByDriver(knex) { + return ['pg', 'pgnative', 'cockroachdb'].includes(knex.client.driverName); +} + +export function isKnexQueryBuilder(value) { return ( hasConstructor(value) && isFunction(value.select) && @@ -65,33 +124,18 @@ function isKnexQueryBuilder(value) { ); } -function isKnexJoinBuilder(value) { +export function isKnexJoinBuilder(value) { return hasConstructor(value) && value.grouping === 'join' && 'joinType' in value; } -function isKnexRaw(value) { +export function isKnexRaw(value) { return hasConstructor(value) && value.isRawInstance && 'client' in value; } -function isKnexTransaction(knex) { +export function isKnexTransaction(knex) { return !!getDialect(knex) && isFunction(knex.commit) && isFunction(knex.rollback); } function hasConstructor(value) { return isObject(value) && isFunction(value.constructor); } - -module.exports = { - getDialect, - isPostgres, - isMySql, - isSqlite, - isMsSql, - isOracle, - hasBatchInsert, - hasWindowFunctions, - isKnexQueryBuilder, - isKnexJoinBuilder, - isKnexRaw, - isKnexTransaction, -}; diff --git a/lib/utils/mixin.js b/lib/utils/mixin.js index f6a8f9a1c..8422cf44b 100644 --- a/lib/utils/mixin.js +++ b/lib/utils/mixin.js @@ -1,8 +1,6 @@ -'use strict'; +import { flatten } from './objectUtils.js'; -const { flatten } = require('./objectUtils'); - -function mixin() { +export function mixin() { const args = flatten(arguments); const mixins = args.slice(1); @@ -11,15 +9,10 @@ function mixin() { }, args[0]); } -function compose() { +export function compose() { const mixins = flatten(arguments); return function (Class) { return mixin(Class, mixins); }; } - -module.exports = { - compose, - mixin, -}; diff --git a/lib/utils/normalizeIds.js b/lib/utils/normalizeIds.js index a2f241631..477f321e7 100644 --- a/lib/utils/normalizeIds.js +++ b/lib/utils/normalizeIds.js @@ -1,9 +1,7 @@ -'use strict'; - -const { isObject } = require('../utils/objectUtils'); +import { isObject } from '../utils/objectUtils.js'; // ids is of type RelationProperty. -function normalizeIds(ids, prop, opt) { +export function normalizeIds(ids, prop, opt) { opt = opt || {}; let isComposite = prop.size > 1; @@ -138,7 +136,3 @@ function normalizedToArray(ret, prop) { return arr; } - -module.exports = { - normalizeIds, -}; diff --git a/lib/utils/objectUtils.js b/lib/utils/objectUtils.js index 7d395c03a..3010c8090 100644 --- a/lib/utils/objectUtils.js +++ b/lib/utils/objectUtils.js @@ -1,8 +1,6 @@ -'use strict'; - const SMALL_ARRAY_SIZE = 10; -function isEmpty(item) { +export function isEmpty(item) { if (Array.isArray(item) || Buffer.isBuffer(item)) { return item.length === 0; } else if (isObject(item)) { @@ -12,17 +10,17 @@ function isEmpty(item) { } } -function isObject(value) { +export function isObject(value) { return value !== null && typeof value === 'object'; } -function hasOwn(obj, prop) { +export function hasOwn(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); } // Quick and dirty check if an object is a plain object and not // for example an instance of some class. -function isPlainObject(value) { +export function isPlainObject(value) { return ( isObject(value) && (!value.constructor || value.constructor === Object) && @@ -30,31 +28,31 @@ function isPlainObject(value) { ); } -function isFunction(value) { +export function isFunction(value) { return typeof value === 'function'; } -function isRegExp(value) { +export function isRegExp(value) { return value instanceof RegExp; } -function isString(value) { +export function isString(value) { return typeof value === 'string'; } -function isNumber(value) { +export function isNumber(value) { return typeof value === 'number'; } -function asArray(value) { - return Array.isArray(value) ? value : [value]; +export function asArray(value) { + return Array.isArray(value) ? value : value !== undefined ? [value] : []; } -function asSingle(value) { +export function asSingle(value) { return Array.isArray(value) ? value[0] : value; } -function uniqBy(items, keyGetter = null) { +export function uniqBy(items, keyGetter = null) { const map = new Map(); for (let i = 0, l = items.length; i < l; ++i) { @@ -67,7 +65,7 @@ function uniqBy(items, keyGetter = null) { return Array.from(map.values()); } -function groupBy(items, keyGetter = null) { +export function groupBy(items, keyGetter = null) { const groups = new Map(); for (const item of items) { @@ -85,7 +83,7 @@ function groupBy(items, keyGetter = null) { return groups; } -function omit(obj, keysToOmit) { +export function omit(obj, keysToOmit) { keysToOmit = asArray(keysToOmit); const keys = Object.keys(obj); @@ -102,7 +100,7 @@ function omit(obj, keysToOmit) { return out; } -function difference(arr1, arr2) { +export function difference(arr1, arr2) { const arr2Set = new Set(arr2); const diff = []; @@ -117,7 +115,7 @@ function difference(arr1, arr2) { return diff; } -function union(arr1, arr2) { +export function union(arr1, arr2) { if (arr1.length < SMALL_ARRAY_SIZE && arr2.length < SMALL_ARRAY_SIZE) { return unionSmall(arr1, arr2); } else { @@ -157,15 +155,15 @@ function unionGeneric(arr1, arr2) { return Array.from(all); } -function last(arr) { +export function last(arr) { return arr[arr.length - 1]; } -function upperFirst(str) { +export function upperFirst(str) { return str[0].toUpperCase() + str.substring(1); } -function values(obj) { +export function values(obj) { if (isObject(obj)) { const keys = Object.keys(obj); const values = new Array(keys.length); @@ -180,7 +178,7 @@ function values(obj) { } } -function once(func) { +export function once(func) { let called = false; let value = undefined; @@ -194,7 +192,7 @@ function once(func) { }; } -function flatten(arrays) { +export function flatten(arrays) { const out = []; for (let i = 0, l = arrays.length; i < l; ++i) { @@ -212,7 +210,7 @@ function flatten(arrays) { return out; } -function get(obj, path) { +export function get(obj, path) { for (let i = 0, l = path.length; i < l; ++i) { const key = path[i]; @@ -226,7 +224,7 @@ function get(obj, path) { return obj; } -function set(obj, path, value) { +export function set(obj, path, value) { const inputObj = obj; for (let i = 0, l = path.length - 1; i < l; ++i) { @@ -264,7 +262,7 @@ function set(obj, path, value) { return inputObj; } -function zipObject(keys, values) { +export function zipObject(keys, values) { const out = {}; for (let i = 0, l = keys.length; i < l; ++i) { @@ -278,7 +276,7 @@ function zipObject(keys, values) { return out; } -function chunk(arr, chunkSize) { +export function chunk(arr, chunkSize) { const out = []; for (let i = 0, l = arr.length; i < l; ++i) { @@ -294,7 +292,20 @@ function chunk(arr, chunkSize) { return out; } -function jsonEquals(val1, val2) { +// Compare like the database would, but only use loose equality for non-JSON +// values, so that for example `1` and `'1'` are equal, but `false` and `[]` +// are not (#1874). +export function nonStrictEquals(value1, value2) { + return ( + (!isJsonValue(value1) && !isJsonValue(value2) && value1 == value2) || jsonEquals(value1, value2) + ); +} + +function isJsonValue(value) { + return Array.isArray(value) || isPlainObject(value); +} + +export function jsonEquals(val1, val2) { return jsonEqualsBase(val1, val2, compareStrict); } @@ -380,7 +391,7 @@ function isSafeKey(key) { return isNumber(key) || (isString(key) && key !== '__proto__'); } -function mergeMaps(map1, map2) { +export function mergeMaps(map1, map2) { const map = new Map(map1); if (map2) { @@ -429,7 +440,7 @@ function getRawTag(value) { // sets, buffers, typed arrays and array buffers are copied, circular // references are kept. Anything else, like functions, errors and built-ins // with internal state like `URL`, is kept as it is. -function cloneDeep(value, clones = new Map()) { +export function cloneDeep(value, clones = new Map()) { if (!isObject(value)) { return value; } else if (clones.has(value)) { @@ -500,33 +511,3 @@ function cloneDeep(value, clones = new Map()) { clones.set(value, copy); return copy; } - -module.exports = { - isEmpty, - isString, - isRegExp, - isObject, - hasOwn, - isNumber, - isFunction, - jsonEquals, - isPlainObject, - difference, - upperFirst, - zipObject, - mergeMaps, - cloneDeep, - asSingle, - asArray, - flatten, - groupBy, - uniqBy, - values, - union, - chunk, - omit, - once, - last, - get, - set, -}; diff --git a/lib/utils/parseFieldExpression.js b/lib/utils/parseFieldExpression.js index 926747908..6810e9779 100644 --- a/lib/utils/parseFieldExpression.js +++ b/lib/utils/parseFieldExpression.js @@ -1,10 +1,8 @@ -'use strict'; - -const jsonFieldExpressionParser = require('../queryBuilder/parsers/jsonFieldExpressionParser'); -const { isObject, hasOwn } = require('./objectUtils'); +import * as jsonFieldExpressionParser from '../queryBuilder/parsers/jsonFieldExpressionParser.js'; +import { isObject, hasOwn } from './objectUtils.js'; const cache = new Map(); -function parseFieldExpression(expr) { +export function parseFieldExpression(expr) { let parsedExpr = cache.get(expr); if (parsedExpr !== undefined) { @@ -50,13 +48,13 @@ function freezeParsedExpr(parsedExpr) { // Keys with a ':' in the data of patches and updates are field expressions like // `'meta:a.b'` that write into json columns, unless they are `declared`: the // `properties` of the schema, or an object with their column names as keys. -function isFieldExpressionKey(key, declared) { +export function isFieldExpressionKey(key, declared) { return key.includes(':') && !(isObject(declared) && hasOwn(declared, key)); } // Returns the json column of a field expression key like `'meta:a'`, or `null` // if the key isn't a field expression or doesn't parse as one. -function getFieldExpressionColumn(key, declared) { +export function getFieldExpressionColumn(key, declared) { if (isFieldExpressionKey(key, declared)) { try { return parseFieldExpression(key).column; @@ -72,7 +70,7 @@ function getFieldExpressionColumn(key, declared) { // literal, e.g. `{a,"",b}`. Keys that would otherwise be misread (empty keys, // keys with whitespace, delimiters, quotes or backslashes and `NULL`) are // double-quoted, with quotes and backslashes escaped. -function toPostgresJsonPath(access) { +export function toPostgresJsonPath(access) { const keys = access.map(({ ref }) => { const key = String(ref); return key === '' || /[{}",\\\s]/.test(key) || key.toUpperCase() === 'NULL' @@ -91,7 +89,7 @@ function toPostgresJsonPath(access) { // so they can't escape the closing quote either. But knex's Postgres dialect // drops all backslashes in front of an escaped `?`, so if the path contains any, // an escape string literal is used in which backslashes are written as `\134`. -function toPostgresJsonPathLiteral(access) { +export function toPostgresJsonPathLiteral(access) { const path = toPostgresJsonPath(access).replace(/'/g, "''"); if (path.includes('\\?')) { @@ -101,10 +99,12 @@ function toPostgresJsonPathLiteral(access) { } } -module.exports = { - parseFieldExpression, - isFieldExpressionKey, - getFieldExpressionColumn, - toPostgresJsonPath, - toPostgresJsonPathLiteral, -}; +// Formats the json path of a parsed field expression as a SQL/JSON path like +// `$."a"[0]`, as MySQL and SQLite use it. Keys are always quoted, so that they +// can't be misread. +export function toJsonPath(access) { + const path = access.map(({ type, ref }) => + type === 'array' ? `[${ref}]` : `.${JSON.stringify(String(ref))}`, + ); + return `$${path.join('')}`; +} diff --git a/lib/utils/promiseUtils/after.js b/lib/utils/promiseUtils/after.js index ce783bb5a..b48031452 100644 --- a/lib/utils/promiseUtils/after.js +++ b/lib/utils/promiseUtils/after.js @@ -1,17 +1,11 @@ -'use strict'; - -const { isPromise } = require('./isPromise'); +import { isPromise } from './isPromise.js'; // Call `func` after `obj` has been resolved. Call `func` synchronously if // `obj` is not a promise for performance reasons. -function after(obj, func) { +export function after(obj, func) { if (isPromise(obj)) { return obj.then(func); } else { return func(obj); } } - -module.exports = { - after, -}; diff --git a/lib/utils/promiseUtils/afterReturn.js b/lib/utils/promiseUtils/afterReturn.js index 281e7fe78..2b628f57a 100644 --- a/lib/utils/promiseUtils/afterReturn.js +++ b/lib/utils/promiseUtils/afterReturn.js @@ -1,17 +1,11 @@ -'use strict'; - -const { isPromise } = require('./isPromise'); +import { isPromise } from './isPromise.js'; // Return `returnValue` after `obj` has been resolved. Return `returnValue` // synchronously if `obj` is not a promise for performance reasons. -function afterReturn(obj, returnValue) { +export function afterReturn(obj, returnValue) { if (isPromise(obj)) { return obj.then(() => returnValue); } else { return returnValue; } } - -module.exports = { - afterReturn, -}; diff --git a/lib/utils/promiseUtils/index.js b/lib/utils/promiseUtils/index.js index d25b22b0d..c8e56838a 100644 --- a/lib/utils/promiseUtils/index.js +++ b/lib/utils/promiseUtils/index.js @@ -1,17 +1,8 @@ -'use strict'; +import { isPromise } from './isPromise.js'; +import { after } from './after.js'; +import { afterReturn } from './afterReturn.js'; +import { mapAfterAllReturn } from './mapAfterAllReturn.js'; +import { promiseMap } from './map.js'; +import { promiseTry } from './try.js'; -const { isPromise } = require('./isPromise'); -const { after } = require('./after'); -const { afterReturn } = require('./afterReturn'); -const { mapAfterAllReturn } = require('./mapAfterAllReturn'); -const { promiseMap } = require('./map'); -const { promiseTry } = require('./try'); - -module.exports = { - isPromise, - after, - afterReturn, - mapAfterAllReturn, - map: promiseMap, - try: promiseTry, -}; +export { isPromise, after, afterReturn, mapAfterAllReturn, promiseMap as map, promiseTry as try }; diff --git a/lib/utils/promiseUtils/isPromise.js b/lib/utils/promiseUtils/isPromise.js index d9f1bc127..0840d637e 100644 --- a/lib/utils/promiseUtils/isPromise.js +++ b/lib/utils/promiseUtils/isPromise.js @@ -1,11 +1,5 @@ -'use strict'; +import { isObject, isFunction } from '../objectUtils.js'; -const { isObject, isFunction } = require('../objectUtils'); - -function isPromise(obj) { +export function isPromise(obj) { return isObject(obj) && isFunction(obj.then); } - -module.exports = { - isPromise, -}; diff --git a/lib/utils/promiseUtils/map.js b/lib/utils/promiseUtils/map.js index 70a614c87..9b210957e 100644 --- a/lib/utils/promiseUtils/map.js +++ b/lib/utils/promiseUtils/map.js @@ -1,9 +1,7 @@ -'use strict'; - -const { isPromise } = require('./isPromise'); +import { isPromise } from './isPromise.js'; // Works like Bluebird.map. -function promiseMap(items, mapper, opt) { +export function promiseMap(items, mapper, opt) { switch (items.length) { case 0: return mapZero(); @@ -43,7 +41,8 @@ function mapMany(items, mapper, opt = {}) { const ctx = { reject, resolve, - rejected: false, + failed: false, + error: null, index: 0, numFinished: 0, results: new Array(items.length), @@ -51,7 +50,7 @@ function mapMany(items, mapper, opt = {}) { mapper, }; - while (ctx.index < concurrency && ctx.index < items.length && !ctx.rejected) { + while (ctx.index < concurrency && ctx.index < items.length && !ctx.failed) { executeNext(ctx); } }); @@ -59,7 +58,7 @@ function mapMany(items, mapper, opt = {}) { function executeNext(ctx) { try { - if (ctx.rejected) { + if (ctx.failed) { return; } @@ -82,12 +81,14 @@ function executeNext(ctx) { } function afterExecute(ctx, result, index) { - if (ctx.rejected) { + ctx.numFinished++; + + if (ctx.failed) { + rejectWhenFinished(ctx); return null; } ctx.results[index] = result; - ctx.numFinished++; if (ctx.numFinished === ctx.items.length) { ctx.resolve(ctx.results); @@ -101,10 +102,20 @@ function afterExecute(ctx, result, index) { } function onError(ctx, err) { - ctx.rejected = true; - ctx.reject(err); + ctx.numFinished++; + + if (!ctx.failed) { + ctx.failed = true; + ctx.error = err; + } + + rejectWhenFinished(ctx); } -module.exports = { - promiseMap, -}; +// Wait for all started mappers before rejecting: They may run queries in a +// transaction that the caller rolls back on rejection. +function rejectWhenFinished(ctx) { + if (ctx.numFinished === ctx.index) { + ctx.reject(ctx.error); + } +} diff --git a/lib/utils/promiseUtils/mapAfterAllReturn.js b/lib/utils/promiseUtils/mapAfterAllReturn.js index 0db3788b0..e16b94887 100644 --- a/lib/utils/promiseUtils/mapAfterAllReturn.js +++ b/lib/utils/promiseUtils/mapAfterAllReturn.js @@ -1,11 +1,9 @@ -'use strict'; - -const { isPromise } = require('./isPromise'); +import { isPromise } from './isPromise.js'; // Map `arr` with `mapper` and after that return `returnValue`. If none of // the mapped values is a promise, return synchronously for performance // reasons. -function mapAfterAllReturn(arr, mapper, returnValue) { +export function mapAfterAllReturn(arr, mapper, returnValue) { const results = new Array(arr.length); let containsPromise = false; @@ -23,7 +21,3 @@ function mapAfterAllReturn(arr, mapper, returnValue) { return returnValue; } } - -module.exports = { - mapAfterAllReturn, -}; diff --git a/lib/utils/promiseUtils/try.js b/lib/utils/promiseUtils/try.js index d564a494d..48456821d 100644 --- a/lib/utils/promiseUtils/try.js +++ b/lib/utils/promiseUtils/try.js @@ -1,9 +1,7 @@ -'use strict'; - -const { isPromise } = require('./isPromise'); +import { isPromise } from './isPromise.js'; // Works like Bluebird.try. -function promiseTry(callback) { +export function promiseTry(callback) { try { const maybePromise = callback(); @@ -16,7 +14,3 @@ function promiseTry(callback) { return Promise.reject(err); } } - -module.exports = { - promiseTry, -}; diff --git a/lib/utils/resolveModel.js b/lib/utils/resolveModel.js index 3e7d17876..19b24d6e5 100644 --- a/lib/utils/resolveModel.js +++ b/lib/utils/resolveModel.js @@ -1,11 +1,14 @@ -'use strict'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { isString, isFunction } from '../utils/objectUtils.js'; -const path = require('path'); -const { isString, isFunction } = require('../utils/objectUtils'); +// Model files given as paths are loaded synchronously with `require()`, which +// on Node >= 20.19 loads both CommonJS and ES modules (without top-level await). +const require = createRequire(import.meta.url); class ResolveError extends Error {} -function resolveModel(modelRef, modelPaths, errorPrefix) { +export function resolveModel(modelRef, modelPaths, errorPrefix) { try { if (isString(modelRef)) { if (isAbsolutePath(modelRef)) { @@ -20,7 +23,7 @@ function resolveModel(modelRef, modelPaths, errorPrefix) { if (!isModelClass(modelRef)) { throw new ResolveError( - `is not a subclass of Model or a file path to a module that exports one. You may be dealing with a require loop. See the documentation section about require loops.`, + `is not a subclass of Model or a file path to a module that exports one. You may be dealing with circular imports (a require loop). See the documentation section about circular imports.`, ); } @@ -56,19 +59,13 @@ function requireUsingModelPaths(modelRef, modelPaths) { } function requireModel(modelPath) { - /** - * Wrap path string in template literal to prevent - * warnings about Objection.JS being an expression - * in webpack builds. - * @link https://github.com/webpack/webpack/issues/196 - */ - let mod = require(`${path.resolve(modelPath)}`); + let mod = require(path.resolve(modelPath)); let modelClass = null; if (isModelClass(mod)) { modelClass = mod; } else if (isModelClass(mod.default)) { - // Babel 6 style of exposing default export. + // ES module or Babel style default export. modelClass = mod.default; } else { Object.keys(mod).forEach((exportName) => { @@ -100,7 +97,3 @@ function isAbsolutePath(pth) { function isModelClass(maybeModel) { return isFunction(maybeModel) && maybeModel.isObjectionModelClass; } - -module.exports = { - resolveModel, -}; diff --git a/lib/utils/tmpColumnUtils.js b/lib/utils/tmpColumnUtils.js index e751c3c12..2b13a4df1 100644 --- a/lib/utils/tmpColumnUtils.js +++ b/lib/utils/tmpColumnUtils.js @@ -1,16 +1,9 @@ -'use strict'; - const OWNER_JOIN_COLUMN_ALIAS_PREFIX = 'objectiontmpjoin'; -function getTempColumn(index) { +export function getTempColumn(index) { return `${OWNER_JOIN_COLUMN_ALIAS_PREFIX}${index}`; } -function isTempColumn(col) { +export function isTempColumn(col) { return col.startsWith(OWNER_JOIN_COLUMN_ALIAS_PREFIX); } - -module.exports = { - getTempColumn, - isTempColumn, -}; diff --git a/lib/utils/warnings.js b/lib/utils/warnings.js index c6779f60e..d1d8afd2e 100644 --- a/lib/utils/warnings.js +++ b/lib/utils/warnings.js @@ -1,26 +1,14 @@ -'use strict'; - const LOGGED_WARNINGS = new Set(); // Log each warning only once. -function warnOnce(message) { +export function warnOnce(message) { if (!LOGGED_WARNINGS.has(message)) { LOGGED_WARNINGS.add(message); console.warn(`[objection] ${message}`); } } -function deprecate(message) { - warnOnce(message); -} - // Only meant for tests that need to see a warning again. -function resetWarnings() { +export function resetWarnings() { LOGGED_WARNINGS.clear(); } - -module.exports = { - warnOnce, - deprecate, - resetWarnings, -}; diff --git a/lib/utils/wrapDbError.js b/lib/utils/wrapDbError.js deleted file mode 100644 index f22f45f7d..000000000 --- a/lib/utils/wrapDbError.js +++ /dev/null @@ -1,64 +0,0 @@ -'use strict'; - -const { wrapError, DBError, UniqueViolationError } = require('db-errors'); - -// db-errors only recognizes mssql unique key violations, but primary key -// violations have the same error number, with a different message (#2688). -const MSSQL_PRIMARY_KEY_REGEX = - /Violation of PRIMARY KEY constraint '(.+)'\. Cannot insert duplicate key in object '(.+)\.(.+)'\. The duplicate key value is \((.+)\)/; - -function wrapDbError(err) { - // tedious throws an AggregateError with an empty message when SQL Server - // reports several errors for one statement, and knex only prepends the SQL to - // it. Add the messages of its errors, to give the reasons. - if (isTediousAggregateError(err)) { - const wrapped = new DBError({ nativeError: err, client: 'mssql' }); - wrapped.message += err.errors.map((error) => error.message).join(' '); - return wrapped; - } - - if (!isTediousError(err)) { - return wrapError(err); - } - - // db-errors parses the errors of the `mssql` package, which wraps the errors - // of tedious and keeps their number and class in `originalError.info`. knex - // uses tedious directly, so its errors are passed on in that shape. - const wrapped = wrapError({ - code: err.code, - originalError: { message: err.message, info: { number: err.number, class: err.class } }, - }); - - if (!(wrapped instanceof DBError)) { - return err; - } - - wrapped.nativeError = err; - - const match = wrapped.constructor === DBError && MSSQL_PRIMARY_KEY_REGEX.exec(err.message); - - if (match) { - return new UniqueViolationError({ - nativeError: err, - client: 'mssql', - table: match[3], - schema: match[2], - constraint: match[1], - }); - } - - return wrapped; -} - -function isTediousError(err) { - return err?.code === 'EREQUEST' && Number.isInteger(err.number); -} - -// Check the name, as AggregateError isn't defined on Node 14. -function isTediousAggregateError(err) { - return err?.name === 'AggregateError' && err.errors.every(isTediousError); -} - -module.exports = { - wrapDbError, -}; diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 71e6f5861..000000000 --- a/package-lock.json +++ /dev/null @@ -1,12200 +0,0 @@ -{ - "name": "objection", - "version": "3.9.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "objection", - "version": "3.9.0", - "license": "MIT", - "dependencies": { - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "db-errors": "^0.2.3" - }, - "devDependencies": { - "@types/node": "^22.15.3", - "chai": "^4.3.10", - "chai-subset": "^1.6.0", - "eslint": "^8.54.0", - "eslint-formatter-codeframe": "^7.32.1", - "eslint-plugin-prettier": "^5.3.1", - "expect.js": "^0.3.1", - "knex": "^3.3.0", - "mocha": "^11.2.2", - "mysql": "^2.18.1", - "pg": "^8.15.6", - "prettier": "3.9.9", - "sqlite3": "^5.1.7", - "tedious": "^20.3.7", - "typescript": "^5.8.3", - "vitepress": "1.6.4" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "knex": ">=2.3.0 <4" - } - }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@algolia/abtesting": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.25.0.tgz", - "integrity": "sha512-rSTin9Uta23uaewYVQEp8XI9T3iA/zrg0/1G2vhf8oFFDxFL5vybnZ5IQwsVAg4JpKxPX4/WYNKdcfWrZymk7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/autocomplete-core": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", - "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", - "@algolia/autocomplete-shared": "1.17.7" - } - }, - "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", - "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.7" - }, - "peerDependencies": { - "search-insights": ">= 1 < 3" - } - }, - "node_modules/@algolia/autocomplete-preset-algolia": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", - "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.7" - }, - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/autocomplete-shared": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", - "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/client-abtesting": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.59.0.tgz", - "integrity": "sha512-bm2XN0hCSMYwStSsCBT0/PUB2BDxoyR1Lnub3c392HMEy9bi8PUSW8vR6zltVEKWG2t4PQFWMD5C07bmJxGgPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.59.0.tgz", - "integrity": "sha512-XOFPOTa69WuqHR6c5tMgnUUwwqQgNSzMpxmhrgA9KmxRf8WIqEa0cokHJvohk5CYb7CZx0xeSL6Bk2IUJ7Lv7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-common": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.59.0.tgz", - "integrity": "sha512-PC8ipLOYFKRTfIUY1J3FJxS6ryzWziaXmIX9/sNMoUR8L+XhF7hX2QAeUI87YVl5zujvlYTSOb+HAIqKXDjyHQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-insights": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.59.0.tgz", - "integrity": "sha512-yFNcCMM5fHiyoR0HuxMrzy+VjDcmhFUZTm2IJ2DHwGsVH3B5SEob4zTmeEZ3j/AZqNnmrJOCKl/tJnBg7+84bA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.59.0.tgz", - "integrity": "sha512-GYja6HkDt2VrQhWmB2cLx3Z5fDwI9no7q+xwCWcFrTPm5CLH9QZvK0XLO9co1FcNIDxxkMaP3AXM39/XcecEOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-query-suggestions": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.59.0.tgz", - "integrity": "sha512-Wofg7bMpWh8N5qDDZs0wy6whc+KMmdNsxgrIGp9Ug2s1Bka0uq7AjyckdxReKPHLA0Q1qH8X/SNh0wn2t2X2Lw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-search": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.59.0.tgz", - "integrity": "sha512-fHnALZfbEnODczGk14Y/1YBRApp6UEpZUTexGcMUPzY7RDc7q4HN2Y6jh0KUG8Jo9B+q+wOoQEc3ziR0ErbuFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/ingestion": { - "version": "1.59.0", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.59.0.tgz", - "integrity": "sha512-Fa38s1mHgoaLCT117sfJ6P78rtxUt93CYxBYpq1VOIcslaF+cH+1h5uikAPsKfxLnsuEDZHiBiaI6eNPTsQMRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/monitoring": { - "version": "1.59.0", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.59.0.tgz", - "integrity": "sha512-NyNsRSqM2tF1MX7ZGw/j4rduoEJiQ5wfvbSo/CFVdEzYCjyxbGFRPOZyS/GETJG9rk1TdHOq8NZqKqk/u1fm4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/recommend": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.59.0.tgz", - "integrity": "sha512-nXBK2uygWtvbCOffMWqqfjjcEtp9enDKY5/2Pw/Hhw8VVaOyK2ThbGK04bNX/Le+qoK1VHYkodWGVTKfw0Otkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.59.0.tgz", - "integrity": "sha512-yb+4afX/zja8QwX0KmV4/ae2kyYkRknsE79CRusYS2U5D8qwn2wq0cn1x12f3tm3F3+5FgmDdbuGdQTuCLSKMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-fetch": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.59.0.tgz", - "integrity": "sha512-Lp52TmpA1QtNmdHzs505Xwf4tgII7RAapkDSF9AAtWIBETcGaTIaXci4Rd9nS6SdaWK1BWTSqAPp5wzh6UN3Ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-node-http": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.59.0.tgz", - "integrity": "sha512-YYHLEs5rC6oRTFwT7bJaKBR9NSFzikeVHGAT1ffATmUQzR8XqyhHXjoieAUoR7fBU6I5cfy1FVeh5GoRgriuzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@azure-rest/core-client": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.9.0.tgz", - "integrity": "sha512-6933vNLqh06RR7rumnrq3UZIeBtRLeBHDPMrieDPIljiCaMMwt0nRw++nd3TOxiohtasNtm29rC+b/9ZVKCOqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.24.0", - "@azure/core-tracing": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.8", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/abort-controller": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", - "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-auth": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", - "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-util": "^1.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-client": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.1.tgz", - "integrity": "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-lro": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", - "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-util": "^1.2.0", - "@azure/logger": "^1.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-paging": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.7.0.tgz", - "integrity": "sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-process": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@azure/core-process/-/core-process-1.0.0.tgz", - "integrity": "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-rest-pipeline": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", - "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.4", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-tracing": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", - "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/core-util": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", - "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/identity": { - "version": "4.13.3", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.3.tgz", - "integrity": "sha512-zGQPtqvXPgSA8yfV2CkIQ1qirqk0p9AIVpC5uEkdXQYcKl07QHvyaGYRnZOk0AsQUmxNb4wfkcwY5di8Z5xa9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.9.0", - "@azure/core-client": "^1.9.2", - "@azure/core-process": "^1.0.0", - "@azure/core-rest-pipeline": "^1.17.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^5.5.0", - "@azure/msal-node": "^6.0.0", - "open": "^10.1.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/keyvault-common": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.1.0.tgz", - "integrity": "sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure-rest/core-client": "^2.3.3", - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.3.0", - "@azure/core-rest-pipeline": "^1.8.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.10.0", - "@azure/logger": "^1.1.4", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/keyvault-keys": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.10.2.tgz", - "integrity": "sha512-VmUSLbXRAbSzDD8grXHGPaknYs0SKr3yuf6U+d4XMpX4XuVYskNqbTTwXce0zR1LyxfTZm9rWEBcvs3vdYwCmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure-rest/core-client": "^2.3.3", - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.9.0", - "@azure/core-lro": "^2.7.2", - "@azure/core-paging": "^1.6.2", - "@azure/core-rest-pipeline": "^1.19.0", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.11.0", - "@azure/keyvault-common": "^2.1.0", - "@azure/logger": "^1.1.4", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/logger": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", - "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@azure/msal-browser": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.25.0.tgz", - "integrity": "sha512-7GyXzDUrdLG5zPDCYJbQdJqbl9a2U9SWUSC4lxr7cXjkBq6diTfWcJsi0UjdGWlqy+EevQg9dSC4g3s3BEp19g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/msal-common": "16.14.2" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-common": { - "version": "16.14.2", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.2.tgz", - "integrity": "sha512-HOBR0Gfo39TMrkxNG8k6cfjRkrRjXZc3JQs3ebMWQc9S4cihuknMYra3sgmzbZB752oQBqBN9+WF7c826p2vIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-node": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-6.0.1.tgz", - "integrity": "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/msal-common": "16.14.1", - "jsonwebtoken": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { - "version": "16.14.1", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.1.tgz", - "integrity": "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@docsearch/css": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", - "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@docsearch/js": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", - "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@docsearch/react": "3.8.2", - "preact": "^10.0.0" - } - }, - "node_modules/@docsearch/react": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", - "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-core": "1.17.7", - "@algolia/autocomplete-preset-algolia": "1.17.7", - "@docsearch/css": "3.8.2", - "algoliasearch": "^5.14.2" - }, - "peerDependencies": { - "@types/react": ">= 16.8.0 < 19.0.0", - "react": ">= 16.8.0 < 19.0.0", - "react-dom": ">= 16.8.0 < 19.0.0", - "search-insights": ">= 1 < 3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "search-insights": { - "optional": true - } - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", - "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true, - "optional": true - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true - }, - "node_modules/@iconify-json/simple-icons": { - "version": "1.2.99", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.99.tgz", - "integrity": "sha512-kouZhqvUoNdxEe+lZksbJJZMCZv4b4Ob62O1GW3fcpz/I0mVPzM+9/Qf/DUuv5ns8l6c46D8M/ubhJjjbLp9HQ==", - "dev": true, - "license": "CC0-1.0", - "dependencies": { - "@iconify/types": "*" - } - }, - "node_modules/@iconify/types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@js-joda/core": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-6.3.0.tgz", - "integrity": "sha512-mdmjc6lJkTCORsHlMxBT9XM1yLqgtsNZmv/Hwme2v/HIb15D2XcmklZKTnynqBgsSizA9SkFvzpuksfIXhDV/w==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.scandir/node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@npmcli/fs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", - "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "dev": true, - "optional": true, - "dependencies": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "node_modules/@npmcli/move-file": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", - "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", - "dev": true, - "optional": true, - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@npmcli/move-file/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "optional": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@pkgr/core": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", - "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.3.tgz", - "integrity": "sha512-a31GFbL70GlGUGhn8goVjRLtdDG1urLCJFWLZ7JcxdHK9pPvCohLFtYLgJDdNbtrNZSrJl1u9bK05ISsSC2EpA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.3.tgz", - "integrity": "sha512-IRv757RLiPGlNxAX5JXU0eiUlUPvKiwFRbkqLd+gywptLK4uSEg1qkl6en6r0cXMazSnWUWOBABv+QfuLV3n5Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.3.tgz", - "integrity": "sha512-UkzI35Fnz6+MJNATdnjnZ3TpdG/rXab8h7oDOD9R0zDVIFmuuYrvgUj8V8mh+A//+LR4TlEQo4ZW4RhyRtWv3A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.3.tgz", - "integrity": "sha512-C3AyMtAnKQ/jSkZ0chIoWbPXRbFz8JnPLhl1ULecEhcaOGkT19xt7KmO8BulQxt9S/XideHRXvsVQUbGj4JCOg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.3.tgz", - "integrity": "sha512-M8yAdBMyHh14JLeXDNGvEkljzuHdcoLBQ399O4Fp4nvX51lueuEY/5U2G9Ci0D6/bQlBp/3JLiBQH55STwwbwg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.3.tgz", - "integrity": "sha512-WQjuOKGh2FN98ZK6i0uM6M6YqnPEds85p3oRkrxiEeTQo/p7RpuU8c9MwQ88LfgwVAzohumlzbQQSikz4Zw9rw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.3.tgz", - "integrity": "sha512-5tDLZG5s9WyVKoEHgwos6FpKrgpotl61EYeHSLSVkQzMzTsaClwEr8mok9rm3y734zjiGInG4dfw/56ebWVvEQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.3.tgz", - "integrity": "sha512-Mh5KbLeplM2HX6lQI18jsxSsqvPW8d+Hyk8HtY6Zl93woMitRSCZ92rBEzq6v8bfZGlpyMgj4P/sKW4KCjhH3A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.3.tgz", - "integrity": "sha512-QZrrwHnznTv6y4iiHtCqDN+ZsF4qNFQObMOLgPrbxhh9O5o4rgoUe5OnkqG/ceCObMrAjhQt+eQ6HSDzBFgVMw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.3.tgz", - "integrity": "sha512-4vLehpXSwlkltnvk5ETB+Hz+d3xayZS3NamZ5Vq4bSo+IUoIALovEJF1YgWPMjHwN7R5o/R4BpP9h3q8EK1xAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.3.tgz", - "integrity": "sha512-SEN7TTTOdXJKZmsWKOnQeVfgy3Dje/08ISXWwFhTggQ3SxjuIaChvmOB5ZDZqJNlBjaXrV6nAHraqEAvdLGqcw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.3.tgz", - "integrity": "sha512-1LDsQMRMCEdGF9Dih5qyh6fF1LK3NnA+pAtE2+9AgZwUdXyd19TOBVC9crSB63iZBHR4vOG7h8gN/LcUH3J7xw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.3.tgz", - "integrity": "sha512-v3D2tAfRriqGOi+5Ac5CiYbBo2NXtLVxJlludTvUZRj502lVMFeVIuvvWbz4qCrotn3e3Uvpvh7FcMpG2QxxXQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.3.tgz", - "integrity": "sha512-db5q6dP189V2+JkMpsXBl8ym6bC0x8ydZ47fUJZ9YdM6T2OF5uqNzX9HaAZHRP/lqjA9bJeNwi0qw1/2/37dUA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.3.tgz", - "integrity": "sha512-nArAVDmrL5ix9UoipcKEsYJw4kMUQCIPYRWuYF2POOC7jd9rmN95d/48ewsYFC/OdtwEx6VYYWYxrp8FbMOU2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.3.tgz", - "integrity": "sha512-2dc7LElu7EVSvpQT8epNnBLa02wlqilNYqHN0FA/RaKqCr9U9ZFYQ0Eh4sfgSzLneLDuJsJRjcezR/l2UyYGXA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.3.tgz", - "integrity": "sha512-9PYq2IEzSwXcvQLERcT9LKXRzw8J05GFTbJHA2anJR2WmlRjA1esgXVYhanp+bmjKsDVoZeU2L7Z2pRClLcZTw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.3.tgz", - "integrity": "sha512-vMh+mt0cw1BHldd8cqaU6A5s1W4EUKl6+8MRsv86mj954L4xyruKD7qPXMUI2icImadAZARK10h0onaGTLmCVg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.3.tgz", - "integrity": "sha512-PpiKU+sWbqDGc0U0su8oXiyeyqm0UQjmaaPKHktX+DaLe6YKJwW2YVD0f2AXHdVvblZbRqpwRVClWP2F5HCqrQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.3.tgz", - "integrity": "sha512-/3enY/AmaIt8IS0fWjX4ztYVPBqkj49cQTy/hCryhBFaEzDfJwwEolq9sxvyWAM2qmsmVuPdeRg5JXSrmeqvJg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.3.tgz", - "integrity": "sha512-zsEP/QDuet52hhwvi5T1VPg0/Tkms0m3zkVv7CPsznvhdO4pL4oJTMz26EJsiCe18OeLKPgpTu3XVO3C1eq3ew==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.3.tgz", - "integrity": "sha512-4ESknW4Db+kqvazVOfsReYJaHxcIsOqcgwjKV2J1tG5Bn4RVf5ocTQKSGuq2HWBJcOLuyim8IWX8DuH6nKmELA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.3.tgz", - "integrity": "sha512-3B8VCQyIyv7FQxYYhb3HpR3bIpronR20txjMSffSoz/sm/DDs3/49CFpEueOsvq9qW9sWoI/6pi6mtciD9NXFg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.3.tgz", - "integrity": "sha512-YOoDkKh2oQxOxRB5D9AR8sj3qpMShpmyTkjojiBjMGigz3caV7UvCnXWWUQC0TBmrZ5zuHe2pWl8S+cMfvXzRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.3.tgz", - "integrity": "sha512-qTv4bG+eKJKifzxNJevMUh8L6CjJrz9/Kn1IbP2F5bo0tBzNIR7HMg4vni6XmDzcKUl00lHHY+J5ZgjbGiujOQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@shikijs/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", - "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.4" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", - "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^3.1.0" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", - "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@shikijs/langs": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", - "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/themes": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", - "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/transformers": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", - "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "2.5.0", - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/types": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", - "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, - "optional": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/hast": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", - "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.15.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.24.tgz", - "integrity": "sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/readable-stream": { - "version": "4.0.25", - "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.25.tgz", - "integrity": "sha512-7GsxL8Wd5X3jh73/qgW7WCnbBvGSdD8U3E4Jiuus3/8I+PN20DtlkJtwFhr/sImXfDDQ2Z03i/9wNiIF3TVSsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/web-bluetooth": { - "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", - "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", - "dev": true, - "license": "MIT", - "dependencies": { - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@typespec/ts-http-runtime/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/@typespec/ts-http-runtime/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@typespec/ts-http-runtime/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", - "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@vue/shared": "3.5.43", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", - "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.43", - "@vue/shared": "3.5.43" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", - "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@vue/compiler-core": "3.5.43", - "@vue/compiler-dom": "3.5.43", - "@vue/compiler-ssr": "3.5.43", - "@vue/shared": "3.5.43", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.28", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", - "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.43", - "@vue/shared": "3.5.43" - } - }, - "node_modules/@vue/devtools-api": { - "version": "7.7.10", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", - "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/devtools-kit": "^7.7.10" - } - }, - "node_modules/@vue/devtools-kit": { - "version": "7.7.10", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", - "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/devtools-shared": "^7.7.10", - "birpc": "^2.3.0", - "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^1.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" - } - }, - "node_modules/@vue/devtools-shared": { - "version": "7.7.10", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", - "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "rfdc": "^1.4.1" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", - "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.43" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", - "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.43", - "@vue/shared": "3.5.43" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", - "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.43", - "@vue/runtime-core": "3.5.43", - "@vue/shared": "3.5.43", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", - "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.43", - "@vue/runtime-dom": "3.5.43", - "@vue/shared": "3.5.43" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", - "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vueuse/core": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", - "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/integrations": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", - "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vueuse/core": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "async-validator": "^4", - "axios": "^1", - "change-case": "^5", - "drauu": "^0.4", - "focus-trap": "^7", - "fuse.js": "^7", - "idb-keyval": "^6", - "jwt-decode": "^4", - "nprogress": "^0.2", - "qrcode": "^1.5", - "sortablejs": "^1", - "universal-cookie": "^7" - }, - "peerDependenciesMeta": { - "async-validator": { - "optional": true - }, - "axios": { - "optional": true - }, - "change-case": { - "optional": true - }, - "drauu": { - "optional": true - }, - "focus-trap": { - "optional": true - }, - "fuse.js": { - "optional": true - }, - "idb-keyval": { - "optional": true - }, - "jwt-decode": { - "optional": true - }, - "nprogress": { - "optional": true - }, - "qrcode": { - "optional": true - }, - "sortablejs": { - "optional": true - }, - "universal-cookie": { - "optional": true - } - } - }, - "node_modules/@vueuse/metadata": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", - "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/shared": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", - "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true, - "optional": true - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "optional": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "optional": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/algoliasearch": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.59.0.tgz", - "integrity": "sha512-wUXzaeI7B526W4y1gFg3lcxgDZ67XSgRJIiellYWOas/pLpO7rOtxm9Gr2E/C8aiSDXIgx1q5TdSsvK67Uakqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/abtesting": "1.25.0", - "@algolia/client-abtesting": "5.59.0", - "@algolia/client-analytics": "5.59.0", - "@algolia/client-common": "5.59.0", - "@algolia/client-insights": "5.59.0", - "@algolia/client-personalization": "5.59.0", - "@algolia/client-query-suggestions": "5.59.0", - "@algolia/client-search": "5.59.0", - "@algolia/ingestion": "1.59.0", - "@algolia/monitoring": "1.59.0", - "@algolia/recommend": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/aproba": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", - "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", - "dev": true, - "optional": true - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/bignumber.js": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz", - "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/birpc": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true - }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/bundle-name": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", - "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/chai-subset": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/chai-subset/-/chai-subset-1.6.0.tgz", - "integrity": "sha1-pdDKFOMpp5WW7XAFi2ZGvWmIz+k=", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, - "engines": { - "node": "*" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, - "optional": true, - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/colorette": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", - "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", - "dev": true - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "dev": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "dev": true, - "optional": true - }, - "node_modules/copy-anything": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.5.tgz", - "integrity": "sha512-vePst2VLTSqPglW7Q98VwFpGiGpOHTyXIo81MO5VyLecA5zMbZw/P/V1vmwD1jksa2iJpn/YkDrJ3XhTfzUyhQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/db-errors": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/db-errors/-/db-errors-0.2.3.tgz", - "integrity": "sha512-OOgqgDuCavHXjYSJoV2yGhv6SeG8nk42aoCSoyXLZUH7VwFG27rxbavU1z+VrZbZjphw5UkDQwUlD21MwZpUng==" - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/default-browser": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", - "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "dev": true, - "optional": true - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", - "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/emoji-regex-xs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", - "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "optional": true - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-formatter-codeframe": { - "version": "7.32.1", - "resolved": "https://registry.npmjs.org/eslint-formatter-codeframe/-/eslint-formatter-codeframe-7.32.1.tgz", - "integrity": "sha512-DK/3Q3+zVKq/7PdSYiCxPrsDF8H/TRMK5n8Hziwr4IMkMy+XiKSwbpj25AdajS63I/B61Snetq4uVvX9fOLyAg==", - "dev": true, - "dependencies": { - "@babel/code-frame": "7.12.11", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/eslint-formatter-codeframe/node_modules/@babel/code-frame": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", - "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "5.5.6", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", - "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.13" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/esm": { - "version": "3.2.25", - "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", - "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/expect.js": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/expect.js/-/expect.js-0.3.1.tgz", - "integrity": "sha1-sKWaDS7/VDdUTr8M6qYBWEHQm1s=", - "dev": true - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fast-uri": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", - "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==" - }, - "node_modules/fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "bin": { - "flat": "cli.js" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flat-cache/node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/flat-cache/node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true - }, - "node_modules/focus-trap": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", - "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tabbable": "^6.4.0" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/getopts": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", - "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==", - "dev": true - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", - "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==", - "dev": true, - "optional": true - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "dev": true, - "optional": true - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "bin": { - "he": "bin/he" - } - }, - "node_modules/hookable": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "dev": true, - "optional": true - }, - "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, - "optional": true, - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "optional": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, - "optional": true, - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true, - "optional": true - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/ini": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz", - "integrity": "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==", - "dev": true - }, - "node_modules/interpret": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", - "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/ip": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", - "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", - "dev": true, - "optional": true - }, - "node_modules/is-core-module": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.0.tgz", - "integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", - "dev": true, - "dependencies": { - "has": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true, - "optional": true - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-md4": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", - "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "dev": true, - "license": "MIT", - "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/knex": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/knex/-/knex-3.3.0.tgz", - "integrity": "sha512-LgWl031hNuLv9Lhxdd9093zULa2aNcoP04Bk29e5NidgRcEr/T0rAr2WYi4BhjTiCDoQiRU56meUE5rppLKZzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "colorette": "2.0.19", - "commander": "^10.0.0", - "debug": "4.3.4", - "escalade": "^3.1.1", - "esm": "^3.2.25", - "get-package-type": "^0.1.0", - "getopts": "2.3.0", - "interpret": "^2.2.0", - "lodash": "^4.18.1", - "pg-connection-string": "2.6.2", - "rechoir": "^0.8.0", - "resolve-from": "^5.0.0", - "tarn": "^3.1.0", - "tildify": "2.0.0" - }, - "bin": { - "knex": "bin/cli.js" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "pg-query-stream": "^4.14.0" - }, - "peerDependenciesMeta": { - "better-sqlite3": { - "optional": true - }, - "mariadb": { - "optional": true - }, - "mysql": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "pg-native": { - "optional": true - }, - "pg-query-stream": { - "optional": true - }, - "sqlite3": { - "optional": true - }, - "tedious": { - "optional": true - } - } - }, - "node_modules/knex/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/make-fetch-happen": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", - "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "dev": true, - "optional": true, - "dependencies": { - "agentkeepalive": "^4.1.3", - "cacache": "^15.2.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^6.0.0", - "minipass": "^3.1.3", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^1.3.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.2", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.0.0", - "ssri": "^8.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/make-fetch-happen/node_modules/agentkeepalive": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", - "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", - "dev": true, - "optional": true, - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/make-fetch-happen/node_modules/cacache": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", - "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "dev": true, - "optional": true, - "dependencies": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/make-fetch-happen/node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-fetch-happen/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "optional": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-fetch-happen/node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "optional": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-fetch-happen/node_modules/ssri": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", - "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "dev": true, - "optional": true, - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/mark.js": { - "version": "8.11.1", - "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", - "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", - "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", - "dev": true - }, - "node_modules/minipass": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.3.tgz", - "integrity": "sha512-N0BOsdFAlNRfmwMhjAsLVWOk7Ljmeb39iqFlsV1At+jqRhSUP9yeof8FyJu4imaJiSUp8vQebWD/guZwGQC8iA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-fetch": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", - "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "dev": true, - "optional": true, - "dependencies": { - "minipass": "^3.1.0", - "minipass-sized": "^1.0.3", - "minizlib": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "optionalDependencies": { - "encoding": "^0.1.12" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minisearch": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", - "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", - "dev": true, - "license": "MIT" - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, - "license": "MIT" - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true - }, - "node_modules/mocha": { - "version": "11.8.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz", - "integrity": "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "browser-stdout": "^1.3.1", - "chokidar": "^4.0.1", - "debug": "^4.3.5", - "diff": "^7.0.0", - "escape-string-regexp": "^4.0.0", - "find-up": "^5.0.0", - "glob": "^10.4.5", - "he": "^1.2.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^9.0.5", - "ms": "^2.1.3", - "picocolors": "^1.1.1", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", - "supports-color": "^8.1.1", - "workerpool": "^9.2.0", - "yargs": "^17.7.2", - "yargs-parser": "^21.1.1", - "yargs-unparser": "^2.0.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/mocha/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/mocha/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mocha/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/mocha/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/mocha/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mocha/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/mocha/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/mocha/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/mysql": { - "version": "2.18.1", - "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz", - "integrity": "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==", - "dev": true, - "dependencies": { - "bignumber.js": "9.0.0", - "readable-stream": "2.3.7", - "safe-buffer": "5.1.2", - "sqlstring": "2.3.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/nanoid": { - "version": "3.3.20", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", - "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", - "dev": true - }, - "node_modules/native-duplexpair": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", - "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", - "dev": true, - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "optional": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abi": { - "version": "3.54.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.54.0.tgz", - "integrity": "sha512-p7eGEiQil0YUV3ItH4/tBb781L5impVmmx2E9FRKF7d18XXzp4PGT2tdYMFY6wQqgxD0IwNZOiSJ0/K0fSi/OA==", - "dev": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.0.tgz", - "integrity": "sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==", - "dev": true, - "engines": { - "node": "^16 || ^18 || >= 20" - } - }, - "node_modules/node-gyp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", - "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", - "dev": true, - "optional": true, - "dependencies": { - "env-paths": "^2.2.0", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^9.1.0", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": ">= 10.12.0" - } - }, - "node_modules/node-gyp/node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "dev": true, - "optional": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/node-gyp/node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "dev": true, - "optional": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/node-gyp/node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "dev": true, - "optional": true, - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/node-gyp/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "dev": true, - "optional": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/oniguruma-to-es": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", - "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex-xs": "^1.0.0", - "regex": "^6.0.1", - "regex-recursion": "^6.0.2" - } - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dev": true, - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/path-scurry/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/pg": { - "version": "8.23.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", - "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.14.1", - "pg-pool": "^3.14.0", - "pg-protocol": "^1.16.1", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.4.1" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", - "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", - "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==", - "dev": true - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "dev": true, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", - "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", - "dev": true, - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "dev": true, - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pg/node_modules/pg-connection-string": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", - "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", - "dev": true, - "license": "MIT" - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.5.29", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", - "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.19", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dev": true, - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, - "node_modules/prebuild-install": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", - "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", - "dev": true, - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.9.9", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", - "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", - "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=", - "dev": true, - "optional": true - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "optional": true, - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", - "dev": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/rechoir": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", - "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", - "dev": true, - "dependencies": { - "resolve": "^1.20.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "dev": true, - "license": "MIT" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.3", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.3.tgz", - "integrity": "sha512-P8ur/gp/AmbEzjr729bZnLjXK5Z+4P0zhIJgBgzqRih7hL7BOukHGtSTA3ACMY467GRFz3duQsi0bDZdR7DKdw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.12.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=", - "dev": true, - "optional": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.3.tgz", - "integrity": "sha512-iIMWbptLYsZdGTJMH8ngApXdsCatE6bCMv2eqGIfGE1++kwh7TCWBdWKK0FKC0yx++196vGK5VdagzRXSLMa8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.64.3", - "@rollup/rollup-android-arm64": "4.64.3", - "@rollup/rollup-darwin-arm64": "4.64.3", - "@rollup/rollup-darwin-x64": "4.64.3", - "@rollup/rollup-freebsd-arm64": "4.64.3", - "@rollup/rollup-freebsd-x64": "4.64.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.64.3", - "@rollup/rollup-linux-arm-musleabihf": "4.64.3", - "@rollup/rollup-linux-arm64-gnu": "4.64.3", - "@rollup/rollup-linux-arm64-musl": "4.64.3", - "@rollup/rollup-linux-loong64-gnu": "4.64.3", - "@rollup/rollup-linux-loong64-musl": "4.64.3", - "@rollup/rollup-linux-ppc64-gnu": "4.64.3", - "@rollup/rollup-linux-ppc64-musl": "4.64.3", - "@rollup/rollup-linux-riscv64-gnu": "4.64.3", - "@rollup/rollup-linux-riscv64-musl": "4.64.3", - "@rollup/rollup-linux-s390x-gnu": "4.64.3", - "@rollup/rollup-linux-x64-gnu": "4.64.3", - "@rollup/rollup-linux-x64-musl": "4.64.3", - "@rollup/rollup-openbsd-x64": "4.64.3", - "@rollup/rollup-openharmony-arm64": "4.64.3", - "@rollup/rollup-win32-arm64-msvc": "4.64.3", - "@rollup/rollup-win32-ia32-msvc": "4.64.3", - "@rollup/rollup-win32-x64-gnu": "4.64.3", - "@rollup/rollup-win32-x64-msvc": "4.64.3", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", - "dev": true, - "optional": true - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/shiki": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", - "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "2.5.0", - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/langs": "2.5.0", - "@shikijs/themes": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "optional": true - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/simple-get/node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/simple-get/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "optional": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz", - "integrity": "sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA==", - "dev": true, - "optional": true, - "dependencies": { - "ip": "^1.1.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.13.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", - "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "dev": true, - "optional": true, - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/sqlite3": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", - "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "bindings": "^1.5.0", - "node-addon-api": "^7.0.0", - "prebuild-install": "^7.1.1", - "tar": "^6.1.11" - }, - "optionalDependencies": { - "node-gyp": "8.x" - }, - "peerDependencies": { - "node-gyp": "8.x" - }, - "peerDependenciesMeta": { - "node-gyp": { - "optional": true - } - } - }, - "node_modules/sqlstring": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz", - "integrity": "sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A=", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dev": true, - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/synckit": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", - "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.3.6" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, - "node_modules/tabbable": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", - "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tar": { - "version": "6.1.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", - "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", - "dev": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^3.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/tar/node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tarn": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.1.2.tgz", - "integrity": "sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/tedious": { - "version": "20.3.7", - "resolved": "https://registry.npmjs.org/tedious/-/tedious-20.3.7.tgz", - "integrity": "sha512-s+43djAcQGFiFHZPWtZPaDSZE/ukJnultjeJHXvKbM//OcZSSusSzXuqAIyOKpraNTWgtobhuWOvxrZK+a1yvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@azure/core-auth": "^1.10.1", - "@azure/identity": "^4.13.1", - "@azure/keyvault-keys": "^4.10.2", - "@js-joda/core": "^6.0.1", - "@types/node": ">=22", - "bl": "^6.1.4", - "iconv-lite": "^0.7.0", - "js-md4": "^0.3.2", - "native-duplexpair": "^1.0.0" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/tedious/node_modules/bl": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", - "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/readable-stream": "^4.0.0", - "buffer": "^6.0.3", - "inherits": "^2.0.4", - "readable-stream": "^4.2.0" - } - }, - "node_modules/tedious/node_modules/readable-stream": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", - "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/tedious/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/tedious/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true - }, - "node_modules/tildify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", - "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unique-filename": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", - "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "dev": true, - "optional": true, - "dependencies": { - "unique-slug": "^2.0.0" - } - }, - "node_modules/unique-slug": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", - "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "dev": true, - "optional": true, - "dependencies": { - "imurmurhash": "^0.1.4" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vitepress": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", - "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@docsearch/css": "3.8.2", - "@docsearch/js": "3.8.2", - "@iconify-json/simple-icons": "^1.2.21", - "@shikijs/core": "^2.1.0", - "@shikijs/transformers": "^2.1.0", - "@shikijs/types": "^2.1.0", - "@types/markdown-it": "^14.1.2", - "@vitejs/plugin-vue": "^5.2.1", - "@vue/devtools-api": "^7.7.0", - "@vue/shared": "^3.5.13", - "@vueuse/core": "^12.4.0", - "@vueuse/integrations": "^12.4.0", - "focus-trap": "^7.6.4", - "mark.js": "8.11.1", - "minisearch": "^7.1.1", - "shiki": "^2.1.0", - "vite": "^5.4.14", - "vue": "^3.5.13" - }, - "bin": { - "vitepress": "bin/vitepress.js" - }, - "peerDependencies": { - "markdown-it-mathjax3": "^4", - "postcss": "^8" - }, - "peerDependenciesMeta": { - "markdown-it-mathjax3": { - "optional": true - }, - "postcss": { - "optional": true - } - } - }, - "node_modules/vue": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", - "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.43", - "@vue/compiler-sfc": "3.5.43", - "@vue/runtime-dom": "3.5.43", - "@vue/server-renderer": "3.5.43", - "@vue/shared": "3.5.43" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dev": true, - "optional": true, - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "node_modules/workerpool": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", - "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "engines": { - "node": ">=0.4" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-unparser/node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - }, - "dependencies": { - "@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true - }, - "@algolia/abtesting": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.25.0.tgz", - "integrity": "sha512-rSTin9Uta23uaewYVQEp8XI9T3iA/zrg0/1G2vhf8oFFDxFL5vybnZ5IQwsVAg4JpKxPX4/WYNKdcfWrZymk7w==", - "dev": true, - "requires": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - } - }, - "@algolia/autocomplete-core": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", - "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", - "dev": true, - "requires": { - "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", - "@algolia/autocomplete-shared": "1.17.7" - } - }, - "@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", - "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", - "dev": true, - "requires": { - "@algolia/autocomplete-shared": "1.17.7" - } - }, - "@algolia/autocomplete-preset-algolia": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", - "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", - "dev": true, - "requires": { - "@algolia/autocomplete-shared": "1.17.7" - } - }, - "@algolia/autocomplete-shared": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", - "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", - "dev": true, - "requires": {} - }, - "@algolia/client-abtesting": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.59.0.tgz", - "integrity": "sha512-bm2XN0hCSMYwStSsCBT0/PUB2BDxoyR1Lnub3c392HMEy9bi8PUSW8vR6zltVEKWG2t4PQFWMD5C07bmJxGgPg==", - "dev": true, - "requires": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - } - }, - "@algolia/client-analytics": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.59.0.tgz", - "integrity": "sha512-XOFPOTa69WuqHR6c5tMgnUUwwqQgNSzMpxmhrgA9KmxRf8WIqEa0cokHJvohk5CYb7CZx0xeSL6Bk2IUJ7Lv7Q==", - "dev": true, - "requires": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - } - }, - "@algolia/client-common": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.59.0.tgz", - "integrity": "sha512-PC8ipLOYFKRTfIUY1J3FJxS6ryzWziaXmIX9/sNMoUR8L+XhF7hX2QAeUI87YVl5zujvlYTSOb+HAIqKXDjyHQ==", - "dev": true - }, - "@algolia/client-insights": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.59.0.tgz", - "integrity": "sha512-yFNcCMM5fHiyoR0HuxMrzy+VjDcmhFUZTm2IJ2DHwGsVH3B5SEob4zTmeEZ3j/AZqNnmrJOCKl/tJnBg7+84bA==", - "dev": true, - "requires": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - } - }, - "@algolia/client-personalization": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.59.0.tgz", - "integrity": "sha512-GYja6HkDt2VrQhWmB2cLx3Z5fDwI9no7q+xwCWcFrTPm5CLH9QZvK0XLO9co1FcNIDxxkMaP3AXM39/XcecEOg==", - "dev": true, - "requires": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - } - }, - "@algolia/client-query-suggestions": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.59.0.tgz", - "integrity": "sha512-Wofg7bMpWh8N5qDDZs0wy6whc+KMmdNsxgrIGp9Ug2s1Bka0uq7AjyckdxReKPHLA0Q1qH8X/SNh0wn2t2X2Lw==", - "dev": true, - "requires": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - } - }, - "@algolia/client-search": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.59.0.tgz", - "integrity": "sha512-fHnALZfbEnODczGk14Y/1YBRApp6UEpZUTexGcMUPzY7RDc7q4HN2Y6jh0KUG8Jo9B+q+wOoQEc3ziR0ErbuFg==", - "dev": true, - "requires": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - } - }, - "@algolia/ingestion": { - "version": "1.59.0", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.59.0.tgz", - "integrity": "sha512-Fa38s1mHgoaLCT117sfJ6P78rtxUt93CYxBYpq1VOIcslaF+cH+1h5uikAPsKfxLnsuEDZHiBiaI6eNPTsQMRA==", - "dev": true, - "requires": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - } - }, - "@algolia/monitoring": { - "version": "1.59.0", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.59.0.tgz", - "integrity": "sha512-NyNsRSqM2tF1MX7ZGw/j4rduoEJiQ5wfvbSo/CFVdEzYCjyxbGFRPOZyS/GETJG9rk1TdHOq8NZqKqk/u1fm4g==", - "dev": true, - "requires": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - } - }, - "@algolia/recommend": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.59.0.tgz", - "integrity": "sha512-nXBK2uygWtvbCOffMWqqfjjcEtp9enDKY5/2Pw/Hhw8VVaOyK2ThbGK04bNX/Le+qoK1VHYkodWGVTKfw0Otkw==", - "dev": true, - "requires": { - "@algolia/client-common": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - } - }, - "@algolia/requester-browser-xhr": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.59.0.tgz", - "integrity": "sha512-yb+4afX/zja8QwX0KmV4/ae2kyYkRknsE79CRusYS2U5D8qwn2wq0cn1x12f3tm3F3+5FgmDdbuGdQTuCLSKMQ==", - "dev": true, - "requires": { - "@algolia/client-common": "5.59.0" - } - }, - "@algolia/requester-fetch": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.59.0.tgz", - "integrity": "sha512-Lp52TmpA1QtNmdHzs505Xwf4tgII7RAapkDSF9AAtWIBETcGaTIaXci4Rd9nS6SdaWK1BWTSqAPp5wzh6UN3Ag==", - "dev": true, - "requires": { - "@algolia/client-common": "5.59.0" - } - }, - "@algolia/requester-node-http": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.59.0.tgz", - "integrity": "sha512-YYHLEs5rC6oRTFwT7bJaKBR9NSFzikeVHGAT1ffATmUQzR8XqyhHXjoieAUoR7fBU6I5cfy1FVeh5GoRgriuzA==", - "dev": true, - "requires": { - "@algolia/client-common": "5.59.0" - } - }, - "@azure-rest/core-client": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.9.0.tgz", - "integrity": "sha512-6933vNLqh06RR7rumnrq3UZIeBtRLeBHDPMrieDPIljiCaMMwt0nRw++nd3TOxiohtasNtm29rC+b/9ZVKCOqg==", - "dev": true, - "requires": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.24.0", - "@azure/core-tracing": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.8", - "tslib": "^2.6.2" - } - }, - "@azure/abort-controller": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", - "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==", - "dev": true, - "requires": { - "tslib": "^2.6.2" - } - }, - "@azure/core-auth": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", - "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==", - "dev": true, - "requires": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-util": "^1.13.0", - "tslib": "^2.6.2" - } - }, - "@azure/core-client": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.1.tgz", - "integrity": "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==", - "dev": true, - "requires": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "tslib": "^2.6.2" - } - }, - "@azure/core-lro": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", - "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", - "dev": true, - "requires": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-util": "^1.2.0", - "@azure/logger": "^1.0.0", - "tslib": "^2.6.2" - } - }, - "@azure/core-paging": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.7.0.tgz", - "integrity": "sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g==", - "dev": true, - "requires": { - "tslib": "^2.6.2" - } - }, - "@azure/core-process": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@azure/core-process/-/core-process-1.0.0.tgz", - "integrity": "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==", - "dev": true - }, - "@azure/core-rest-pipeline": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", - "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", - "dev": true, - "requires": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.4", - "tslib": "^2.6.2" - } - }, - "@azure/core-tracing": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", - "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==", - "dev": true, - "requires": { - "tslib": "^2.6.2" - } - }, - "@azure/core-util": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", - "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==", - "dev": true, - "requires": { - "@azure/abort-controller": "^2.1.2", - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - } - }, - "@azure/identity": { - "version": "4.13.3", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.3.tgz", - "integrity": "sha512-zGQPtqvXPgSA8yfV2CkIQ1qirqk0p9AIVpC5uEkdXQYcKl07QHvyaGYRnZOk0AsQUmxNb4wfkcwY5di8Z5xa9A==", - "dev": true, - "requires": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.9.0", - "@azure/core-client": "^1.9.2", - "@azure/core-process": "^1.0.0", - "@azure/core-rest-pipeline": "^1.17.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^5.5.0", - "@azure/msal-node": "^6.0.0", - "open": "^10.1.0", - "tslib": "^2.2.0" - } - }, - "@azure/keyvault-common": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.1.0.tgz", - "integrity": "sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw==", - "dev": true, - "requires": { - "@azure-rest/core-client": "^2.3.3", - "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.3.0", - "@azure/core-rest-pipeline": "^1.8.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.10.0", - "@azure/logger": "^1.1.4", - "tslib": "^2.2.0" - } - }, - "@azure/keyvault-keys": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.10.2.tgz", - "integrity": "sha512-VmUSLbXRAbSzDD8grXHGPaknYs0SKr3yuf6U+d4XMpX4XuVYskNqbTTwXce0zR1LyxfTZm9rWEBcvs3vdYwCmQ==", - "dev": true, - "requires": { - "@azure-rest/core-client": "^2.3.3", - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.9.0", - "@azure/core-lro": "^2.7.2", - "@azure/core-paging": "^1.6.2", - "@azure/core-rest-pipeline": "^1.19.0", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.11.0", - "@azure/keyvault-common": "^2.1.0", - "@azure/logger": "^1.1.4", - "tslib": "^2.8.1" - } - }, - "@azure/logger": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", - "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==", - "dev": true, - "requires": { - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - } - }, - "@azure/msal-browser": { - "version": "5.25.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.25.0.tgz", - "integrity": "sha512-7GyXzDUrdLG5zPDCYJbQdJqbl9a2U9SWUSC4lxr7cXjkBq6diTfWcJsi0UjdGWlqy+EevQg9dSC4g3s3BEp19g==", - "dev": true, - "requires": { - "@azure/msal-common": "16.14.2" - } - }, - "@azure/msal-common": { - "version": "16.14.2", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.2.tgz", - "integrity": "sha512-HOBR0Gfo39TMrkxNG8k6cfjRkrRjXZc3JQs3ebMWQc9S4cihuknMYra3sgmzbZB752oQBqBN9+WF7c826p2vIQ==", - "dev": true - }, - "@azure/msal-node": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-6.0.1.tgz", - "integrity": "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==", - "dev": true, - "requires": { - "@azure/msal-common": "16.14.1", - "jsonwebtoken": "^9.0.0" - }, - "dependencies": { - "@azure/msal-common": { - "version": "16.14.1", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.1.tgz", - "integrity": "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==", - "dev": true - } - } - }, - "@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true - }, - "@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true - }, - "@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.24.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "@babel/parser": { - "version": "7.29.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", - "dev": true, - "requires": { - "@babel/types": "^7.29.8" - } - }, - "@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, - "requires": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - } - }, - "@docsearch/css": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", - "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", - "dev": true - }, - "@docsearch/js": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", - "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", - "dev": true, - "requires": { - "@docsearch/react": "3.8.2", - "preact": "^10.0.0" - } - }, - "@docsearch/react": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", - "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", - "dev": true, - "requires": { - "@algolia/autocomplete-core": "1.17.7", - "@algolia/autocomplete-preset-algolia": "1.17.7", - "@docsearch/css": "3.8.2", - "algoliasearch": "^5.14.2" - } - }, - "@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "dev": true, - "optional": true - }, - "@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "dev": true, - "optional": true - }, - "@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "dev": true, - "optional": true - }, - "@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "dev": true, - "optional": true - }, - "@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "dev": true, - "optional": true - }, - "@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "dev": true, - "optional": true - }, - "@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "dev": true, - "optional": true - }, - "@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "dev": true, - "optional": true - }, - "@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "dev": true, - "optional": true - }, - "@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "dev": true, - "optional": true - }, - "@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "dev": true, - "optional": true - }, - "@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "dev": true, - "optional": true - }, - "@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "dev": true, - "optional": true - }, - "@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "dev": true, - "optional": true - }, - "@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "dev": true, - "optional": true - }, - "@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^3.3.0" - } - }, - "@eslint-community/regexpp": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", - "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", - "dev": true - }, - "@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "requires": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "dependencies": { - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - } - } - }, - "@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "dev": true - }, - "@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true, - "optional": true - }, - "@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "dev": true, - "requires": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - } - }, - "@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true - }, - "@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "dev": true - }, - "@iconify-json/simple-icons": { - "version": "1.2.99", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.99.tgz", - "integrity": "sha512-kouZhqvUoNdxEe+lZksbJJZMCZv4b4Ob62O1GW3fcpz/I0mVPzM+9/Qf/DUuv5ns8l6c46D8M/ubhJjjbLp9HQ==", - "dev": true, - "requires": { - "@iconify/types": "*" - } - }, - "@iconify/types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "dev": true - }, - "@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "requires": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true - }, - "ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true - }, - "emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "requires": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - } - }, - "strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "requires": { - "ansi-regex": "^6.0.1" - } - }, - "wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "requires": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - } - } - } - }, - "@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true - }, - "@js-joda/core": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-6.3.0.tgz", - "integrity": "sha512-mdmjc6lJkTCORsHlMxBT9XM1yLqgtsNZmv/Hwme2v/HIb15D2XcmklZKTnynqBgsSizA9SkFvzpuksfIXhDV/w==", - "dev": true - }, - "@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "dev": true, - "optional": true - }, - "@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "dependencies": { - "@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true - } - } - }, - "@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "@npmcli/fs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", - "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "dev": true, - "optional": true, - "requires": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "@npmcli/move-file": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", - "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", - "dev": true, - "optional": true, - "requires": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "dependencies": { - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "optional": true - } - } - }, - "@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true - }, - "@pkgr/core": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", - "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", - "dev": true - }, - "@rollup/rollup-android-arm-eabi": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.3.tgz", - "integrity": "sha512-a31GFbL70GlGUGhn8goVjRLtdDG1urLCJFWLZ7JcxdHK9pPvCohLFtYLgJDdNbtrNZSrJl1u9bK05ISsSC2EpA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-android-arm64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.3.tgz", - "integrity": "sha512-IRv757RLiPGlNxAX5JXU0eiUlUPvKiwFRbkqLd+gywptLK4uSEg1qkl6en6r0cXMazSnWUWOBABv+QfuLV3n5Q==", - "dev": true, - "optional": true - }, - "@rollup/rollup-darwin-arm64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.3.tgz", - "integrity": "sha512-UkzI35Fnz6+MJNATdnjnZ3TpdG/rXab8h7oDOD9R0zDVIFmuuYrvgUj8V8mh+A//+LR4TlEQo4ZW4RhyRtWv3A==", - "dev": true, - "optional": true - }, - "@rollup/rollup-darwin-x64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.3.tgz", - "integrity": "sha512-C3AyMtAnKQ/jSkZ0chIoWbPXRbFz8JnPLhl1ULecEhcaOGkT19xt7KmO8BulQxt9S/XideHRXvsVQUbGj4JCOg==", - "dev": true, - "optional": true - }, - "@rollup/rollup-freebsd-arm64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.3.tgz", - "integrity": "sha512-M8yAdBMyHh14JLeXDNGvEkljzuHdcoLBQ399O4Fp4nvX51lueuEY/5U2G9Ci0D6/bQlBp/3JLiBQH55STwwbwg==", - "dev": true, - "optional": true - }, - "@rollup/rollup-freebsd-x64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.3.tgz", - "integrity": "sha512-WQjuOKGh2FN98ZK6i0uM6M6YqnPEds85p3oRkrxiEeTQo/p7RpuU8c9MwQ88LfgwVAzohumlzbQQSikz4Zw9rw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.3.tgz", - "integrity": "sha512-5tDLZG5s9WyVKoEHgwos6FpKrgpotl61EYeHSLSVkQzMzTsaClwEr8mok9rm3y734zjiGInG4dfw/56ebWVvEQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm-musleabihf": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.3.tgz", - "integrity": "sha512-Mh5KbLeplM2HX6lQI18jsxSsqvPW8d+Hyk8HtY6Zl93woMitRSCZ92rBEzq6v8bfZGlpyMgj4P/sKW4KCjhH3A==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm64-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.3.tgz", - "integrity": "sha512-QZrrwHnznTv6y4iiHtCqDN+ZsF4qNFQObMOLgPrbxhh9O5o4rgoUe5OnkqG/ceCObMrAjhQt+eQ6HSDzBFgVMw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-arm64-musl": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.3.tgz", - "integrity": "sha512-4vLehpXSwlkltnvk5ETB+Hz+d3xayZS3NamZ5Vq4bSo+IUoIALovEJF1YgWPMjHwN7R5o/R4BpP9h3q8EK1xAA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-loong64-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.3.tgz", - "integrity": "sha512-SEN7TTTOdXJKZmsWKOnQeVfgy3Dje/08ISXWwFhTggQ3SxjuIaChvmOB5ZDZqJNlBjaXrV6nAHraqEAvdLGqcw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-loong64-musl": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.3.tgz", - "integrity": "sha512-1LDsQMRMCEdGF9Dih5qyh6fF1LK3NnA+pAtE2+9AgZwUdXyd19TOBVC9crSB63iZBHR4vOG7h8gN/LcUH3J7xw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-ppc64-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.3.tgz", - "integrity": "sha512-v3D2tAfRriqGOi+5Ac5CiYbBo2NXtLVxJlludTvUZRj502lVMFeVIuvvWbz4qCrotn3e3Uvpvh7FcMpG2QxxXQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-ppc64-musl": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.3.tgz", - "integrity": "sha512-db5q6dP189V2+JkMpsXBl8ym6bC0x8ydZ47fUJZ9YdM6T2OF5uqNzX9HaAZHRP/lqjA9bJeNwi0qw1/2/37dUA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.3.tgz", - "integrity": "sha512-nArAVDmrL5ix9UoipcKEsYJw4kMUQCIPYRWuYF2POOC7jd9rmN95d/48ewsYFC/OdtwEx6VYYWYxrp8FbMOU2Q==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-riscv64-musl": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.3.tgz", - "integrity": "sha512-2dc7LElu7EVSvpQT8epNnBLa02wlqilNYqHN0FA/RaKqCr9U9ZFYQ0Eh4sfgSzLneLDuJsJRjcezR/l2UyYGXA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-s390x-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.3.tgz", - "integrity": "sha512-9PYq2IEzSwXcvQLERcT9LKXRzw8J05GFTbJHA2anJR2WmlRjA1esgXVYhanp+bmjKsDVoZeU2L7Z2pRClLcZTw==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-x64-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.3.tgz", - "integrity": "sha512-vMh+mt0cw1BHldd8cqaU6A5s1W4EUKl6+8MRsv86mj954L4xyruKD7qPXMUI2icImadAZARK10h0onaGTLmCVg==", - "dev": true, - "optional": true - }, - "@rollup/rollup-linux-x64-musl": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.3.tgz", - "integrity": "sha512-PpiKU+sWbqDGc0U0su8oXiyeyqm0UQjmaaPKHktX+DaLe6YKJwW2YVD0f2AXHdVvblZbRqpwRVClWP2F5HCqrQ==", - "dev": true, - "optional": true - }, - "@rollup/rollup-openbsd-x64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.3.tgz", - "integrity": "sha512-/3enY/AmaIt8IS0fWjX4ztYVPBqkj49cQTy/hCryhBFaEzDfJwwEolq9sxvyWAM2qmsmVuPdeRg5JXSrmeqvJg==", - "dev": true, - "optional": true - }, - "@rollup/rollup-openharmony-arm64": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.3.tgz", - "integrity": "sha512-zsEP/QDuet52hhwvi5T1VPg0/Tkms0m3zkVv7CPsznvhdO4pL4oJTMz26EJsiCe18OeLKPgpTu3XVO3C1eq3ew==", - "dev": true, - "optional": true - }, - "@rollup/rollup-win32-arm64-msvc": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.3.tgz", - "integrity": "sha512-4ESknW4Db+kqvazVOfsReYJaHxcIsOqcgwjKV2J1tG5Bn4RVf5ocTQKSGuq2HWBJcOLuyim8IWX8DuH6nKmELA==", - "dev": true, - "optional": true - }, - "@rollup/rollup-win32-ia32-msvc": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.3.tgz", - "integrity": "sha512-3B8VCQyIyv7FQxYYhb3HpR3bIpronR20txjMSffSoz/sm/DDs3/49CFpEueOsvq9qW9sWoI/6pi6mtciD9NXFg==", - "dev": true, - "optional": true - }, - "@rollup/rollup-win32-x64-gnu": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.3.tgz", - "integrity": "sha512-YOoDkKh2oQxOxRB5D9AR8sj3qpMShpmyTkjojiBjMGigz3caV7UvCnXWWUQC0TBmrZ5zuHe2pWl8S+cMfvXzRg==", - "dev": true, - "optional": true - }, - "@rollup/rollup-win32-x64-msvc": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.3.tgz", - "integrity": "sha512-qTv4bG+eKJKifzxNJevMUh8L6CjJrz9/Kn1IbP2F5bo0tBzNIR7HMg4vni6XmDzcKUl00lHHY+J5ZgjbGiujOQ==", - "dev": true, - "optional": true - }, - "@shikijs/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", - "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", - "dev": true, - "requires": { - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.4" - } - }, - "@shikijs/engine-javascript": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", - "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", - "dev": true, - "requires": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^3.1.0" - } - }, - "@shikijs/engine-oniguruma": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", - "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", - "dev": true, - "requires": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "@shikijs/langs": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", - "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", - "dev": true, - "requires": { - "@shikijs/types": "2.5.0" - } - }, - "@shikijs/themes": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", - "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", - "dev": true, - "requires": { - "@shikijs/types": "2.5.0" - } - }, - "@shikijs/transformers": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", - "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", - "dev": true, - "requires": { - "@shikijs/core": "2.5.0", - "@shikijs/types": "2.5.0" - } - }, - "@shikijs/types": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", - "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", - "dev": true, - "requires": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true - }, - "@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, - "optional": true - }, - "@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true - }, - "@types/hast": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", - "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", - "dev": true, - "requires": { - "@types/unist": "*" - } - }, - "@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "dev": true - }, - "@types/markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", - "dev": true, - "requires": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, - "requires": { - "@types/unist": "*" - } - }, - "@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true - }, - "@types/node": { - "version": "22.15.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.24.tgz", - "integrity": "sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng==", - "dev": true, - "requires": { - "undici-types": "~6.21.0" - } - }, - "@types/readable-stream": { - "version": "4.0.25", - "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.25.tgz", - "integrity": "sha512-7GsxL8Wd5X3jh73/qgW7WCnbBvGSdD8U3E4Jiuus3/8I+PN20DtlkJtwFhr/sImXfDDQ2Z03i/9wNiIF3TVSsw==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true - }, - "@types/web-bluetooth": { - "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", - "dev": true - }, - "@typespec/ts-http-runtime": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", - "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", - "dev": true, - "requires": { - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "tslib": "^2.6.2" - }, - "dependencies": { - "agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true - }, - "http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "requires": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - } - }, - "https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "requires": { - "agent-base": "^7.1.2", - "debug": "4" - } - } - } - }, - "@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, - "@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "requires": {} - }, - "@vue/compiler-core": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", - "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", - "dev": true, - "requires": { - "@babel/parser": "^7.29.8", - "@vue/shared": "3.5.43", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "@vue/compiler-dom": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", - "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", - "dev": true, - "requires": { - "@vue/compiler-core": "3.5.43", - "@vue/shared": "3.5.43" - } - }, - "@vue/compiler-sfc": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", - "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", - "dev": true, - "requires": { - "@babel/parser": "^7.29.8", - "@vue/compiler-core": "3.5.43", - "@vue/compiler-dom": "3.5.43", - "@vue/compiler-ssr": "3.5.43", - "@vue/shared": "3.5.43", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.28", - "source-map-js": "^1.2.1" - } - }, - "@vue/compiler-ssr": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", - "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", - "dev": true, - "requires": { - "@vue/compiler-dom": "3.5.43", - "@vue/shared": "3.5.43" - } - }, - "@vue/devtools-api": { - "version": "7.7.10", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", - "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", - "dev": true, - "requires": { - "@vue/devtools-kit": "^7.7.10" - } - }, - "@vue/devtools-kit": { - "version": "7.7.10", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", - "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", - "dev": true, - "requires": { - "@vue/devtools-shared": "^7.7.10", - "birpc": "^2.3.0", - "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^1.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" - } - }, - "@vue/devtools-shared": { - "version": "7.7.10", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", - "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", - "dev": true, - "requires": { - "rfdc": "^1.4.1" - } - }, - "@vue/reactivity": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", - "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", - "dev": true, - "requires": { - "@vue/shared": "3.5.43" - } - }, - "@vue/runtime-core": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", - "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", - "dev": true, - "requires": { - "@vue/reactivity": "3.5.43", - "@vue/shared": "3.5.43" - } - }, - "@vue/runtime-dom": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", - "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", - "dev": true, - "requires": { - "@vue/reactivity": "3.5.43", - "@vue/runtime-core": "3.5.43", - "@vue/shared": "3.5.43", - "csstype": "^3.2.3" - } - }, - "@vue/server-renderer": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", - "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", - "dev": true, - "requires": { - "@vue/compiler-ssr": "3.5.43", - "@vue/runtime-dom": "3.5.43", - "@vue/shared": "3.5.43" - } - }, - "@vue/shared": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", - "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", - "dev": true - }, - "@vueuse/core": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", - "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", - "dev": true, - "requires": { - "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - } - }, - "@vueuse/integrations": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", - "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", - "dev": true, - "requires": { - "@vueuse/core": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - } - }, - "@vueuse/metadata": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", - "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", - "dev": true - }, - "@vueuse/shared": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", - "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", - "dev": true, - "requires": { - "vue": "^3.5.13" - } - }, - "abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true, - "optional": true - }, - "abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, - "requires": { - "event-target-shim": "^5.0.0" - } - }, - "acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", - "dev": true - }, - "acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} - }, - "agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "optional": true, - "requires": { - "debug": "4" - } - }, - "aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "optional": true, - "requires": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - } - }, - "ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "requires": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - } - }, - "ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "requires": { - "ajv": "^8.0.0" - } - }, - "algoliasearch": { - "version": "5.59.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.59.0.tgz", - "integrity": "sha512-wUXzaeI7B526W4y1gFg3lcxgDZ67XSgRJIiellYWOas/pLpO7rOtxm9Gr2E/C8aiSDXIgx1q5TdSsvK67Uakqw==", - "dev": true, - "requires": { - "@algolia/abtesting": "1.25.0", - "@algolia/client-abtesting": "5.59.0", - "@algolia/client-analytics": "5.59.0", - "@algolia/client-common": "5.59.0", - "@algolia/client-insights": "5.59.0", - "@algolia/client-personalization": "5.59.0", - "@algolia/client-query-suggestions": "5.59.0", - "@algolia/client-search": "5.59.0", - "@algolia/ingestion": "1.59.0", - "@algolia/monitoring": "1.59.0", - "@algolia/recommend": "5.59.0", - "@algolia/requester-browser-xhr": "5.59.0", - "@algolia/requester-fetch": "5.59.0", - "@algolia/requester-node-http": "5.59.0" - } - }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "aproba": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", - "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", - "dev": true, - "optional": true - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true - }, - "bignumber.js": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz", - "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==", - "dev": true - }, - "bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "requires": { - "file-uri-to-path": "1.0.0" - } - }, - "birpc": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", - "dev": true - }, - "bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "requires": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - }, - "dependencies": { - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - } - } - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true - }, - "buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "dev": true - }, - "bundle-name": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", - "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", - "dev": true, - "requires": { - "run-applescript": "^7.0.0" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true - }, - "ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true - }, - "chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", - "dev": true, - "requires": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" - } - }, - "chai-subset": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/chai-subset/-/chai-subset-1.6.0.tgz", - "integrity": "sha1-pdDKFOMpp5WW7XAFi2ZGvWmIz+k=", - "dev": true - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "dev": true - }, - "character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "dev": true - }, - "check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "dev": true, - "requires": { - "get-func-name": "^2.0.2" - } - }, - "chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true - }, - "clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "optional": true - }, - "cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "dependencies": { - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, - "optional": true - }, - "colorette": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", - "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", - "dev": true - }, - "comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true - }, - "commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "dev": true - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "dev": true, - "optional": true - }, - "copy-anything": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.5.tgz", - "integrity": "sha512-vePst2VLTSqPglW7Q98VwFpGiGpOHTyXIo81MO5VyLecA5zMbZw/P/V1vmwD1jksa2iJpn/YkDrJ3XhTfzUyhQ==", - "dev": true - }, - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true - }, - "cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true - }, - "db-errors": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/db-errors/-/db-errors-0.2.3.tgz", - "integrity": "sha512-OOgqgDuCavHXjYSJoV2yGhv6SeG8nk42aoCSoyXLZUH7VwFG27rxbavU1z+VrZbZjphw5UkDQwUlD21MwZpUng==" - }, - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true - }, - "deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", - "dev": true, - "requires": { - "type-detect": "^4.0.0" - } - }, - "deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true - }, - "deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "default-browser": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", - "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", - "dev": true, - "requires": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - } - }, - "default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true - }, - "define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true - }, - "delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "dev": true, - "optional": true - }, - "dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true - }, - "detect-libc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", - "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", - "dev": true - }, - "devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, - "requires": { - "dequal": "^2.0.0" - } - }, - "diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "dev": true - }, - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, - "requires": { - "safe-buffer": "^5.0.1" - } - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "emoji-regex-xs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", - "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", - "dev": true - }, - "encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "optional": true, - "requires": { - "iconv-lite": "^0.6.2" - }, - "dependencies": { - "iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "optional": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - } - } - }, - "end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, - "requires": { - "once": "^1.4.0" - } - }, - "entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true - }, - "env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "optional": true - }, - "err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "optional": true - }, - "esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "requires": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true - }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, - "eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "dev": true, - "requires": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "dependencies": { - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - } - } - }, - "eslint-formatter-codeframe": { - "version": "7.32.1", - "resolved": "https://registry.npmjs.org/eslint-formatter-codeframe/-/eslint-formatter-codeframe-7.32.1.tgz", - "integrity": "sha512-DK/3Q3+zVKq/7PdSYiCxPrsDF8H/TRMK5n8Hziwr4IMkMy+XiKSwbpj25AdajS63I/B61Snetq4uVvX9fOLyAg==", - "dev": true, - "requires": { - "@babel/code-frame": "7.12.11", - "chalk": "^4.0.0" - }, - "dependencies": { - "@babel/code-frame": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", - "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", - "dev": true, - "requires": { - "@babel/highlight": "^7.10.4" - } - } - } - }, - "eslint-plugin-prettier": { - "version": "5.5.6", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", - "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", - "dev": true, - "requires": { - "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.13" - } - }, - "eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - } - }, - "eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true - }, - "esm": { - "version": "3.2.25", - "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", - "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", - "dev": true - }, - "espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "requires": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - } - }, - "esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "requires": { - "estraverse": "^5.1.0" - } - }, - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "requires": { - "estraverse": "^5.2.0" - } - }, - "estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true - }, - "estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true - }, - "events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true - }, - "expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true - }, - "expect.js": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/expect.js/-/expect.js-0.3.1.tgz", - "integrity": "sha1-sKWaDS7/VDdUTr8M6qYBWEHQm1s=", - "dev": true - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "fast-uri": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", - "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==" - }, - "fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, - "requires": { - "reusify": "^1.0.4" - } - }, - "file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "requires": { - "flat-cache": "^3.0.4" - } - }, - "file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true - }, - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true - }, - "flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "requires": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "dependencies": { - "json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "requires": { - "json-buffer": "3.0.1" - } - } - } - }, - "flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true - }, - "focus-trap": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", - "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", - "dev": true, - "requires": { - "tabbable": "^6.4.0" - } - }, - "foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "dependencies": { - "signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true - } - } - }, - "fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true - }, - "fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "optional": true - }, - "function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true - }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true - }, - "get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true - }, - "get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true - }, - "getopts": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", - "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==", - "dev": true - }, - "github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true - }, - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "requires": { - "is-glob": "^4.0.3" - } - }, - "globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - } - }, - "graceful-fs": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", - "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==", - "dev": true, - "optional": true - }, - "graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "dev": true, - "optional": true - }, - "hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "dev": true, - "requires": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - } - }, - "hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dev": true, - "requires": { - "@types/hast": "^3.0.0" - } - }, - "he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true - }, - "hookable": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "dev": true - }, - "html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "dev": true - }, - "http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "dev": true, - "optional": true - }, - "http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, - "optional": true, - "requires": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - } - }, - "https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "optional": true, - "requires": { - "agent-base": "6", - "debug": "4" - } - }, - "humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, - "optional": true, - "requires": { - "ms": "^2.0.0" - } - }, - "iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - }, - "ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true - }, - "indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "optional": true - }, - "infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true, - "optional": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "ini": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz", - "integrity": "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==", - "dev": true - }, - "interpret": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", - "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", - "dev": true - }, - "ip": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", - "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", - "dev": true, - "optional": true - }, - "is-core-module": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.0.tgz", - "integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "requires": { - "is-docker": "^3.0.0" - } - }, - "is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true, - "optional": true - }, - "is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true - }, - "is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true - }, - "is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, - "requires": { - "is-inside-container": "^1.0.0" - } - }, - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "requires": { - "@isaacs/cliui": "^8.0.2", - "@pkgjs/parseargs": "^0.11.0" - } - }, - "js-md4": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", - "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "dev": true, - "requires": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - } - }, - "jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "dev": true, - "requires": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "dev": true, - "requires": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "knex": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/knex/-/knex-3.3.0.tgz", - "integrity": "sha512-LgWl031hNuLv9Lhxdd9093zULa2aNcoP04Bk29e5NidgRcEr/T0rAr2WYi4BhjTiCDoQiRU56meUE5rppLKZzQ==", - "dev": true, - "requires": { - "colorette": "2.0.19", - "commander": "^10.0.0", - "debug": "4.3.4", - "escalade": "^3.1.1", - "esm": "^3.2.25", - "get-package-type": "^0.1.0", - "getopts": "2.3.0", - "interpret": "^2.2.0", - "lodash": "^4.18.1", - "pg-connection-string": "2.6.2", - "rechoir": "^0.8.0", - "resolve-from": "^5.0.0", - "tarn": "^3.1.0", - "tildify": "2.0.0" - }, - "dependencies": { - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true - } - } - }, - "levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - } - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true - }, - "lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "dev": true - }, - "lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "dev": true - }, - "lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "dev": true - }, - "lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "dev": true - }, - "lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true - }, - "lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "dev": true - }, - "lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true - }, - "log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "requires": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - } - }, - "loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", - "dev": true, - "requires": { - "get-func-name": "^2.0.1" - } - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "optional": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "requires": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "make-fetch-happen": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", - "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "dev": true, - "optional": true, - "requires": { - "agentkeepalive": "^4.1.3", - "cacache": "^15.2.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^6.0.0", - "minipass": "^3.1.3", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^1.3.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.2", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.0.0", - "ssri": "^8.0.0" - }, - "dependencies": { - "agentkeepalive": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", - "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", - "dev": true, - "optional": true, - "requires": { - "humanize-ms": "^1.2.1" - } - }, - "cacache": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", - "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "dev": true, - "optional": true, - "requires": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - } - }, - "chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "optional": true - }, - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "optional": true - }, - "p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "optional": true, - "requires": { - "aggregate-error": "^3.0.0" - } - }, - "ssri": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", - "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^3.1.1" - } - } - } - }, - "mark.js": { - "version": "8.11.1", - "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", - "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", - "dev": true - }, - "mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "dev": true, - "requires": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - } - }, - "micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "dev": true, - "requires": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "dev": true - }, - "micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "dev": true, - "requires": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "dev": true - }, - "micromark-util-types": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", - "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", - "dev": true - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", - "dev": true - }, - "minipass": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.3.tgz", - "integrity": "sha512-N0BOsdFAlNRfmwMhjAsLVWOk7Ljmeb39iqFlsV1At+jqRhSUP9yeof8FyJu4imaJiSUp8vQebWD/guZwGQC8iA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-fetch": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", - "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "dev": true, - "optional": true, - "requires": { - "encoding": "^0.1.12", - "minipass": "^3.1.0", - "minipass-sized": "^1.0.3", - "minizlib": "^2.0.0" - } - }, - "minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minisearch": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", - "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", - "dev": true - }, - "minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - } - }, - "mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true - }, - "mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true - }, - "mocha": { - "version": "11.8.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz", - "integrity": "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==", - "dev": true, - "requires": { - "browser-stdout": "^1.3.1", - "chokidar": "^4.0.1", - "debug": "^4.3.5", - "diff": "^7.0.0", - "escape-string-regexp": "^4.0.0", - "find-up": "^5.0.0", - "glob": "^10.4.5", - "he": "^1.2.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^9.0.5", - "ms": "^2.1.3", - "picocolors": "^1.1.1", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", - "supports-color": "^8.1.1", - "workerpool": "^9.2.0", - "yargs": "^17.7.2", - "yargs-parser": "^21.1.1", - "yargs-unparser": "^2.0.0" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "requires": { - "readdirp": "^4.0.1" - } - }, - "debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "requires": { - "ms": "^2.1.3" - } - }, - "glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "requires": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - } - }, - "minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - }, - "minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true - }, - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "mysql": { - "version": "2.18.1", - "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz", - "integrity": "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==", - "dev": true, - "requires": { - "bignumber.js": "9.0.0", - "readable-stream": "2.3.7", - "safe-buffer": "5.1.2", - "sqlstring": "2.3.1" - } - }, - "nanoid": { - "version": "3.3.20", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", - "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", - "dev": true - }, - "napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", - "dev": true - }, - "native-duplexpair": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", - "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", - "dev": true - }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true - }, - "negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "optional": true - }, - "node-abi": { - "version": "3.54.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.54.0.tgz", - "integrity": "sha512-p7eGEiQil0YUV3ItH4/tBb781L5impVmmx2E9FRKF7d18XXzp4PGT2tdYMFY6wQqgxD0IwNZOiSJ0/K0fSi/OA==", - "dev": true, - "requires": { - "semver": "^7.3.5" - } - }, - "node-addon-api": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.0.tgz", - "integrity": "sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==", - "dev": true - }, - "node-gyp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", - "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", - "dev": true, - "optional": true, - "requires": { - "env-paths": "^2.2.0", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^9.1.0", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - }, - "dependencies": { - "are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "dev": true, - "optional": true, - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - } - }, - "gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "dev": true, - "optional": true, - "requires": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - } - }, - "npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "dev": true, - "optional": true, - "requires": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - } - }, - "readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "optional": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - } - } - }, - "nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "dev": true, - "optional": true, - "requires": { - "abbrev": "1" - } - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "oniguruma-to-es": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", - "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", - "dev": true, - "requires": { - "emoji-regex-xs": "^1.0.0", - "regex": "^6.0.1", - "regex-recursion": "^6.0.2" - } - }, - "open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "dev": true, - "requires": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - } - }, - "optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dev": true, - "requires": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - } - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - }, - "package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "requires": { - "callsites": "^3.0.0" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "requires": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "dependencies": { - "lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true - } - } - }, - "pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true - }, - "perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true - }, - "pg": { - "version": "8.23.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", - "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", - "dev": true, - "requires": { - "pg-cloudflare": "^1.4.1", - "pg-connection-string": "^2.14.1", - "pg-pool": "^3.14.0", - "pg-protocol": "^1.16.1", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "dependencies": { - "pg-connection-string": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", - "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", - "dev": true - } - } - }, - "pg-cloudflare": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", - "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", - "dev": true, - "optional": true - }, - "pg-connection-string": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", - "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==", - "dev": true - }, - "pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "dev": true - }, - "pg-pool": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", - "dev": true, - "requires": {} - }, - "pg-protocol": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", - "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", - "dev": true - }, - "pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "dev": true, - "requires": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - } - }, - "pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "dev": true, - "requires": { - "split2": "^4.1.0" - } - }, - "picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true - }, - "postcss": { - "version": "8.5.29", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", - "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", - "dev": true, - "requires": { - "nanoid": "^3.3.19", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.2" - } - }, - "postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "dev": true - }, - "postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=", - "dev": true - }, - "postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "dev": true - }, - "postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dev": true, - "requires": { - "xtend": "^4.0.0" - } - }, - "preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "dev": true, - "requires": {} - }, - "prebuild-install": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", - "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", - "dev": true, - "requires": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - } - }, - "prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true - }, - "prettier": { - "version": "3.9.9", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", - "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", - "dev": true - }, - "prettier-linter-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", - "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", - "dev": true, - "requires": { - "fast-diff": "^1.1.2" - } - }, - "process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true - }, - "process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, - "promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=", - "dev": true, - "optional": true - }, - "promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "optional": true, - "requires": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - } - }, - "property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", - "dev": true - }, - "pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", - "dev": true, - "requires": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true - }, - "randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "requires": { - "safe-buffer": "^5.1.0" - } - }, - "rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "requires": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "dependencies": { - "strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true - } - } - }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "rechoir": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", - "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", - "dev": true, - "requires": { - "resolve": "^1.20.0" - } - }, - "regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", - "dev": true, - "requires": { - "regex-utilities": "^2.3.0" - } - }, - "regex-recursion": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "dev": true, - "requires": { - "regex-utilities": "^2.3.0" - } - }, - "regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "dev": true - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true - }, - "require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" - }, - "resolve": { - "version": "1.22.3", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.3.tgz", - "integrity": "sha512-P8ur/gp/AmbEzjr729bZnLjXK5Z+4P0zhIJgBgzqRih7hL7BOukHGtSTA3ACMY467GRFz3duQsi0bDZdR7DKdw==", - "dev": true, - "requires": { - "is-core-module": "^2.12.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - }, - "retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=", - "dev": true, - "optional": true - }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true - }, - "rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "rollup": { - "version": "4.64.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.3.tgz", - "integrity": "sha512-iIMWbptLYsZdGTJMH8ngApXdsCatE6bCMv2eqGIfGE1++kwh7TCWBdWKK0FKC0yx++196vGK5VdagzRXSLMa8A==", - "dev": true, - "requires": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.64.3", - "@rollup/rollup-android-arm64": "4.64.3", - "@rollup/rollup-darwin-arm64": "4.64.3", - "@rollup/rollup-darwin-x64": "4.64.3", - "@rollup/rollup-freebsd-arm64": "4.64.3", - "@rollup/rollup-freebsd-x64": "4.64.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.64.3", - "@rollup/rollup-linux-arm-musleabihf": "4.64.3", - "@rollup/rollup-linux-arm64-gnu": "4.64.3", - "@rollup/rollup-linux-arm64-musl": "4.64.3", - "@rollup/rollup-linux-loong64-gnu": "4.64.3", - "@rollup/rollup-linux-loong64-musl": "4.64.3", - "@rollup/rollup-linux-ppc64-gnu": "4.64.3", - "@rollup/rollup-linux-ppc64-musl": "4.64.3", - "@rollup/rollup-linux-riscv64-gnu": "4.64.3", - "@rollup/rollup-linux-riscv64-musl": "4.64.3", - "@rollup/rollup-linux-s390x-gnu": "4.64.3", - "@rollup/rollup-linux-x64-gnu": "4.64.3", - "@rollup/rollup-linux-x64-musl": "4.64.3", - "@rollup/rollup-openbsd-x64": "4.64.3", - "@rollup/rollup-openharmony-arm64": "4.64.3", - "@rollup/rollup-win32-arm64-msvc": "4.64.3", - "@rollup/rollup-win32-ia32-msvc": "4.64.3", - "@rollup/rollup-win32-x64-gnu": "4.64.3", - "@rollup/rollup-win32-x64-msvc": "4.64.3", - "@types/estree": "1.0.9", - "fsevents": "~2.3.2" - } - }, - "run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true - }, - "run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, - "search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "dev": true, - "peer": true - }, - "semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true - }, - "serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "requires": { - "randombytes": "^2.1.0" - } - }, - "set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", - "dev": true, - "optional": true - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, - "shiki": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", - "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", - "dev": true, - "requires": { - "@shikijs/core": "2.5.0", - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/langs": "2.5.0", - "@shikijs/themes": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "optional": true - }, - "simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true - }, - "simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, - "requires": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - }, - "dependencies": { - "decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "requires": { - "mimic-response": "^3.1.0" - } - }, - "mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true - } - } - }, - "smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "optional": true - }, - "socks": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz", - "integrity": "sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA==", - "dev": true, - "optional": true, - "requires": { - "ip": "^1.1.5", - "smart-buffer": "^4.2.0" - } - }, - "socks-proxy-agent": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", - "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "dev": true, - "optional": true, - "requires": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - } - }, - "source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true - }, - "space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true - }, - "speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "dev": true - }, - "split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true - }, - "sqlite3": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", - "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", - "dev": true, - "requires": { - "bindings": "^1.5.0", - "node-addon-api": "^7.0.0", - "node-gyp": "8.x", - "prebuild-install": "^7.1.1", - "tar": "^6.1.11" - } - }, - "sqlstring": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz", - "integrity": "sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A=", - "dev": true - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.0" - } - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "string-width-cjs": { - "version": "npm:string-width@4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dev": true, - "requires": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "strip-ansi-cjs": { - "version": "npm:strip-ansi@6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true - }, - "superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "dev": true, - "requires": { - "copy-anything": "^4" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true - }, - "synckit": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", - "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", - "dev": true, - "requires": { - "@pkgr/core": "^0.3.6" - } - }, - "tabbable": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", - "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", - "dev": true - }, - "tar": { - "version": "6.1.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", - "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", - "dev": true, - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^3.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "dependencies": { - "chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true - }, - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true - } - } - }, - "tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "dev": true, - "requires": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "requires": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "dependencies": { - "readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - } - } - }, - "tarn": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.1.2.tgz", - "integrity": "sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg==", - "dev": true - }, - "tedious": { - "version": "20.3.7", - "resolved": "https://registry.npmjs.org/tedious/-/tedious-20.3.7.tgz", - "integrity": "sha512-s+43djAcQGFiFHZPWtZPaDSZE/ukJnultjeJHXvKbM//OcZSSusSzXuqAIyOKpraNTWgtobhuWOvxrZK+a1yvA==", - "dev": true, - "requires": { - "@azure/core-auth": "^1.10.1", - "@azure/identity": "^4.13.1", - "@azure/keyvault-keys": "^4.10.2", - "@js-joda/core": "^6.0.1", - "@types/node": ">=22", - "bl": "^6.1.4", - "iconv-lite": "^0.7.0", - "js-md4": "^0.3.2", - "native-duplexpair": "^1.0.0" - }, - "dependencies": { - "bl": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", - "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", - "dev": true, - "requires": { - "@types/readable-stream": "^4.0.0", - "buffer": "^6.0.3", - "inherits": "^2.0.4", - "readable-stream": "^4.2.0" - } - }, - "readable-stream": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", - "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", - "dev": true, - "requires": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true - }, - "tildify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", - "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", - "dev": true - }, - "trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true - }, - "tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true - }, - "tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "requires": { - "safe-buffer": "^5.0.1" - } - }, - "type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1" - } - }, - "type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true - }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - }, - "typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true - }, - "undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true - }, - "unique-filename": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", - "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "dev": true, - "optional": true, - "requires": { - "unique-slug": "^2.0.0" - } - }, - "unique-slug": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", - "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "dev": true, - "optional": true, - "requires": { - "imurmurhash": "^0.1.4" - } - }, - "unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "dev": true, - "requires": { - "@types/unist": "^3.0.0" - } - }, - "unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, - "requires": { - "@types/unist": "^3.0.0" - } - }, - "unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "requires": { - "@types/unist": "^3.0.0" - } - }, - "unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "dev": true, - "requires": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - } - }, - "unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "dev": true, - "requires": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - } - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true - }, - "vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "dev": true, - "requires": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - } - }, - "vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "dev": true, - "requires": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - } - }, - "vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "requires": { - "esbuild": "^0.21.3", - "fsevents": "~2.3.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - } - }, - "vitepress": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", - "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", - "dev": true, - "requires": { - "@docsearch/css": "3.8.2", - "@docsearch/js": "3.8.2", - "@iconify-json/simple-icons": "^1.2.21", - "@shikijs/core": "^2.1.0", - "@shikijs/transformers": "^2.1.0", - "@shikijs/types": "^2.1.0", - "@types/markdown-it": "^14.1.2", - "@vitejs/plugin-vue": "^5.2.1", - "@vue/devtools-api": "^7.7.0", - "@vue/shared": "^3.5.13", - "@vueuse/core": "^12.4.0", - "@vueuse/integrations": "^12.4.0", - "focus-trap": "^7.6.4", - "mark.js": "8.11.1", - "minisearch": "^7.1.1", - "shiki": "^2.1.0", - "vite": "^5.4.14", - "vue": "^3.5.13" - } - }, - "vue": { - "version": "3.5.43", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", - "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", - "dev": true, - "requires": { - "@vue/compiler-dom": "3.5.43", - "@vue/compiler-sfc": "3.5.43", - "@vue/runtime-dom": "3.5.43", - "@vue/server-renderer": "3.5.43", - "@vue/shared": "3.5.43" - } - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dev": true, - "optional": true, - "requires": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "workerpool": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", - "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", - "dev": true - }, - "wrap-ansi-cjs": { - "version": "npm:wrap-ansi@7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "dev": true, - "requires": { - "is-wsl": "^3.1.0" - } - }, - "xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "requires": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "dependencies": { - "y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true - } - } - }, - "yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true - }, - "yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "requires": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "dependencies": { - "is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true - } - } - }, - "yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true - }, - "zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true - } - } -} diff --git a/package.json b/package.json index 04ff3a4ec..2b339fd19 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,26 @@ "name": "objection", "version": "3.9.0", "description": "An SQL-friendly ORM for Node.js", + "type": "module", "main": "lib/objection.js", + "exports": { + ".": { + "types": "./typings/objection/index.d.ts", + "default": "./lib/objection.js" + }, + "./package.json": "./package.json" + }, "license": "MIT", + "packageManager": "pnpm@12.9.0", "scripts": { - "test": "npm run eslint && mocha --slow 10 --timeout 15000 --reporter spec --recursive tests --exclude \"tests/unit/relations/files/**\" && npm run test:typings", - "test:fast": "mocha --slow 10 --timeout 15000 --reporter spec --recursive tests --bail --exclude \"tests/unit/relations/files/**\"", - "test:typings": "tsc && prettier --check \"typings/**/*.ts\" \"tests/ts/**/*.ts\"", + "test": "pnpm eslint && vitest run && pnpm test:typings", + "test:fast": "vitest run --bail 1", + "test:watch": "vitest", + "test:typings": "tsc && tsc -p tests/ts/strict && tsc -p tests/bench/typings && prettier --check \"typings/**/*.ts\" \"tests/ts/**/*.ts\" \"tests/bench/typings/app/*.ts\"", + "bench:typings": "node tests/bench/typings/bench.js", "db:up": "docker compose up -d --wait", "db:down": "docker compose --profile mssql down -v", - "build:parsers": "npx --yes peggy@5.1.0 --format commonjs -o lib/queryBuilder/parsers/jsonFieldExpressionParser.js lib/queryBuilder/parsers/jsonFieldExpressionParser.pegjs && npx --yes peggy@5.1.0 --format commonjs -o lib/queryBuilder/parsers/relationExpressionParser.js lib/queryBuilder/parsers/relationExpressionParser.pegjs && prettier --write \"lib/queryBuilder/parsers/*Parser.js\"", + "build:parsers": "peggy --format es -o lib/queryBuilder/parsers/jsonFieldExpressionParser.js lib/queryBuilder/parsers/jsonFieldExpressionParser.pegjs && peggy --format es -o lib/queryBuilder/parsers/relationExpressionParser.js lib/queryBuilder/parsers/relationExpressionParser.pegjs && prettier --write \"lib/queryBuilder/parsers/*Parser.js\"", "prettier": "prettier --write \"{examples,lib,tests,testUtils,typings,doc}/**/*.{js,ts}\"", "eslint": "eslint --format codeframe \"examples/**/*.js\" \"lib/**/*.js\" \"tests/**/*.js\" \"testUtils/**/*.js\" \"*.js\"", "docs:dev": "vitepress dev doc", @@ -18,7 +29,7 @@ "docs:preview": "vitepress preview doc" }, "publishConfig": { - "tag": "latest" + "tag": "next" }, "author": { "name": "Sami Koskimäki", @@ -41,7 +52,7 @@ }, "homepage": "https://ditojs.github.io/objection/", "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" }, "keywords": [ "orm", @@ -62,28 +73,25 @@ "types": "./typings/objection/index.d.ts", "dependencies": { "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "db-errors": "^0.2.3" + "ajv-formats": "^3.0.1" }, "peerDependencies": { - "knex": ">=2.3.0 <4" + "knex": "^3.3.0" }, "devDependencies": { "@types/node": "^22.15.3", - "chai": "^4.3.10", - "chai-subset": "^1.6.0", "eslint": "^8.54.0", "eslint-formatter-codeframe": "^7.32.1", "eslint-plugin-prettier": "^5.3.1", - "expect.js": "^0.3.1", "knex": "^3.3.0", - "mocha": "^11.2.2", "mysql": "^2.18.1", + "peggy": "5.1.0", "pg": "^8.15.6", "prettier": "3.9.9", - "sqlite3": "^5.1.7", + "sqlite3": "^6.0.1", "tedious": "^20.3.7", - "typescript": "^5.8.3", - "vitepress": "1.6.4" + "typescript": "^7.0.0", + "vitepress": "1.6.4", + "vitest": "^4.1.11" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 000000000..b6d60fe9f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5180 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.9.0 + version: 12.9.0 + +packages: + + '@pnpm/exe.android-arm64@12.9.0': + resolution: {integrity: sha512-/53fHFwP1mBYQjli6h3LBZsocAMjrEY6hCDiomIwdLIFbtxHrWucPg/s8ozOG3K6Qt8yEA78v7fCcNrgZKTlEQ==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.9.0': + resolution: {integrity: sha512-QI+Md3q3Df0WbDGpqciRw9ge0r7CGbxSlP3HcyIf34/YdZsbmUTl416oWOe3RrkZpVVVCA20f8B9esYrITpArA==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.9.0': + resolution: {integrity: sha512-mWr7fGPYJ12GuvU/q0SyJfj7X/9Eqa5dA6cc+yUUAKRdYEcqW4lQcsDd1ArfxJNSDYooz9X+UhFeVxKO9e/xDA==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.9.0': + resolution: {integrity: sha512-fr4ns4FyMLptdYbWqW3yc/yvbfQP7D1RBCFUJpSPr7tOzd3JxZvl+Tkvg65kEAGU5wXI6vl3eCOofumEj/n3Gw==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.9.0': + resolution: {integrity: sha512-4+hZPeJzSSw1oOrfUg+v4QylHv6qKhdgcCnKt9ooryE79p1Z9oyozVJ5em+g6ajbvv6jlH+epnht8/s9Cp5M3Q==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.9.0': + resolution: {integrity: sha512-+mHQSbA6M6W71PyV0IxYu2T43lZA8rAhlw4Gy7EMlyiv8H2GWJAXtUXl2huw7tnsar2AhCRlPn/ZvUv+/mIBkA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.9.0': + resolution: {integrity: sha512-U1mBoG27+J/HuWYtVZOQrvbiKnTlDsDVzyroKQqHsJaTHTXi0H5Y+fU1GjUyMcNG5r/EXtbhp9iAwQt64uXTxQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.9.0': + resolution: {integrity: sha512-P5bgei3i6wjlKq6s4PNg/0cvik354upY2wW3YGSfweYTABnAZP2zGzgMc0eO5SHTz934Cbya8Z0YFRdw6MfmCg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.9.0': + resolution: {integrity: sha512-oNx2uOo2gVrRckEvz7V+FKeq9HOF0rKNHcCyxRRHSXEXpmP6ZMMgJ+iU/m/zfSGp6bAcC5mvAE270FhAeIeKhA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.9.0': + resolution: {integrity: sha512-OChHS7hOlqdBMtZqjIB1QoZGNfzLPSsvTa2kYanf3qso5Q+KgnROQ+uV4a0wRGC6sdshSA63+Ui8MCylRy6iwA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.9.0': + resolution: {integrity: sha512-ubZPsDMXS+LEnPa7wZD18E+KTpOAZPt+ms/khfIROt+6Cp0A2kayQA8aIsvV7+jGZD0xoknvaGN/P3v9OisZUg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.9.0': + resolution: {integrity: sha512-ElANYUwQuMfWqMJR4m5L5h9zKjvGKcA8hi2put4J1DMx2smVaNGkGnxkgeIYgMjmF7UFxlZDcL4jItkScgsW4A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.9.0': + resolution: {integrity: sha512-cqnBKZg5e9fFghmYtgJLyZOAsS1EyLeDVJ0qNsjR7hcc+dq1mWRiDo0erqdj2PGoYaVogyF9WejZSJoKvlfXDw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.9.0': + resolution: {integrity: sha512-khqst6QGgHdKs2lEmz0N75qN/EoOcEywNQlRPfBrBrq1SytHra6ro/xViueJkVvDOz9gEO8oYcXTdZeAjErjMg==} + cpu: [x64] + os: [win32] + + pnpm@12.9.0: + resolution: {integrity: sha512-j2TvoLeSmGypiuVoeRvNdyKAfidMTwwdmFPGIRZJXFhBGduYdioqU0OxaE02G5rib90W1VFmbXY1NG0/roMN3Q==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.9.0': + optional: true + + '@pnpm/exe.android-x64@12.9.0': + optional: true + + '@pnpm/exe.darwin-arm64@12.9.0': + optional: true + + '@pnpm/exe.darwin-x64@12.9.0': + optional: true + + '@pnpm/exe.freebsd-x64@12.9.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.9.0': + optional: true + + '@pnpm/exe.linux-arm64@12.9.0': + optional: true + + '@pnpm/exe.linux-ppc64@12.9.0': + optional: true + + '@pnpm/exe.linux-riscv64@12.9.0': + optional: true + + '@pnpm/exe.linux-s390x@12.9.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.9.0': + optional: true + + '@pnpm/exe.linux-x64@12.9.0': + optional: true + + '@pnpm/exe.win32-arm64@12.9.0': + optional: true + + '@pnpm/exe.win32-x64@12.9.0': + optional: true + + pnpm@12.9.0: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.9.0 + '@pnpm/exe.android-x64': 12.9.0 + '@pnpm/exe.darwin-arm64': 12.9.0 + '@pnpm/exe.darwin-x64': 12.9.0 + '@pnpm/exe.freebsd-x64': 12.9.0 + '@pnpm/exe.linux-arm64': 12.9.0 + '@pnpm/exe.linux-arm64-musl': 12.9.0 + '@pnpm/exe.linux-ppc64': 12.9.0 + '@pnpm/exe.linux-riscv64': 12.9.0 + '@pnpm/exe.linux-s390x': 12.9.0 + '@pnpm/exe.linux-x64': 12.9.0 + '@pnpm/exe.linux-x64-musl': 12.9.0 + '@pnpm/exe.win32-arm64': 12.9.0 + '@pnpm/exe.win32-x64': 12.9.0 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + ajv: + specifier: ^8.17.1 + version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) + devDependencies: + '@types/node': + specifier: ^22.15.3 + version: 22.15.24 + eslint: + specifier: ^8.54.0 + version: 8.57.1(supports-color@7.2.0) + eslint-formatter-codeframe: + specifier: ^7.32.1 + version: 7.32.1 + eslint-plugin-prettier: + specifier: ^5.3.1 + version: 5.5.6(eslint@8.57.1(supports-color@7.2.0))(prettier@3.9.9) + knex: + specifier: ^3.3.0 + version: 3.3.0(mysql@2.18.1)(pg@8.23.1)(sqlite3@6.0.1)(supports-color@7.2.0)(tedious@20.3.7(supports-color@7.2.0)) + mysql: + specifier: ^2.18.1 + version: 2.18.1 + peggy: + specifier: 5.1.0 + version: 5.1.0 + pg: + specifier: ^8.15.6 + version: 8.23.1 + prettier: + specifier: 3.9.9 + version: 3.9.9 + sqlite3: + specifier: ^6.0.1 + version: 6.0.1 + tedious: + specifier: ^20.3.7 + version: 20.3.7(supports-color@7.2.0) + typescript: + specifier: ^7.0.0 + version: 7.0.2 + vitepress: + specifier: 1.6.4 + version: 1.6.4(@algolia/client-search@5.59.0)(@types/node@22.15.24)(lightningcss@1.33.0)(postcss@8.5.29)(search-insights@2.17.3)(typescript@7.0.2) + vitest: + specifier: ^4.1.11 + version: 4.1.11(@types/node@22.15.24)(vite@8.3.4(@types/node@22.15.24)(esbuild@0.28.2)) + +packages: + + '@aashutoshrathi/word-wrap@1.2.6': + resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} + engines: {node: '>=0.10.0'} + + '@algolia/abtesting@1.25.0': + resolution: {integrity: sha512-rSTin9Uta23uaewYVQEp8XI9T3iA/zrg0/1G2vhf8oFFDxFL5vybnZ5IQwsVAg4JpKxPX4/WYNKdcfWrZymk7w==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.59.0': + resolution: {integrity: sha512-bm2XN0hCSMYwStSsCBT0/PUB2BDxoyR1Lnub3c392HMEy9bi8PUSW8vR6zltVEKWG2t4PQFWMD5C07bmJxGgPg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.59.0': + resolution: {integrity: sha512-XOFPOTa69WuqHR6c5tMgnUUwwqQgNSzMpxmhrgA9KmxRf8WIqEa0cokHJvohk5CYb7CZx0xeSL6Bk2IUJ7Lv7Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.59.0': + resolution: {integrity: sha512-PC8ipLOYFKRTfIUY1J3FJxS6ryzWziaXmIX9/sNMoUR8L+XhF7hX2QAeUI87YVl5zujvlYTSOb+HAIqKXDjyHQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.59.0': + resolution: {integrity: sha512-yFNcCMM5fHiyoR0HuxMrzy+VjDcmhFUZTm2IJ2DHwGsVH3B5SEob4zTmeEZ3j/AZqNnmrJOCKl/tJnBg7+84bA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.59.0': + resolution: {integrity: sha512-GYja6HkDt2VrQhWmB2cLx3Z5fDwI9no7q+xwCWcFrTPm5CLH9QZvK0XLO9co1FcNIDxxkMaP3AXM39/XcecEOg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.59.0': + resolution: {integrity: sha512-Wofg7bMpWh8N5qDDZs0wy6whc+KMmdNsxgrIGp9Ug2s1Bka0uq7AjyckdxReKPHLA0Q1qH8X/SNh0wn2t2X2Lw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.59.0': + resolution: {integrity: sha512-fHnALZfbEnODczGk14Y/1YBRApp6UEpZUTexGcMUPzY7RDc7q4HN2Y6jh0KUG8Jo9B+q+wOoQEc3ziR0ErbuFg==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.59.0': + resolution: {integrity: sha512-Fa38s1mHgoaLCT117sfJ6P78rtxUt93CYxBYpq1VOIcslaF+cH+1h5uikAPsKfxLnsuEDZHiBiaI6eNPTsQMRA==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.59.0': + resolution: {integrity: sha512-NyNsRSqM2tF1MX7ZGw/j4rduoEJiQ5wfvbSo/CFVdEzYCjyxbGFRPOZyS/GETJG9rk1TdHOq8NZqKqk/u1fm4g==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.59.0': + resolution: {integrity: sha512-nXBK2uygWtvbCOffMWqqfjjcEtp9enDKY5/2Pw/Hhw8VVaOyK2ThbGK04bNX/Le+qoK1VHYkodWGVTKfw0Otkw==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.59.0': + resolution: {integrity: sha512-yb+4afX/zja8QwX0KmV4/ae2kyYkRknsE79CRusYS2U5D8qwn2wq0cn1x12f3tm3F3+5FgmDdbuGdQTuCLSKMQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.59.0': + resolution: {integrity: sha512-Lp52TmpA1QtNmdHzs505Xwf4tgII7RAapkDSF9AAtWIBETcGaTIaXci4Rd9nS6SdaWK1BWTSqAPp5wzh6UN3Ag==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.59.0': + resolution: {integrity: sha512-YYHLEs5rC6oRTFwT7bJaKBR9NSFzikeVHGAT1ffATmUQzR8XqyhHXjoieAUoR7fBU6I5cfy1FVeh5GoRgriuzA==} + engines: {node: '>= 14.0.0'} + + '@azure-rest/core-client@2.9.0': + resolution: {integrity: sha512-6933vNLqh06RR7rumnrq3UZIeBtRLeBHDPMrieDPIljiCaMMwt0nRw++nd3TOxiohtasNtm29rC+b/9ZVKCOqg==} + engines: {node: '>=22.0.0'} + + '@azure/abort-controller@2.2.0': + resolution: {integrity: sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==} + engines: {node: '>=22.0.0'} + + '@azure/core-auth@1.11.0': + resolution: {integrity: sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==} + engines: {node: '>=22.0.0'} + + '@azure/core-client@1.11.1': + resolution: {integrity: sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==} + engines: {node: '>=22.0.0'} + + '@azure/core-lro@2.7.2': + resolution: {integrity: sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==} + engines: {node: '>=18.0.0'} + + '@azure/core-paging@1.7.0': + resolution: {integrity: sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g==} + engines: {node: '>=22.0.0'} + + '@azure/core-process@1.0.0': + resolution: {integrity: sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==} + engines: {node: '>=22.0.0'} + + '@azure/core-rest-pipeline@1.25.0': + resolution: {integrity: sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==} + engines: {node: '>=22.0.0'} + + '@azure/core-tracing@1.4.0': + resolution: {integrity: sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==} + engines: {node: '>=22.0.0'} + + '@azure/core-util@1.14.0': + resolution: {integrity: sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==} + engines: {node: '>=22.0.0'} + + '@azure/identity@4.13.3': + resolution: {integrity: sha512-zGQPtqvXPgSA8yfV2CkIQ1qirqk0p9AIVpC5uEkdXQYcKl07QHvyaGYRnZOk0AsQUmxNb4wfkcwY5di8Z5xa9A==} + engines: {node: '>=22.0.0'} + + '@azure/keyvault-common@2.1.0': + resolution: {integrity: sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw==} + engines: {node: '>=20.0.0'} + + '@azure/keyvault-keys@4.10.2': + resolution: {integrity: sha512-VmUSLbXRAbSzDD8grXHGPaknYs0SKr3yuf6U+d4XMpX4XuVYskNqbTTwXce0zR1LyxfTZm9rWEBcvs3vdYwCmQ==} + engines: {node: '>=20.0.0'} + + '@azure/logger@1.4.0': + resolution: {integrity: sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==} + engines: {node: '>=22.0.0'} + + '@azure/msal-browser@5.25.0': + resolution: {integrity: sha512-7GyXzDUrdLG5zPDCYJbQdJqbl9a2U9SWUSC4lxr7cXjkBq6diTfWcJsi0UjdGWlqy+EevQg9dSC4g3s3BEp19g==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.14.1': + resolution: {integrity: sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.14.2': + resolution: {integrity: sha512-HOBR0Gfo39TMrkxNG8k6cfjRkrRjXZc3JQs3ebMWQc9S4cihuknMYra3sgmzbZB752oQBqBN9+WF7c826p2vIQ==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@6.0.1': + resolution: {integrity: sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==} + engines: {node: '>=20'} + + '@babel/code-frame@7.12.11': + resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/highlight@7.24.7': + resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.4.0': + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.11.1': + resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@iconify-json/simple-icons@1.2.99': + resolution: {integrity: sha512-kouZhqvUoNdxEe+lZksbJJZMCZv4b4Ob62O1GW3fcpz/I0mVPzM+9/Qf/DUuv5ns8l6c46D8M/ubhJjjbLp9HQ==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@js-joda/core@6.3.0': + resolution: {integrity: sha512-mdmjc6lJkTCORsHlMxBT9XM1yLqgtsNZmv/Hwme2v/HIb15D2XcmklZKTnynqBgsSizA9SkFvzpuksfIXhDV/w==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + + '@peggyjs/from-mem@3.1.3': + resolution: {integrity: sha512-LLlgtfXIaeYXoOYovOI0spLM8ZXaqkAlmcRRrLzHJzLMqkU6Sw0R4KMoCoHx1PjaP815pSCBlS+BN6aD8t1Jgg==} + engines: {node: '>=20.8'} + + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.64.3': + resolution: {integrity: sha512-a31GFbL70GlGUGhn8goVjRLtdDG1urLCJFWLZ7JcxdHK9pPvCohLFtYLgJDdNbtrNZSrJl1u9bK05ISsSC2EpA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.64.3': + resolution: {integrity: sha512-IRv757RLiPGlNxAX5JXU0eiUlUPvKiwFRbkqLd+gywptLK4uSEg1qkl6en6r0cXMazSnWUWOBABv+QfuLV3n5Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.64.3': + resolution: {integrity: sha512-UkzI35Fnz6+MJNATdnjnZ3TpdG/rXab8h7oDOD9R0zDVIFmuuYrvgUj8V8mh+A//+LR4TlEQo4ZW4RhyRtWv3A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.64.3': + resolution: {integrity: sha512-C3AyMtAnKQ/jSkZ0chIoWbPXRbFz8JnPLhl1ULecEhcaOGkT19xt7KmO8BulQxt9S/XideHRXvsVQUbGj4JCOg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.64.3': + resolution: {integrity: sha512-M8yAdBMyHh14JLeXDNGvEkljzuHdcoLBQ399O4Fp4nvX51lueuEY/5U2G9Ci0D6/bQlBp/3JLiBQH55STwwbwg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.64.3': + resolution: {integrity: sha512-WQjuOKGh2FN98ZK6i0uM6M6YqnPEds85p3oRkrxiEeTQo/p7RpuU8c9MwQ88LfgwVAzohumlzbQQSikz4Zw9rw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.64.3': + resolution: {integrity: sha512-5tDLZG5s9WyVKoEHgwos6FpKrgpotl61EYeHSLSVkQzMzTsaClwEr8mok9rm3y734zjiGInG4dfw/56ebWVvEQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.64.3': + resolution: {integrity: sha512-Mh5KbLeplM2HX6lQI18jsxSsqvPW8d+Hyk8HtY6Zl93woMitRSCZ92rBEzq6v8bfZGlpyMgj4P/sKW4KCjhH3A==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.64.3': + resolution: {integrity: sha512-QZrrwHnznTv6y4iiHtCqDN+ZsF4qNFQObMOLgPrbxhh9O5o4rgoUe5OnkqG/ceCObMrAjhQt+eQ6HSDzBFgVMw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.64.3': + resolution: {integrity: sha512-4vLehpXSwlkltnvk5ETB+Hz+d3xayZS3NamZ5Vq4bSo+IUoIALovEJF1YgWPMjHwN7R5o/R4BpP9h3q8EK1xAA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.64.3': + resolution: {integrity: sha512-SEN7TTTOdXJKZmsWKOnQeVfgy3Dje/08ISXWwFhTggQ3SxjuIaChvmOB5ZDZqJNlBjaXrV6nAHraqEAvdLGqcw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.64.3': + resolution: {integrity: sha512-1LDsQMRMCEdGF9Dih5qyh6fF1LK3NnA+pAtE2+9AgZwUdXyd19TOBVC9crSB63iZBHR4vOG7h8gN/LcUH3J7xw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.64.3': + resolution: {integrity: sha512-v3D2tAfRriqGOi+5Ac5CiYbBo2NXtLVxJlludTvUZRj502lVMFeVIuvvWbz4qCrotn3e3Uvpvh7FcMpG2QxxXQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.64.3': + resolution: {integrity: sha512-db5q6dP189V2+JkMpsXBl8ym6bC0x8ydZ47fUJZ9YdM6T2OF5uqNzX9HaAZHRP/lqjA9bJeNwi0qw1/2/37dUA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.64.3': + resolution: {integrity: sha512-nArAVDmrL5ix9UoipcKEsYJw4kMUQCIPYRWuYF2POOC7jd9rmN95d/48ewsYFC/OdtwEx6VYYWYxrp8FbMOU2Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.64.3': + resolution: {integrity: sha512-2dc7LElu7EVSvpQT8epNnBLa02wlqilNYqHN0FA/RaKqCr9U9ZFYQ0Eh4sfgSzLneLDuJsJRjcezR/l2UyYGXA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.64.3': + resolution: {integrity: sha512-9PYq2IEzSwXcvQLERcT9LKXRzw8J05GFTbJHA2anJR2WmlRjA1esgXVYhanp+bmjKsDVoZeU2L7Z2pRClLcZTw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.64.3': + resolution: {integrity: sha512-vMh+mt0cw1BHldd8cqaU6A5s1W4EUKl6+8MRsv86mj954L4xyruKD7qPXMUI2icImadAZARK10h0onaGTLmCVg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.64.3': + resolution: {integrity: sha512-PpiKU+sWbqDGc0U0su8oXiyeyqm0UQjmaaPKHktX+DaLe6YKJwW2YVD0f2AXHdVvblZbRqpwRVClWP2F5HCqrQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.64.3': + resolution: {integrity: sha512-/3enY/AmaIt8IS0fWjX4ztYVPBqkj49cQTy/hCryhBFaEzDfJwwEolq9sxvyWAM2qmsmVuPdeRg5JXSrmeqvJg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.64.3': + resolution: {integrity: sha512-zsEP/QDuet52hhwvi5T1VPg0/Tkms0m3zkVv7CPsznvhdO4pL4oJTMz26EJsiCe18OeLKPgpTu3XVO3C1eq3ew==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.64.3': + resolution: {integrity: sha512-4ESknW4Db+kqvazVOfsReYJaHxcIsOqcgwjKV2J1tG5Bn4RVf5ocTQKSGuq2HWBJcOLuyim8IWX8DuH6nKmELA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.64.3': + resolution: {integrity: sha512-3B8VCQyIyv7FQxYYhb3HpR3bIpronR20txjMSffSoz/sm/DDs3/49CFpEueOsvq9qW9sWoI/6pi6mtciD9NXFg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.64.3': + resolution: {integrity: sha512-YOoDkKh2oQxOxRB5D9AR8sj3qpMShpmyTkjojiBjMGigz3caV7UvCnXWWUQC0TBmrZ5zuHe2pWl8S+cMfvXzRg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.64.3': + resolution: {integrity: sha512-qTv4bG+eKJKifzxNJevMUh8L6CjJrz9/Kn1IbP2F5bo0tBzNIR7HMg4vni6XmDzcKUl00lHHY+J5ZgjbGiujOQ==} + cpu: [x64] + os: [win32] + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.2.0': + resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/node@22.15.24': + resolution: {integrity: sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng==} + + '@types/readable-stream@4.0.25': + resolution: {integrity: sha512-7GsxL8Wd5X3jh73/qgW7WCnbBvGSdD8U3E4Jiuus3/8I+PN20DtlkJtwFhr/sImXfDDQ2Z03i/9wNiIF3TVSsw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typespec/ts-http-runtime@0.3.9': + resolution: {integrity: sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==} + engines: {node: '>=22.0.0'} + + '@ungap/structured-clone@1.2.0': + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + '@vue/compiler-core@3.5.43': + resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} + + '@vue/compiler-dom@3.5.43': + resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} + + '@vue/compiler-sfc@3.5.43': + resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} + + '@vue/compiler-ssr@3.5.43': + resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/reactivity@3.5.43': + resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} + + '@vue/runtime-core@3.5.43': + resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} + + '@vue/runtime-dom@3.5.43': + resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} + + '@vue/server-renderer@3.5.43': + resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} + + '@vue/shared@3.5.43': + resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.12.1: + resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + algoliasearch@5.59.0: + resolution: {integrity: sha512-wUXzaeI7B526W4y1gFg3lcxgDZ67XSgRJIiellYWOas/pLpO7rOtxm9Gr2E/C8aiSDXIgx1q5TdSsvK67Uakqw==} + engines: {node: '>= 14.0.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.0.0: + resolution: {integrity: sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bl@6.1.6: + resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bundle-name@4.1.1: + resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==} + engines: {node: '>=18'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.19: + resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-anything@4.1.5: + resolution: {integrity: sha512-vePst2VLTSqPglW7Q98VwFpGiGpOHTyXIo81MO5VyLecA5zMbZw/P/V1vmwD1jksa2iJpn/YkDrJ3XhTfzUyhQ==} + engines: {node: '>=18'} + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.1.1: + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-formatter-codeframe@7.32.1: + resolution: {integrity: sha512-DK/3Q3+zVKq/7PdSYiCxPrsDF8H/TRMK5n8Hziwr4IMkMy+XiKSwbpj25AdajS63I/B61Snetq4uVvX9fOLyAg==} + engines: {node: ^10.12.0 || >=12.0.0} + + eslint-plugin-prettier@5.5.6: + resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + esm@3.2.25: + resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} + engines: {node: '>=6'} + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.5.0: + resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.0.1: + resolution: {integrity: sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==} + + fastq@1.15.0: + resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + getopts@2.3.0: + resolution: {integrity: sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has@1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + interpret@2.2.0: + resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} + engines: {node: '>= 0.10'} + + is-core-module@2.12.0: + resolution: {integrity: sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + js-md4@0.3.2: + resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + knex@3.3.0: + resolution: {integrity: sha512-LgWl031hNuLv9Lhxdd9093zULa2aNcoP04Bk29e5NidgRcEr/T0rAr2WYi4BhjTiCDoQiRU56meUE5rppLKZzQ==} + engines: {node: '>=16'} + hasBin: true + peerDependencies: + better-sqlite3: '*' + mariadb: '*' + mysql: '*' + mysql2: '*' + pg: '*' + pg-native: '*' + pg-query-stream: ^4.14.0 + sqlite3: '*' + tedious: '*' + peerDependenciesMeta: + better-sqlite3: + optional: true + mariadb: + optional: true + mysql: + optional: true + mysql2: + optional: true + pg: + optional: true + pg-native: + optional: true + pg-query-stream: + optional: true + sqlite3: + optional: true + tedious: + optional: true + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + mysql@2.18.1: + resolution: {integrity: sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==} + engines: {node: '>= 0.6'} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + native-duplexpair@1.0.0: + resolution: {integrity: sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-abi@3.98.0: + resolution: {integrity: sha512-Llh8noNw7s15m+rmIrZnZQbe4gLyasL9loT74rC92PkmwWKeEedKvc7o6pjp8B3MhrYT/Cl846NOYZbz1wmddw==} + engines: {node: '>=10'} + + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + optionator@0.9.3: + resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + peggy@5.1.0: + resolution: {integrity: sha512-IEo5aYRZ2kXH4Qby06cjtL114PZnwLoTiA41vUmg2vPZgANn+c87m5BUurhuDr5/cu758ZlpgsAfBVx+hhO5+w==} + engines: {node: '>=20'} + hasBin: true + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + pg-cloudflare@1.4.1: + resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} + + pg-connection-string@2.14.1: + resolution: {integrity: sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==} + + pg-connection-string@2.6.2: + resolution: {integrity: sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.1: + resolution: {integrity: sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + preact@10.29.8: + resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} + engines: {node: '>=14'} + hasBin: true + + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@2.3.7: + resolution: {integrity: sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.8.0: + resolution: {integrity: sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve@1.22.3: + resolution: {integrity: sha512-P8ur/gp/AmbEzjr729bZnLjXK5Z+4P0zhIJgBgzqRih7hL7BOukHGtSTA3ACMY467GRFz3duQsi0bDZdR7DKdw==} + hasBin: true + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.64.3: + resolution: {integrity: sha512-iIMWbptLYsZdGTJMH8ngApXdsCatE6bCMv2eqGIfGE1++kwh7TCWBdWKK0FKC0yx++196vGK5VdagzRXSLMa8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + source-map-generator@2.0.6: + resolution: {integrity: sha512-IlassDs1Ve8nV6uyQZXF9kdkJpVKnMte2JZQXu13M0A5zwc+vu6+LNHfmxsHBMDtoZE21RHiKI0/xvpecZRCNg==} + engines: {node: '>=20'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sqlite3@6.0.1: + resolution: {integrity: sha512-X0czUUMG2tmSqJpEQa3tCuZSHKIx8PwM53vLZzKp/o6Rpy25fiVfjdbnZ988M8+O3ZWR1ih0K255VumCb3MAnQ==} + engines: {node: '>=20.17.0'} + + sqlstring@2.3.1: + resolution: {integrity: sha512-ooAzh/7dxIG5+uDik1z/Rd1vli0+38izZhGzSa34FwR7IbelPWCCKSNIl8jlL/F7ERvy8CB2jNeM1E9i9mXMAQ==} + engines: {node: '>= 0.6'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + tarn@3.1.2: + resolution: {integrity: sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg==} + engines: {node: '>=8.0.0'} + + tedious@20.3.7: + resolution: {integrity: sha512-s+43djAcQGFiFHZPWtZPaDSZE/ukJnultjeJHXvKbM//OcZSSusSzXuqAIyOKpraNTWgtobhuWOvxrZK+a1yvA==} + engines: {node: '>=22'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + tildify@2.0.0: + resolution: {integrity: sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==} + engines: {node: '>=8'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@6.29.0: + resolution: {integrity: sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==} + engines: {node: '>=18.17'} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vue@3.5.43: + resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@aashutoshrathi/word-wrap@1.2.6': {} + + '@algolia/abtesting@1.25.0': + dependencies: + '@algolia/client-common': 5.59.0 + '@algolia/requester-browser-xhr': 5.59.0 + '@algolia/requester-fetch': 5.59.0 + '@algolia/requester-node-http': 5.59.0 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.59.0)(algoliasearch@5.59.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.59.0)(algoliasearch@5.59.0)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.59.0)(algoliasearch@5.59.0) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.59.0)(algoliasearch@5.59.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.59.0)(algoliasearch@5.59.0) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.59.0)(algoliasearch@5.59.0)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.59.0)(algoliasearch@5.59.0) + '@algolia/client-search': 5.59.0 + algoliasearch: 5.59.0 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.59.0)(algoliasearch@5.59.0)': + dependencies: + '@algolia/client-search': 5.59.0 + algoliasearch: 5.59.0 + + '@algolia/client-abtesting@5.59.0': + dependencies: + '@algolia/client-common': 5.59.0 + '@algolia/requester-browser-xhr': 5.59.0 + '@algolia/requester-fetch': 5.59.0 + '@algolia/requester-node-http': 5.59.0 + + '@algolia/client-analytics@5.59.0': + dependencies: + '@algolia/client-common': 5.59.0 + '@algolia/requester-browser-xhr': 5.59.0 + '@algolia/requester-fetch': 5.59.0 + '@algolia/requester-node-http': 5.59.0 + + '@algolia/client-common@5.59.0': {} + + '@algolia/client-insights@5.59.0': + dependencies: + '@algolia/client-common': 5.59.0 + '@algolia/requester-browser-xhr': 5.59.0 + '@algolia/requester-fetch': 5.59.0 + '@algolia/requester-node-http': 5.59.0 + + '@algolia/client-personalization@5.59.0': + dependencies: + '@algolia/client-common': 5.59.0 + '@algolia/requester-browser-xhr': 5.59.0 + '@algolia/requester-fetch': 5.59.0 + '@algolia/requester-node-http': 5.59.0 + + '@algolia/client-query-suggestions@5.59.0': + dependencies: + '@algolia/client-common': 5.59.0 + '@algolia/requester-browser-xhr': 5.59.0 + '@algolia/requester-fetch': 5.59.0 + '@algolia/requester-node-http': 5.59.0 + + '@algolia/client-search@5.59.0': + dependencies: + '@algolia/client-common': 5.59.0 + '@algolia/requester-browser-xhr': 5.59.0 + '@algolia/requester-fetch': 5.59.0 + '@algolia/requester-node-http': 5.59.0 + + '@algolia/ingestion@1.59.0': + dependencies: + '@algolia/client-common': 5.59.0 + '@algolia/requester-browser-xhr': 5.59.0 + '@algolia/requester-fetch': 5.59.0 + '@algolia/requester-node-http': 5.59.0 + + '@algolia/monitoring@1.59.0': + dependencies: + '@algolia/client-common': 5.59.0 + '@algolia/requester-browser-xhr': 5.59.0 + '@algolia/requester-fetch': 5.59.0 + '@algolia/requester-node-http': 5.59.0 + + '@algolia/recommend@5.59.0': + dependencies: + '@algolia/client-common': 5.59.0 + '@algolia/requester-browser-xhr': 5.59.0 + '@algolia/requester-fetch': 5.59.0 + '@algolia/requester-node-http': 5.59.0 + + '@algolia/requester-browser-xhr@5.59.0': + dependencies: + '@algolia/client-common': 5.59.0 + + '@algolia/requester-fetch@5.59.0': + dependencies: + '@algolia/client-common': 5.59.0 + + '@algolia/requester-node-http@5.59.0': + dependencies: + '@algolia/client-common': 5.59.0 + + '@azure-rest/core-client@2.9.0(supports-color@7.2.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) + '@azure/core-tracing': 1.4.0 + '@typespec/ts-http-runtime': 0.3.9(supports-color@7.2.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/abort-controller@2.2.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.11.0(supports-color@7.2.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0(supports-color@7.2.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-client@1.11.1(supports-color@7.2.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@7.2.0) + '@azure/logger': 1.4.0(supports-color@7.2.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-lro@2.7.2(supports-color@7.2.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0(supports-color@7.2.0) + '@azure/logger': 1.4.0(supports-color@7.2.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-paging@1.7.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-process@1.0.0': {} + + '@azure/core-rest-pipeline@1.25.0(supports-color@7.2.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@7.2.0) + '@azure/logger': 1.4.0(supports-color@7.2.0) + '@typespec/ts-http-runtime': 0.3.9(supports-color@7.2.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.4.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.14.0(supports-color@7.2.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@typespec/ts-http-runtime': 0.3.9(supports-color@7.2.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/identity@4.13.3(supports-color@7.2.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-client': 1.11.1(supports-color@7.2.0) + '@azure/core-process': 1.0.0 + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@7.2.0) + '@azure/logger': 1.4.0(supports-color@7.2.0) + '@azure/msal-browser': 5.25.0 + '@azure/msal-node': 6.0.1 + open: 10.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/keyvault-common@2.1.0(supports-color@7.2.0)': + dependencies: + '@azure-rest/core-client': 2.9.0(supports-color@7.2.0) + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@7.2.0) + '@azure/logger': 1.4.0(supports-color@7.2.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/keyvault-keys@4.10.2(supports-color@7.2.0)': + dependencies: + '@azure-rest/core-client': 2.9.0(supports-color@7.2.0) + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/core-lro': 2.7.2(supports-color@7.2.0) + '@azure/core-paging': 1.7.0 + '@azure/core-rest-pipeline': 1.25.0(supports-color@7.2.0) + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@7.2.0) + '@azure/keyvault-common': 2.1.0(supports-color@7.2.0) + '@azure/logger': 1.4.0(supports-color@7.2.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/logger@1.4.0(supports-color@7.2.0)': + dependencies: + '@typespec/ts-http-runtime': 0.3.9(supports-color@7.2.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/msal-browser@5.25.0': + dependencies: + '@azure/msal-common': 16.14.2 + + '@azure/msal-common@16.14.1': {} + + '@azure/msal-common@16.14.2': {} + + '@azure/msal-node@6.0.1': + dependencies: + '@azure/msal-common': 16.14.1 + jsonwebtoken: 9.0.3 + + '@babel/code-frame@7.12.11': + dependencies: + '@babel/highlight': 7.24.7 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/highlight@7.24.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.59.0)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.59.0)(search-insights@2.17.3) + preact: 10.29.8 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - preact-render-to-string + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.59.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.59.0)(algoliasearch@5.59.0)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.59.0)(algoliasearch@5.59.0) + '@docsearch/css': 3.8.2 + algoliasearch: 5.59.0 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1(supports-color@7.2.0))': + dependencies: + eslint: 8.57.1(supports-color@7.2.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.11.1': {} + + '@eslint/eslintrc@2.1.4(supports-color@7.2.0)': + dependencies: + ajv: 6.12.6 + debug: 4.3.4(supports-color@7.2.0) + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@humanwhocodes/config-array@0.13.0(supports-color@7.2.0)': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.3.4(supports-color@7.2.0) + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@iconify-json/simple-icons@1.2.99': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@js-joda/core@6.3.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.15.0 + + '@oxc-project/types@0.153.0': {} + + '@peggyjs/from-mem@3.1.3': + dependencies: + semver: 7.7.4 + + '@pkgr/core@0.3.6': {} + + '@rolldown/binding-android-arm-eabi@1.2.13': + optional: true + + '@rolldown/binding-android-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-x64@1.2.13': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.13': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.13': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.13': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.13': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.13': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.64.3': + optional: true + + '@rollup/rollup-android-arm64@4.64.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.64.3': + optional: true + + '@rollup/rollup-darwin-x64@4.64.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.64.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.64.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.64.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.64.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.64.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.64.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.64.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.64.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.64.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.64.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.64.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.64.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.64.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.64.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.64.3': + optional: true + + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.2.0': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/node@22.15.24': + dependencies: + undici-types: 6.21.0 + + '@types/readable-stream@4.0.25': + dependencies: + '@types/node': 22.15.24 + + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@typespec/ts-http-runtime@0.3.9(supports-color@7.2.0)': + dependencies: + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ungap/structured-clone@1.2.0': {} + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@22.15.24)(lightningcss@1.33.0))(vue@3.5.43(typescript@7.0.2))': + dependencies: + vite: 5.4.21(@types/node@22.15.24)(lightningcss@1.33.0) + vue: 3.5.43(typescript@7.0.2) + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.3.0 + tinyrainbow: 3.2.0 + + '@vitest/mocker@4.1.11(vite@8.3.4(@types/node@22.15.24)(esbuild@0.28.2))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.3.4(@types/node@22.15.24)(esbuild@0.28.2) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.2.0 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 + + '@vue/compiler-core@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/shared': 3.5.43 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.2 + + '@vue/compiler-dom@3.5.43': + dependencies: + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.29 + source-map-js: 1.2.2 + + '@vue/compiler-ssr@3.5.43': + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.43': + dependencies: + '@vue/shared': 3.5.43 + + '@vue/runtime-core@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/runtime-dom@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.43': + dependencies: + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/shared@3.5.43': {} + + '@vueuse/core@12.8.2(typescript@7.0.2)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@7.0.2) + vue: 3.5.43(typescript@7.0.2) + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@7.0.2)': + dependencies: + '@vueuse/core': 12.8.2(typescript@7.0.2) + '@vueuse/shared': 12.8.2(typescript@7.0.2) + vue: 3.5.43(typescript@7.0.2) + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2(typescript@7.0.2)': + dependencies: + vue: 3.5.43(typescript@7.0.2) + transitivePeerDependencies: + - typescript + + abbrev@4.0.0: + optional: true + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + acorn-jsx@5.3.2(acorn@8.12.1): + dependencies: + acorn: 8.12.1 + + acorn@8.12.1: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.1 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + algoliasearch@5.59.0: + dependencies: + '@algolia/abtesting': 1.25.0 + '@algolia/client-abtesting': 5.59.0 + '@algolia/client-analytics': 5.59.0 + '@algolia/client-common': 5.59.0 + '@algolia/client-insights': 5.59.0 + '@algolia/client-personalization': 5.59.0 + '@algolia/client-query-suggestions': 5.59.0 + '@algolia/client-search': 5.59.0 + '@algolia/ingestion': 1.59.0 + '@algolia/monitoring': 1.59.0 + '@algolia/recommend': 5.59.0 + '@algolia/requester-browser-xhr': 5.59.0 + '@algolia/requester-fetch': 5.59.0 + '@algolia/requester-node-http': 5.59.0 + + ansi-regex@5.0.1: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + bignumber.js@9.0.0: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + birpc@2.9.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bl@6.1.6: + dependencies: + '@types/readable-stream': 4.0.25 + buffer: 6.0.3 + inherits: 2.0.4 + readable-stream: 4.8.0 + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + buffer-equal-constant-time@1.0.1: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-name@4.1.1: + dependencies: + run-applescript: 7.1.0 + + callsites@3.1.0: {} + + ccount@2.0.1: {} + + chai@6.3.0: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + chownr@1.1.4: {} + + chownr@3.0.0: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + colorette@2.0.19: {} + + comma-separated-tokens@2.0.3: {} + + commander@10.0.1: {} + + commander@14.0.3: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + copy-anything@4.1.5: {} + + core-util-is@1.0.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.3.4(supports-color@7.2.0): + dependencies: + ms: 2.1.2 + optionalDependencies: + supports-color: 7.2.0 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + deep-is@0.1.4: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.1: + dependencies: + bundle-name: 4.1.1 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + emoji-regex-xs@1.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + entities@7.0.1: {} + + env-paths@2.2.1: + optional: true + + es-module-lexer@2.3.2: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + optional: true + + escalade@3.1.1: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + eslint-formatter-codeframe@7.32.1: + dependencies: + '@babel/code-frame': 7.12.11 + chalk: 4.1.2 + + eslint-plugin-prettier@5.5.6(eslint@8.57.1(supports-color@7.2.0))(prettier@3.9.9): + dependencies: + eslint: 8.57.1(supports-color@7.2.0) + prettier: 3.9.9 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.13 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1(supports-color@7.2.0): + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1(supports-color@7.2.0)) + '@eslint-community/regexpp': 4.11.1 + '@eslint/eslintrc': 2.1.4(supports-color@7.2.0) + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0(supports-color@7.2.0) + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.2.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.3.4(supports-color@7.2.0) + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.5.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.3 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + esm@3.2.25: {} + + espree@9.6.1: + dependencies: + acorn: 8.12.1 + acorn-jsx: 5.3.2(acorn@8.12.1) + eslint-visitor-keys: 3.4.3 + + esquery@1.5.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + event-target-shim@5.0.1: {} + + events@3.3.0: {} + + expand-template@2.0.3: {} + + expect-type@1.4.0: {} + + exponential-backoff@3.1.3: + optional: true + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.0.1: {} + + fastq@1.15.0: + dependencies: + reusify: 1.0.4 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + file-uri-to-path@1.0.0: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.1: {} + + focus-trap@7.8.0: + dependencies: + tabbable: 6.5.0 + + fs-constants@1.0.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-package-type@0.1.0: {} + + getopts@2.3.0: {} + + github-from-package@0.0.0: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + graceful-fs@4.2.11: + optional: true + + graphemer@1.4.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has@1.0.3: + dependencies: + function-bind: 1.1.2 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hookable@5.5.3: {} + + html-void-elements@3.0.0: {} + + http-proxy-agent@7.0.2(supports-color@7.2.0): + dependencies: + agent-base: 7.1.4 + debug: 4.3.4(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6(supports-color@7.2.0): + dependencies: + agent-base: 7.1.4 + debug: 4.3.4(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + interpret@2.2.0: {} + + is-core-module@2.12.0: + dependencies: + has: 1.0.3 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-path-inside@3.0.3: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + isexe@4.0.0: + optional: true + + js-md4@0.3.2: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.2 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + knex@3.3.0(mysql@2.18.1)(pg@8.23.1)(sqlite3@6.0.1)(supports-color@7.2.0)(tedious@20.3.7(supports-color@7.2.0)): + dependencies: + colorette: 2.0.19 + commander: 10.0.1 + debug: 4.3.4(supports-color@7.2.0) + escalade: 3.1.1 + esm: 3.2.25 + get-package-type: 0.1.0 + getopts: 2.3.0 + interpret: 2.2.0 + lodash: 4.18.1 + pg-connection-string: 2.6.2 + rechoir: 0.8.0 + resolve-from: 5.0.0 + tarn: 3.1.2 + tildify: 2.0.0 + optionalDependencies: + mysql: 2.18.1 + pg: 8.23.1 + sqlite3: 6.0.1 + tedious: 20.3.7(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash@4.18.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + mark.js@8.11.1: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.2.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + mimic-response@3.1.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minisearch@7.2.0: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mitt@3.0.1: {} + + mkdirp-classic@0.5.3: {} + + ms@2.1.2: {} + + mysql@2.18.1: + dependencies: + bignumber.js: 9.0.0 + readable-stream: 2.3.7 + safe-buffer: 5.1.2 + sqlstring: 2.3.1 + + nanoid@3.3.20: {} + + napi-build-utils@2.0.0: {} + + native-duplexpair@1.0.0: {} + + natural-compare@1.4.0: {} + + node-abi@3.98.0: + dependencies: + semver: 7.8.5 + + node-addon-api@8.9.2: {} + + node-gyp@12.4.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.8.5 + tar: 7.5.22 + tinyglobby: 0.2.17 + undici: 6.29.0 + which: 6.0.1 + optional: true + + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + optional: true + + obug@2.2.1: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + + open@10.2.0: + dependencies: + default-browser: 5.5.1 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + optionator@0.9.3: + dependencies: + '@aashutoshrathi/word-wrap': 1.2.6 + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + pathe@2.0.3: {} + + peggy@5.1.0: + dependencies: + '@peggyjs/from-mem': 3.1.3 + commander: 14.0.3 + source-map-generator: 2.0.6 + + perfect-debounce@1.0.0: {} + + pg-cloudflare@1.4.1: + optional: true + + pg-connection-string@2.14.1: {} + + pg-connection-string@2.6.2: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.1): + dependencies: + pg: 8.23.1 + + pg-protocol@1.16.1: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.1: + dependencies: + pg-connection-string: 2.14.1 + pg-pool: 3.14.0(pg@8.23.1) + pg-protocol: 1.16.1 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.1 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.0: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + preact@10.29.8: {} + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.98.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier@3.9.9: {} + + proc-log@6.1.0: + optional: true + + process-nextick-args@2.0.1: {} + + process@0.11.10: {} + + property-information@7.2.0: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@2.3.7: + dependencies: + core-util-is: 1.0.2 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.8.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + rechoir@0.8.0: + dependencies: + resolve: 1.22.3 + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve@1.22.3: + dependencies: + is-core-module: 2.12.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.0.4: {} + + rfdc@1.4.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rolldown@1.2.13: + dependencies: + '@oxc-project/types': 0.153.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 + + rollup@4.64.3: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.64.3 + '@rollup/rollup-android-arm64': 4.64.3 + '@rollup/rollup-darwin-arm64': 4.64.3 + '@rollup/rollup-darwin-x64': 4.64.3 + '@rollup/rollup-freebsd-arm64': 4.64.3 + '@rollup/rollup-freebsd-x64': 4.64.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.64.3 + '@rollup/rollup-linux-arm-musleabihf': 4.64.3 + '@rollup/rollup-linux-arm64-gnu': 4.64.3 + '@rollup/rollup-linux-arm64-musl': 4.64.3 + '@rollup/rollup-linux-loong64-gnu': 4.64.3 + '@rollup/rollup-linux-loong64-musl': 4.64.3 + '@rollup/rollup-linux-ppc64-gnu': 4.64.3 + '@rollup/rollup-linux-ppc64-musl': 4.64.3 + '@rollup/rollup-linux-riscv64-gnu': 4.64.3 + '@rollup/rollup-linux-riscv64-musl': 4.64.3 + '@rollup/rollup-linux-s390x-gnu': 4.64.3 + '@rollup/rollup-linux-x64-gnu': 4.64.3 + '@rollup/rollup-linux-x64-musl': 4.64.3 + '@rollup/rollup-openbsd-x64': 4.64.3 + '@rollup/rollup-openharmony-arm64': 4.64.3 + '@rollup/rollup-win32-arm64-msvc': 4.64.3 + '@rollup/rollup-win32-ia32-msvc': 4.64.3 + '@rollup/rollup-win32-x64-gnu': 4.64.3 + '@rollup/rollup-win32-x64-msvc': 4.64.3 + fsevents: 2.3.3 + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + search-insights@2.17.3: {} + + semver@7.7.4: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + siginfo@2.0.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + source-map-generator@2.0.6: {} + + source-map-js@1.2.2: {} + + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + + split2@4.2.0: {} + + sqlite3@6.0.1: + dependencies: + bindings: 1.5.0 + node-addon-api: 8.9.2 + prebuild-install: 7.1.3 + tar: 7.5.22 + optionalDependencies: + node-gyp: 12.4.0 + + sqlstring@2.3.1: {} + + stackback@0.0.2: {} + + std-env@4.3.0: {} + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + superjson@2.2.6: + dependencies: + copy-anything: 4.1.5 + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + + tabbable@6.5.0: {} + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + tarn@3.1.2: {} + + tedious@20.3.7(supports-color@7.2.0): + dependencies: + '@azure/core-auth': 1.11.0(supports-color@7.2.0) + '@azure/identity': 4.13.3(supports-color@7.2.0) + '@azure/keyvault-keys': 4.10.2(supports-color@7.2.0) + '@js-joda/core': 6.3.0 + '@types/node': 22.15.24 + bl: 6.1.6 + iconv-lite: 0.7.3 + js-md4: 0.3.2 + native-duplexpair: 1.0.0 + transitivePeerDependencies: + - supports-color + + text-table@0.2.0: {} + + tildify@2.0.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.2.0: {} + + trim-lines@3.0.1: {} + + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@6.21.0: {} + + undici@6.29.0: + optional: true + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@5.4.21(@types/node@22.15.24)(lightningcss@1.33.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.29 + rollup: 4.64.3 + optionalDependencies: + '@types/node': 22.15.24 + fsevents: 2.3.3 + lightningcss: 1.33.0 + + vite@8.3.4(@types/node@22.15.24)(esbuild@0.28.2): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.29 + rolldown: 1.2.13 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.15.24 + esbuild: 0.28.2 + fsevents: 2.3.3 + + vitepress@1.6.4(@algolia/client-search@5.59.0)(@types/node@22.15.24)(lightningcss@1.33.0)(postcss@8.5.29)(search-insights@2.17.3)(typescript@7.0.2): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.59.0)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.99 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.2.0 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@22.15.24)(lightningcss@1.33.0))(vue@3.5.43(typescript@7.0.2)) + '@vue/devtools-api': 7.7.10 + '@vue/shared': 3.5.43 + '@vueuse/core': 12.8.2(typescript@7.0.2) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@7.0.2) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21(@types/node@22.15.24)(lightningcss@1.33.0) + vue: 3.5.43(typescript@7.0.2) + optionalDependencies: + postcss: 8.5.29 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - preact-render-to-string + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + + vitest@4.1.11(@types/node@22.15.24)(vite@8.3.4(@types/node@22.15.24)(esbuild@0.28.2)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.3.4(@types/node@22.15.24)(esbuild@0.28.2)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.2.1 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyrainbow: 3.2.0 + vite: 8.3.4(@types/node@22.15.24)(esbuild@0.28.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.15.24 + transitivePeerDependencies: + - msw + + vue@3.5.43(typescript@7.0.2): + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 + optionalDependencies: + typescript: 7.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@6.0.1: + dependencies: + isexe: 4.0.0 + optional: true + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xtend@4.0.2: {} + + yallist@5.0.0: {} + + yocto-queue@0.1.0: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..f13780a08 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +# sqlite3 downloads or builds its native module. esbuild's script only checks +# its binary, which comes from an optional dependency anyway. +allowBuilds: + esbuild: false + sqlite3: true +# Was newer than pnpm's minimum release age when the lockfile was created. +minimumReleaseAgeExclude: + - tedious@20.3.7 diff --git a/publish-docs.sh b/publish-docs.sh deleted file mode 100755 index 919033eb5..000000000 --- a/publish-docs.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -NODE_OPTIONS=--openssl-legacy-provider npm run docs:build -rm -rf ../objection-doc/* -mv doc/.vuepress/dist/* ../objection-doc/ -cd ../objection-doc -git add -A -git commit -m "update docs" -git push origin gh-pages:gh-pages \ No newline at end of file diff --git a/reproduction-template.js b/reproduction-template.js index 16728cb30..ca6ce76ff 100644 --- a/reproduction-template.js +++ b/reproduction-template.js @@ -3,23 +3,18 @@ * They create a simple IMDB-style database. Try to add minimal modifications to this file to reproduce * your bug. * + * Save this file as reproduction-template.mjs, so that node runs it as an ES module. + * * install: - * npm install objection knex sqlite3 chai + * npm install objection knex sqlite3 * * run: - * node reproduction-template + * node reproduction-template.mjs */ -let Model; - -try { - Model = require('./').Model; -} catch (err) { - Model = require('objection').Model; -} - -const Knex = require('knex'); -const chai = require('chai'); +import { Model } from 'objection'; +import Knex from 'knex'; +import assert from 'node:assert/strict'; async function main() { await createSchema(); @@ -42,7 +37,7 @@ async function main() { const jennifer = await Person.query().findOne({ firstName: 'Jennifer' }).withGraphFetched('pets'); - chai.expect(jennifer.pets[0].name).to.equal('Doggo'); + assert.equal(jennifer.pets[0].name, 'Doggo'); } /////////////////////////////////////////////////////////////// diff --git a/testUtils/TestSession.js b/testUtils/TestSession.js index a2c107b12..22d33c64b 100644 --- a/testUtils/TestSession.js +++ b/testUtils/TestSession.js @@ -1,25 +1,11 @@ -const knexUtils = require('../lib/utils/knexUtils'); -const { map: promiseMap } = require('../lib/utils/promiseUtils'); -const { delay, cloneDeep } = require('./testUtils'); -const { Model, transaction, snakeCaseMappers, ref } = require('../'); - -const chai = require('chai'); -chai.use(require('chai-subset')); +import * as knexUtils from '../lib/utils/knexUtils.js'; +import { map as promiseMap } from '../lib/utils/promiseUtils/index.js'; +import { delay, cloneDeep } from './testUtils.js'; +import { Model, transaction, snakeCaseMappers, ref } from 'objection'; +import Knex from 'knex'; class TestSession { - static init() { - if (this.staticInitCalled) { - return; - } - - registerUnhandledRejectionHandler(); - - this.staticInitCalled = true; - } - constructor(opt) { - TestSession.init(); - this.opt = opt; this.knex = this.createKnex(opt); this.unboundModels = this.createModels(); @@ -29,7 +15,7 @@ class TestSession { } createKnex() { - return require('knex')(this.opt.knexConfig); + return Knex(this.opt.knexConfig); } createModels() { @@ -244,7 +230,7 @@ class TestSession { this.createTable('model3', (table, { increments }) => { increments('id'); table.string('model3Prop1'); - table.text('model3JsonProp'); + table.json('model3JsonProp'); }), ) .then(() => @@ -316,7 +302,7 @@ class TestSession { connection.filename || `${connection.host}:${connection.port}/${connection.database}`; const err = new Error( `Could not connect to ${client} at ${target}.` + - ' Run `npm run db:up` to start the test databases with docker, or see' + + ' Run `pnpm db:up` to start the test databases with docker, or see' + ' "Running the tests" in doc/guide/contributing.md to use your own.', ); @@ -415,16 +401,6 @@ class TestSession { return this.knex.destroy(); } - addUnhandledRejectionHandler(handler) { - const handlers = TestSession.unhandledRejectionHandlers; - handlers.push(handler); - } - - removeUnhandledRejectionHandler(handler) { - const handlers = TestSession.unhandledRejectionHandlers; - handlers.splice(handlers.indexOf(handler), 1); - } - isPostgres() { return knexUtils.isPostgres(this.knex); } @@ -442,8 +418,6 @@ class TestSession { } } -TestSession.staticInitCalled = false; -TestSession.unhandledRejectionHandlers = []; TestSession.hookCounter = 0; // Creates a hook that waits for `ms` milliseconds and then @@ -478,26 +452,4 @@ function inc(obj, key) { obj[key] = (obj[key] || 0) + 1; } -function registerUnhandledRejectionHandler() { - // Mocha removes its own listener and re-emits each rejection from user code, - // so this listener receives it twice. Handle each rejected promise once. - const handledPromises = new WeakSet(); - - process.on('unhandledRejection', (error, promise) => { - if (handledPromises.has(promise)) { - return; - } - - handledPromises.add(promise); - - if (TestSession.unhandledRejectionHandlers.length === 0) { - console.error(error.stack); - } - - TestSession.unhandledRejectionHandlers.forEach((handler) => { - handler(error); - }); - }); -} - -module.exports = TestSession; +export default TestSession; diff --git a/testUtils/graphCases.js b/testUtils/graphCases.js new file mode 100644 index 000000000..bac062e2e --- /dev/null +++ b/testUtils/graphCases.js @@ -0,0 +1,962 @@ +import { insertRowsWithIds } from './testUtils.js'; + +// Generated graphs for `upsertGraph()` and `insertGraph()`: a schema with all +// kinds of relations, rows to seed it with, and a graph and options to write, +// all derived from a seed. + +const prefix = 'graph_gen_'; + +// The columns of each table, in the order of creation: +export const graphTables = { + person: ['id', 'label', 'name', 'age', 'parentId', 'bossId', 'favoritePetId', 'stamp'], + pet: ['id', 'label', 'name', 'ownerId', 'stamp'], + toy: ['id', 'label', 'name', 'petId'], + profile: ['id', 'label', 'bio', 'personId'], + movie: ['id', 'label', 'title'], + person_movie: ['personId', 'movieId', 'role'], + badge: ['kind', 'num', 'label', 'title', 'personId'], + license: ['id', 'label', 'code'], + person_license: ['personId', 'licenseId'], + friendship: ['personId', 'friendId'], +}; + +// The foreign keys and the tables they point to: +const foreignKeys = { + parentId: 'person', + bossId: 'person', + ownerId: 'person', + petId: 'pet', + personId: 'person', + movieId: 'movie', + licenseId: 'license', + favoritePetId: 'pet', + friendId: 'person', +}; + +// The relations that keep their key in the related row: +const rowKeyRelations = new Set(['children', 'pets', 'toys', 'profile', 'badges']); + +const relationTypes = { + children: 'person', + parent: 'person', + boss: 'person', + owner: 'person', + pets: 'pet', + toys: 'toy', + profile: 'profile', + movies: 'movie', + badges: 'badge', + license: 'license', + favoritePet: 'pet', + siblings: 'pet', + activeOwner: 'person', + ownerFriends: 'person', +}; + +function graphTableName(table) { + return `${prefix}${table}`; +} + +export async function dropGraphTables(knex) { + for (const table of Object.keys(graphTables).reverse()) { + await knex.schema.dropTableIfExists(graphTableName(table)); + } +} + +export async function createGraphTables(knex) { + const t = graphTableName; + const isMsSql = knex.client.dialect === 'mssql'; + + await dropGraphTables(knex); + await knex.schema + .createTable(t('person'), (table) => { + table.increments('id'); + table.string('label'); + table.string('name'); + table.integer('age'); + table.integer('parentId'); + table.integer('bossId'); + table.integer('favoritePetId'); + table.string('stamp'); + }) + .createTable(t('pet'), (table) => { + table.increments('id'); + table.string('label'); + table.string('name'); + table.integer('ownerId'); + table.string('stamp'); + }) + .createTable(t('toy'), (table) => { + table.increments('id'); + table.string('label'); + table.string('name'); + table.integer('petId'); + }) + .createTable(t('profile'), (table) => { + table.increments('id'); + table.string('label'); + table.string('bio'); + table.integer('personId'); + // A HasOneRelation slot. SQL Server only allows one null in a unique key + // without the filter: + table.unique(['personId'], isMsSql ? { predicate: knex.whereNotNull('personId') } : {}); + }) + .createTable(t('movie'), (table) => { + table.increments('id'); + table.string('label'); + table.string('title'); + }) + // Deletes of related rows leave the join rows to the database: + .createTable(t('person_movie'), (table) => { + table + .integer('personId') + .unsigned() + .references('id') + .inTable(t('person')) + .onDelete('CASCADE'); + table.integer('movieId').unsigned().references('id').inTable(t('movie')).onDelete('CASCADE'); + table.string('role'); + }) + .createTable(t('badge'), (table) => { + table.string('kind'); + table.integer('num'); + table.string('label'); + table.string('title'); + table.integer('personId'); + table.primary(['kind', 'num']); + }) + .createTable(t('license'), (table) => { + table.increments('id'); + table.string('label'); + table.string('code'); + }) + .createTable(t('person_license'), (table) => { + table + .integer('personId') + .unsigned() + .unique() + .references('id') + .inTable(t('person')) + .onDelete('CASCADE'); + table + .integer('licenseId') + .unsigned() + .references('id') + .inTable(t('license')) + .onDelete('CASCADE'); + }) + // The friends of a pet's owner, through a join table keyed on the owner. + // SQL Server allows one cascade path per table: + .createTable(t('friendship'), (table) => { + table + .integer('personId') + .unsigned() + .references('id') + .inTable(t('person')) + .onDelete('CASCADE'); + table.integer('friendId'); + }); +} + +export function createGraphModels(Model) { + const t = graphTableName; + + // Stamp the rows that the hooks see written: + class StampedModel extends Model { + $beforeInsert() { + this.stamp = 'insert'; + } + + $beforeUpdate() { + this.stamp = 'update'; + } + } + + class Person extends StampedModel { + static get tableName() { + return t('person'); + } + + static get relationMappings() { + return { + parent: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: `${t('person')}.parentId`, to: `${t('person')}.id` }, + }, + children: { + relation: Model.HasManyRelation, + modelClass: Person, + join: { from: `${t('person')}.id`, to: `${t('person')}.parentId` }, + }, + boss: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: `${t('person')}.bossId`, to: `${t('person')}.id` }, + }, + pets: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: `${t('person')}.id`, to: `${t('pet')}.ownerId` }, + }, + profile: { + relation: Model.HasOneRelation, + modelClass: Profile, + join: { from: `${t('person')}.id`, to: `${t('profile')}.personId` }, + }, + badges: { + relation: Model.HasManyRelation, + modelClass: Badge, + join: { from: `${t('person')}.id`, to: `${t('badge')}.personId` }, + }, + license: { + relation: Model.HasOneThroughRelation, + modelClass: License, + join: { + from: `${t('person')}.id`, + through: { + from: `${t('person_license')}.personId`, + to: `${t('person_license')}.licenseId`, + }, + to: `${t('license')}.id`, + }, + }, + favoritePet: { + relation: Model.BelongsToOneRelation, + modelClass: Pet, + join: { from: `${t('person')}.favoritePetId`, to: `${t('pet')}.id` }, + }, + movies: { + relation: Model.ManyToManyRelation, + modelClass: Movie, + join: { + from: `${t('person')}.id`, + through: { + from: `${t('person_movie')}.personId`, + to: `${t('person_movie')}.movieId`, + extra: ['role'], + }, + to: `${t('movie')}.id`, + }, + }, + }; + } + } + + class Pet extends StampedModel { + static get tableName() { + return t('pet'); + } + + static get relationMappings() { + return { + owner: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: `${t('pet')}.ownerId`, to: `${t('person')}.id` }, + }, + toys: { + relation: Model.HasManyRelation, + modelClass: Toy, + join: { from: `${t('pet')}.id`, to: `${t('toy')}.petId` }, + }, + // Relations keyed on the owner's key, which a move of the pet changes: + activeOwner: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + modify: (query) => query.where(`${t('person')}.age`, '>=', 30), + join: { from: `${t('pet')}.ownerId`, to: `${t('person')}.id` }, + }, + siblings: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: `${t('pet')}.ownerId`, to: `${t('pet')}.ownerId` }, + }, + ownerFriends: { + relation: Model.ManyToManyRelation, + modelClass: Person, + join: { + from: `${t('pet')}.ownerId`, + through: { + from: `${t('friendship')}.personId`, + to: `${t('friendship')}.friendId`, + }, + to: `${t('person')}.id`, + }, + }, + }; + } + } + + class Toy extends Model { + static get tableName() { + return t('toy'); + } + } + + class Profile extends Model { + static get tableName() { + return t('profile'); + } + } + + class Movie extends Model { + static get tableName() { + return t('movie'); + } + } + + class Badge extends Model { + static get tableName() { + return t('badge'); + } + + static get idColumn() { + return ['kind', 'num']; + } + } + + class License extends Model { + static get tableName() { + return t('license'); + } + } + + return { Person, Pet, Toy, Profile, Movie, Badge, License }; +} + +// Seed the tables with the rows of a case, with their ids: +export async function seedGraphTables(knex, rows) { + await knex.transaction(async (trx) => { + for (const table of Object.keys(graphTables).reverse()) { + await trx(graphTableName(table)).delete(); + } + + for (const [table, tableRows] of Object.entries(rows)) { + const name = graphTableName(table); + const hasIdentity = graphTables[table].includes('id'); + + if (hasIdentity) { + await insertRowsWithIds(trx, name, tableRows); + } else if (tableRows.length > 0) { + await trx(name).insert(tableRows); + } + } + }); +} + +// Read the rows of all tables, with the ids of the rows and their foreign keys +// replaced by the labels of the rows that they point to, as the order in which +// the writes hand out ids isn't defined. +export async function dumpGraphTables(knex) { + const rowsByTable = {}; + const labels = {}; + + for (const [table, columns] of Object.entries(graphTables)) { + rowsByTable[table] = await knex(graphTableName(table)).select(columns); + + if (columns.includes('id')) { + labels[table] = new Map(rowsByTable[table].map((row) => [row.id, row.label])); + } + } + + return Object.fromEntries( + Object.entries(rowsByTable).map(([table, rows]) => [ + table, + rows + .map((row) => + JSON.stringify( + Object.fromEntries( + Object.entries(row) + .filter(([column]) => column !== 'id') + .map(([column, value]) => [ + column, + foreignKeys[column] && value !== null + ? (labels[foreignKeys[column]].get(value) ?? `missing:${value}`) + : value, + ]), + ), + ), + ) + .sort(), + ]), + ); +} + +// The labels of the rows that the graph keeps or inserts: the existing ones +// that it mentions with their ids and its new models, without `#unrelate` or +// `#delete` on them or on a model above them. +export function findKeptGraphRows(testCase) { + const kept = []; + + const visit = (value, table, isRemoved) => { + if (Array.isArray(value)) { + value.forEach((item) => visit(item, table, isRemoved)); + } else if (value && typeof value === 'object') { + const isRemovedHere = isRemoved || !!value['#unrelate'] || !!value['#delete']; + const id = value.id ?? value['#dbRef']; + const row = + !isRemovedHere && + (table === 'badge' + ? testCase.rows.badge.find( + (badge) => badge.kind === value.kind && badge.num === value.num, + ) + : id !== undefined && testCase.rows[table].find((other) => other.id === id)); + + if (row) { + kept.push([table, row.label]); + } else if (!isRemovedHere && value.label !== undefined) { + kept.push([table, value.label]); + } + + for (const [key, child] of Object.entries(value)) { + if (relationTypes[key]) { + visit(child, relationTypes[key], isRemovedHere); + } + } + } + }; + + visit(testCase.graph, 'person', false); + return kept; +} + +// Create the rows of the database and the call that writes a graph from +// `seed`. Each existing row appears once in the graph, apart from rows that get +// a second place, which agrees with the first one unless the case is built to +// conflict (`isConflicting`). +export function createGraphCase(seed) { + const random = createRandom(seed); + const rows = createRows(random); + const isInsert = random.chance(0.2); + const uidNodes = []; + const usedIds = new Set(); + let labelCount = 0; + let uidCount = 0; + let hasMarkers = false; + let isConflicting = false; + // The pets that list the pets of their owner as `siblings`: + const siblingsPlaces = []; + // Values that rows have to get from the graph, by table and label: + const expectedValues = []; + // Graphs that move rows or insert badges, which have their ids, mostly come + // with the options that they need: + let hasMovedRows = false; + let hasNewBadges = false; + + const newLabel = (type) => `${type}-new${++labelCount}`; + const unused = (type, rows) => rows.filter((row) => !usedIds.has(`${type}:${row.id}`)); + const use = (type, obj) => { + usedIds.add(`${type}:${obj.id ?? obj['#dbRef']}`); + return obj; + }; + + // The place of each model whose relation keeps a key in its row, which other + // places of its row have to share: + const keyPlaces = new Map(); + const isRowKeyPlace = (place) => rowKeyRelations.has(place?.key); + + // Give models a `#id`, which `#ref` and `#ref{}` point to later: + const setUid = (obj, type) => { + obj['#id'] = `u${++uidCount}`; + uidNodes.push({ obj, type }); + return obj['#id']; + }; + + // A model in a BelongsToOneRelation that puts the model at `place` where + // `place` already puts it, or deliberately somewhere else: + const createContainerRef = (place) => { + if (random.chance(0.2)) { + // The `#id` keeps `#ref` models from replacing it: + const model = { label: newLabel('person') }; + setUid(model, 'person'); + isConflicting = true; + return model; + } else { + return { '#ref': place.container['#id'] ?? setUid(place.container, 'person') }; + } + }; + + // A model of `type` from its place, from elsewhere in the database, or a new + // one: + const createModel = (type, allRows, depth, place, allOwnRows = [], allowsDbRef = false) => { + const roll = random.int(0, 9); + const existingRows = unused(type, allRows); + const ownRows = unused(type, allOwnRows); + + if (!isInsert && ownRows.length > 0 && roll < 4) { + return createObject(type, use(type, { id: random.pick(ownRows).id }), depth, place); + } else if (!isInsert && existingRows.length > 0 && roll < 6) { + hasMovedRows = true; + return createObject(type, use(type, { id: random.pick(existingRows).id }), depth, place); + } else if ( + allowsDbRef && + existingRows.length > 0 && + roll < 7 && + random.chance(isInsert ? 0.6 : 0.3) + ) { + return use(type, { '#dbRef': random.pick(existingRows).id }); + } else { + return createObject(type, { label: newLabel(type) }, depth, place); + } + }; + + const createArray = (type, allRows, depth, place, ownRows, allowsDbRef) => + Array.from({ length: random.int(0, 3) }, () => + createModel(type, allRows, depth + 1, place, ownRows, allowsDbRef), + ); + + const createObject = (type, obj, depth, place = null) => { + if (random.chance(0.25)) setUid(obj, type); + if (isRowKeyPlace(place)) keyPlaces.set(obj, place); + const at = (key) => ({ container: obj, key }); + const ownRows = (table, key) => + obj.id ? rows[table].filter((row) => row[key] === obj.id) : []; + + // Mark some related models at their place for removal: + if (obj.id && depth > 0 && random.chance(0.05)) { + hasMarkers = true; + obj[random.chance(0.5) ? '#unrelate' : '#delete'] = true; + } + + if (type === 'person') { + if (random.chance(0.5)) obj.name = `name ${random.int(1, 99)}`; + if (random.chance(0.3)) obj.age = random.int(1, 90); + + if (depth < 3) { + if (random.chance(0.4)) { + obj.children = createArray( + 'person', + rows.person, + depth, + at('children'), + ownRows('person', 'parentId'), + ); + } + if (random.chance(0.4)) { + obj.pets = createArray('pet', rows.pet, depth, at('pets'), ownRows('pet', 'ownerId')); + // Relations of one pet that are keyed on its owner, which change when + // the pet moves here: + const pet = random.chance(0.3) && random.pick(obj.pets.filter((pet) => !isMarked(pet))); + if (pet) { + // `insertGraph()` can't relate the pets of `siblings`: + if (!isInsert && random.chance(0.6)) { + siblingsPlaces.push({ owner: obj, pet }); + } + if (random.chance(0.4)) { + const ownFriends = ownRows('friendship', 'personId').map((row) => ({ + id: row.friendId, + })); + pet.ownerFriends = createArray( + 'person', + rows.person, + depth + 1, + { container: pet, key: 'ownerFriends' }, + ownFriends, + true, + ); + } + } + } + if (random.chance(0.25)) { + obj.boss = random.chance(0.2) + ? null + : createModel('person', rows.person, depth + 1, at('boss'), [], true); + } + if (random.chance(0.25)) { + obj.profile = random.chance(0.2) + ? null + : createModel( + 'profile', + rows.profile, + depth + 1, + at('profile'), + ownRows('profile', 'personId'), + ); + } + if (random.chance(0.25)) { + obj.badges = Array.from({ length: random.int(0, 2) }, () => { + const own = ownRows('badge', 'personId').filter( + (badge) => !usedIds.has(`badge:${badge.kind},${badge.num}`), + ); + const badge = !isInsert && own.length > 0 && random.chance(0.6) && random.pick(own); + + if (badge) { + usedIds.add(`badge:${badge.kind},${badge.num}`); + return { + kind: badge.kind, + num: badge.num, + ...(random.chance(0.5) && { title: `title ${random.int(1, 9)}` }), + }; + } else { + hasNewBadges = true; + return { kind: 'n', num: ++labelCount, label: newLabel('badge') }; + } + }); + } + if (random.chance(0.15)) { + const ownLicenses = ownRows('person_license', 'personId').map((row) => ({ + id: row.licenseId, + })); + obj.license = random.chance(0.2) + ? null + : createModel('license', rows.license, depth + 1, at('license'), ownLicenses, true); + } + if (random.chance(0.3)) { + const ownMovies = ownRows('person_movie', 'personId').map((row) => ({ + id: row.movieId, + })); + obj.movies = createArray('movie', rows.movie, depth, at('movies'), ownMovies, true).map( + (movie) => { + if (!movie['#dbRef'] && random.chance(0.5)) movie.role = `role ${random.int(1, 9)}`; + return movie; + }, + ); + } + // A pet that the graph may leave out of its owner's `pets`, which + // then keeps it here: + if (random.chance(0.1)) { + obj.favoritePet = createModel('pet', rows.pet, depth + 1, at('favoritePet'), [], true); + } + // Below `children`, the parent is the model that lists the child: + if (random.chance(0.1) && !isMarked(obj) && !place?.container['#delete']) { + obj.parent = + place?.key === 'children' + ? createContainerRef(place) + : random.chance(0.3) + ? null + : createModel('person', rows.person, depth + 1, at('parent'), [], true); + } + } + } else if (type === 'pet') { + if (random.chance(0.5)) obj.name = `name ${random.int(1, 99)}`; + + if (depth < 3) { + if (random.chance(0.3)) { + obj.toys = createArray('toy', rows.toy, depth, at('toys'), ownRows('toy', 'petId')); + } + // Below `pets`, the owner is the model that lists the pet: + const isInPets = place.key === 'pets'; + if (random.chance(0.1) && !isMarked(obj) && !(isInPets && place.container['#delete'])) { + const ownerKey = random.chance(0.7) ? 'owner' : 'activeOwner'; + obj[ownerKey] = isInPets + ? createContainerRef(place) + : createModel('person', rows.person, depth + 1, at(ownerKey), [], true); + } + } + } else if (type === 'toy') { + if (random.chance(0.5)) obj.name = `name ${random.int(1, 99)}`; + } else if (type === 'profile') { + if (random.chance(0.5)) obj.bio = `bio ${random.int(1, 99)}`; + } else if (type === 'movie') { + if (random.chance(0.3)) obj.title = `title ${random.int(1, 99)}`; + } else if (type === 'license') { + if (random.chance(0.3)) obj.code = `code ${random.int(1, 99)}`; + } + + return obj; + }; + + const roots = Array.from({ length: random.int(1, 2) }, () => + isInsert + ? createObject('person', { label: newLabel('person') }, 0) + : createModel('person', rows.person, 0, null, rows.person), + ).filter((root) => !root['#dbRef']); + + if (roots.length === 0) { + roots.push(createObject('person', { label: newLabel('person') }, 0)); + } + + // Replace some models with a `#ref` to a model of the same type. A place + // that keeps a key in the row only takes models that don't set it elsewhere: + const replaceWithRefs = (obj) => { + for (const [key, value] of Object.entries(obj)) { + const place = { container: obj, key }; + // The roles that this place gives the join rows of its models: + const rolesHere = new Map(); + + const replace = (item) => { + const candidates = uidNodes.filter(({ obj: model, type }) => { + const keyPlace = keyPlaces.get(model); + return ( + type === relationTypes[key] && + !model['#delete'] && + !contains(item, model) && + (!isRowKeyPlace(place) || + (!isMarked(model) && + !(key === 'children' && 'parent' in model) && + !(key === 'pets' && ('owner' in model || 'activeOwner' in model)) && + (!keyPlace || (keyPlace.container === obj && keyPlace.key === key)))) + ); + }); + + if (candidates.length > 0 && !hasUid(item) && !item['#ref'] && random.chance(0.15)) { + const model = random.pick(candidates).obj; + // Join rows of `#ref` models may have extras of their own. A pair of + // models has one join row, which conflicts with a second role: + const role = key === 'movies' && random.chance(0.5) && `role ${random.int(1, 9)}`; + if (role) { + const otherRole = + rolesHere.get(model) ?? (value.includes(model) ? model.role : undefined); + isConflicting ||= otherRole !== undefined && otherRole !== role; + rolesHere.set(model, otherRole ?? role); + } + // A pair that its model's place unrelates and the `#ref` keeps conflicts: + isConflicting ||= + ['movies', 'ownerFriends'].includes(key) && isMarked(model) && value.includes(model); + if (isRowKeyPlace(place)) keyPlaces.set(model, place); + return { '#ref': model['#id'], ...(role && { role }) }; + } else { + replaceWithRefs(item); + return item; + } + }; + + if (Array.isArray(value)) { + obj[key] = value.map(replace); + } else if (value && typeof value === 'object') { + obj[key] = replace(value); + } + } + }; + + for (const root of roots) { + replaceWithRefs(root); + } + + // `siblings` describe the same pets as the final `pets` of their owner, so + // they list them all: + for (const { owner, pet } of siblingsPlaces) { + pet.siblings = owner.pets + .filter((sibling) => !isMarked(sibling)) + .map((sibling) => ({ '#ref': sibling['#ref'] ?? sibling['#id'] ?? setUid(sibling, 'pet') })); + } + + // Point some names to the names of other models with `#ref{}`: + const namedUidNodes = uidNodes.filter( + (node) => node.obj.name !== undefined && roots.some((root) => contains(root, node.obj)), + ); + + for (const node of uidNodes) { + const target = namedUidNodes.length > 0 && random.pick(namedUidNodes); + + if (target && target !== node && node.obj.name !== undefined && random.chance(0.15)) { + node.obj.name = `ref #ref{${target.obj['#id']}.name}`; + } + } + + // Give some rows that the graph doesn't contain yet a second place, which + // either agrees with the first one or conflicts with it, as such graphs have + // to be rejected: + const unusedPersons = unused('person', rows.person); + const hasSecondPlace = !isInsert && unusedPersons.length > 0 && random.chance(0.2); + + if (hasSecondPlace) { + const person = use('person', { id: random.pick(unusedPersons).id }); + const other = () => random.pick(rows.person).id; + const pets = unused('pet', rows.pet); + const profiles = unused('profile', rows.profile); + const shape = random.int(0, 2); + + if (shape === 0) { + // A foreign key prop in one place, the relation in another: + const bossId = other(); + const otherBossId = random.chance(0.5) ? bossId : other(); + isConflicting ||= otherBossId !== bossId; + roots.push({ id: person.id, boss: { id: bossId } }, { id: person.id, bossId: otherBossId }); + } else if (shape === 1 && pets.length > 0) { + // A foreign key prop next to the relation that sets it, which follows + // the relation, e.g. the old key of a fetched model: + const pet = random.pick(pets); + roots.push({ id: person.id, pets: [{ ...use('pet', { id: pet.id }), ownerId: other() }] }); + const { label } = rows.person.find((row) => row.id === person.id); + expectedValues.push(['pet', pet.label, { ownerId: label }]); + } else { + // Two rows for one HasOneRelation slot: + const profile = () => + profiles.length > 0 && random.chance(0.7) + ? { id: random.pick(profiles).id } + : { label: newLabel('profile') }; + const first = profile(); + const second = random.chance(0.4) ? { ...first } : profile(); + isConflicting ||= !first.id || second.id !== first.id; + roots.push({ id: person.id, profile: first }, { id: person.id, profile: second }); + } + } + + const options = { allowRefs: true, ...(hasMarkers && { allowMarkers: true }) }; + const optionNames = isInsert + ? ['relate'] + : [ + 'relate', + 'unrelate', + 'insertMissing', + 'noDelete', + 'noUpdate', + 'noInsert', + 'noRelate', + 'noUnrelate', + 'update', + ]; + const paths = [ + 'children', + 'pets', + 'boss', + 'profile', + 'movies', + 'badges', + 'license', + 'children.pets', + 'boss.children', + 'pets.toys', + ]; + + for (const name of optionNames) { + if (random.chance(name === 'relate' ? 0.6 : 0.2)) { + // Some options name relation paths instead of applying everywhere: + options[name] = random.chance(0.3) + ? [...new Set(Array.from({ length: random.int(1, 2) }, () => random.pick(paths)))] + : true; + } + } + + if ((hasMovedRows && random.chance(0.85)) || hasSecondPlace) options.relate = true; + // `#ref` models and their models may be written through paths that allow + // different writes: + if (!isInsert && /"#ref"/.test(JSON.stringify(roots)) && random.chance(0.3)) { + options.noUpdate = [random.pick(paths)]; + } + if (hasNewBadges && !isInsert && random.chance(0.85)) options.insertMissing = true; + + if (!isInsert && random.chance(0.3)) { + options.fetchStrategy = random.pick(['OnlyIdentifiers', 'OnlyNeeded', 'Everything']); + } + + return { + seed, + rows, + method: isInsert ? 'insertGraph' : 'upsertGraph', + graph: roots.length === 1 ? roots[0] : roots, + options, + isConflicting, + // `noRelate` leaves the rows where they are: + expectedValues: options.noRelate ? [] : expectedValues, + }; +} + +function createRows(random) { + const rows = Object.fromEntries(Object.keys(graphTables).map((table) => [table, []])); + const personCount = random.int(2, 5); + + for (let id = 1; id <= personCount; id++) { + rows.person.push({ + id, + label: `p${id}`, + name: `person ${id}`, + age: random.int(1, 90), + parentId: id > 1 && random.chance(0.5) ? random.int(1, id - 1) : null, + bossId: random.chance(0.3) ? random.int(1, personCount) : null, + }); + } + + for (const person of rows.person) { + for (let i = random.int(0, 2); i > 0; i--) { + const id = rows.pet.length + 1; + rows.pet.push({ id, label: `pet${id}`, name: `pet ${id}`, ownerId: person.id }); + + for (let j = random.int(0, 1); j > 0; j--) { + const toyId = rows.toy.length + 1; + rows.toy.push({ id: toyId, label: `toy${toyId}`, name: `toy ${toyId}`, petId: id }); + } + } + + if (random.chance(0.4)) { + const id = rows.profile.length + 1; + rows.profile.push({ id, label: `prof${id}`, bio: `bio ${id}`, personId: person.id }); + } + } + + for (let id = 1; id <= 3; id++) { + rows.movie.push({ id, label: `m${id}`, title: `movie ${id}` }); + rows.license.push({ id, label: `l${id}`, code: `code ${id}` }); + } + + for (const person of rows.person) { + for (let i = random.int(0, 2); i > 0; i--) { + const num = rows.badge.length + 1; + rows.badge.push({ + kind: 'b', + num, + label: `b${num}`, + title: `badge ${num}`, + personId: person.id, + }); + } + + if (random.chance(0.4)) { + rows.person_license.push({ personId: person.id, licenseId: random.int(1, 3) }); + } + + if (rows.pet.length > 0 && random.chance(0.3)) { + person.favoritePetId = random.pick(rows.pet).id; + } + + for (const friend of rows.person) { + if (friend !== person && random.chance(0.2)) { + rows.friendship.push({ personId: person.id, friendId: friend.id }); + } + } + + for (const movie of rows.movie) { + if (random.chance(0.3)) { + rows.person_movie.push({ + personId: person.id, + movieId: movie.id, + role: `role ${movie.id}`, + }); + } + } + } + + return rows; +} + +// mulberry32, a small seeded generator: +function createRandom(seed) { + let state = seed >>> 0 || 1; + + const next = () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + + return { + int: (min, max) => min + Math.floor(next() * (max - min + 1)), + chance: (probability) => next() < probability, + pick: (items) => items[Math.floor(next() * items.length)], + }; +} + +function contains(value, target) { + return ( + value === target || + (value !== null && + typeof value === 'object' && + Object.values(value).some((child) => contains(child, target))) + ); +} + +function isMarked(obj) { + return !!obj['#unrelate'] || !!obj['#delete']; +} + +function hasUid(value) { + return ( + value !== null && + typeof value === 'object' && + (Object.hasOwn(value, '#id') || Object.values(value).some(hasUid)) + ); +} diff --git a/testUtils/mockKnex.js b/testUtils/mockKnex.js index 90f73d95b..f59d3bcee 100644 --- a/testUtils/mockKnex.js +++ b/testUtils/mockKnex.js @@ -1,4 +1,6 @@ -const knexMethods = require('knex/lib/query/method-constants').concat('queryBuilder', 'raw'); +import methodConstants from 'knex/lib/query/method-constants.js'; + +const knexMethods = methodConstants.concat('queryBuilder', 'raw'); /** * @param {function} knex @@ -10,7 +12,7 @@ const knexMethods = require('knex/lib/query/method-constants').concat('queryBuil * @returns {function} * Mocked knex. */ -module.exports = function mockKnex(knex, mockExecutor) { +export default function mockKnex(knex, mockExecutor) { const mock = (table) => { return mock.queryBuilder().table(table); }; @@ -62,4 +64,4 @@ module.exports = function mockKnex(knex, mockExecutor) { } return mock; -}; +} diff --git a/testUtils/setup.js b/testUtils/setup.js new file mode 100644 index 000000000..3a263adc2 --- /dev/null +++ b/testUtils/setup.js @@ -0,0 +1,63 @@ +import { expect } from 'vitest'; +import { QueryBuilderUserContext } from '../lib/queryBuilder/QueryBuilderUserContext.js'; + +// The user context of a query keeps a reference to its query builder under a symbol +// key, which vitest would compare too. Only compare the context values themselves. +expect.addEqualityTesters([ + function userContextEquality(a, b, customTesters) { + if (a instanceof QueryBuilderUserContext || b instanceof QueryBuilderUserContext) { + return this.equals(contextValues(a), contextValues(b), customTesters); + } + }, +]); + +function contextValues(value) { + return value instanceof QueryBuilderUserContext + ? Object.fromEntries(Object.entries(value)) + : value; +} + +expect.extend({ + // Like `toMatchObject()`, but arrays in `expected` only need to be a subset of + // the arrays in `received`: each expected item has to match one of the received + // items, regardless of their order and the lengths of the arrays. + toContainSubset(received, expected) { + const pass = isSubset(expected, received); + const { printReceived, printExpected } = this.utils; + + return { + pass, + message: () => + `expected ${printReceived(received)} ${pass ? 'not ' : ''}to contain subset ${printExpected(expected)}`, + actual: received, + expected, + }; + }, +}); + +function isSubset(expected, actual) { + if (expected === actual) { + return true; + } + + if (typeof expected !== 'object' || expected === null) { + return false; + } + + if (typeof actual !== 'object' || actual === null) { + return false; + } + + if (Array.isArray(expected)) { + return ( + typeof actual.length === 'number' && + expected.every((item) => Array.from(actual).some((it) => isSubset(item, it))) + ); + } + + if (expected instanceof Date) { + return actual instanceof Date && expected.getTime() === actual.getTime(); + } + + return Object.keys(expected).every((key) => isSubset(expected[key], actual[key])); +} diff --git a/testUtils/testUtils.js b/testUtils/testUtils.js index 6fee4dee3..77ea0305f 100644 --- a/testUtils/testUtils.js +++ b/testUtils/testUtils.js @@ -1,5 +1,6 @@ -const expect = require('expect.js'); -const { cloneDeep } = require('../lib/utils/objectUtils'); +import { expect } from 'vitest'; + +export { cloneDeep } from '../lib/utils/objectUtils.js'; /** * Expect that `result` contains all attributes of `partial` and their values equal. @@ -17,9 +18,9 @@ const { cloneDeep } = require('../lib/utils/objectUtils'); * expectPartialEqual({a: 1}, {a: 2}); * ``` */ -function expectPartialEqual(result, partial) { +export function expectPartialEqual(result, partial) { if (Array.isArray(result) && Array.isArray(partial)) { - expect(result).to.have.length(partial.length); + expect(result).toHaveLength(partial.length); result.forEach((value, idx) => { expectPartialEqual(result[idx], partial[idx]); }); @@ -30,13 +31,30 @@ function expectPartialEqual(result, partial) { !Array.isArray(result) ) { var partialKeys = Object.keys(partial); - expect(pick(result, partialKeys)).to.eql(partial); + expect(pick(result, partialKeys)).toEqual(partial); } else { throw new Error('result and partial must both be arrays or objects'); } } -function createRejectionReflection(err) { +/** + * Expect that `fn` throws, and pass the thrown error to `check` to make further + * assertions about it. + */ +export function expectThrows(fn, check) { + let error; + + try { + fn(); + } catch (err) { + error = err; + } + + expect(error, 'expected function to throw').toBeDefined(); + check(error); +} + +export function createRejectionReflection(err) { return { isRejected: () => true, isFulfilled: () => false, @@ -44,7 +62,35 @@ function createRejectionReflection(err) { }; } -function delay(ms) { +// Insert rows with their ids into a table with an identity column. SQL Server +// only takes them with IDENTITY_INSERT, and Postgres needs its sequence moved +// past them, so that the next inserts get new ids. +export async function insertRowsWithIds(knex, table, rows) { + const { dialect } = knex.client; + + if (rows.length > 0) { + if (dialect === 'mssql') { + // The setting only lasts for one batch: + const { sql, bindings } = knex(table).insert(rows).toSQL(); + await knex.raw(`SET IDENTITY_INSERT ?? ON; ${sql}; SET IDENTITY_INSERT ?? OFF`, [ + table, + ...bindings, + table, + ]); + } else { + await knex(table).insert(rows); + } + } + + if (dialect === 'postgresql') { + await knex.raw( + "select setval(pg_get_serial_sequence(?, 'id'), coalesce(max(id), 0) + 1, false) from ??", + [table, table], + ); + } +} + +export function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -54,7 +100,7 @@ function isObject(value) { } // Like lodash's `pick`: copies the given keys (own or inherited) that exist in `obj`. -function pick(obj, ...keys) { +export function pick(obj, ...keys) { const result = {}; if (obj != null) { @@ -69,7 +115,7 @@ function pick(obj, ...keys) { } // Like lodash's `range`: `range(end)` or `range(start, end[, step])`. -function range(start, end, step) { +export function range(start, end, step) { if (end === undefined) { end = start; start = 0; @@ -85,7 +131,7 @@ function range(start, end, step) { // Like lodash's `sortBy`: a stable ascending sort by property names or // functions, or by the items themselves. `null` and `undefined` sort last. -function sortBy(items, ...iteratees) { +export function sortBy(items, ...iteratees) { const getters = iteratees .flat() .map((it) => (typeof it === 'function' ? it : (item) => item[it])); @@ -109,13 +155,3 @@ function sortBy(items, ...iteratees) { return 0; }); } - -module.exports = { - expectPartialEqual, - createRejectionReflection, - delay, - cloneDeep, - pick, - range, - sortBy, -}; diff --git a/tests/bench/typings/app/base.ts b/tests/bench/typings/app/base.ts new file mode 100644 index 000000000..11052fc13 --- /dev/null +++ b/tests/bench/typings/app/base.ts @@ -0,0 +1,25 @@ +import { Model, Page, QueryBuilder, Generated } from 'objection'; + +export class AppQueryBuilder extends QueryBuilder { + declare RebindType: AppQueryBuilder; + + active(): this { + return this.where('deletedAt', null); + } + + newest(): this { + return this.orderBy('createdAt', 'desc'); + } +} + +export class BaseModel extends Model { + declare QueryBuilderType: AppQueryBuilder; + id!: Generated; + createdAt!: Generated; + updatedAt!: Generated; + deletedAt!: Date | null; + + $beforeUpdate() { + this.updatedAt = new Date() as Generated; + } +} diff --git a/tests/bench/typings/app/models.ts b/tests/bench/typings/app/models.ts new file mode 100644 index 000000000..b84050dcd --- /dev/null +++ b/tests/bench/typings/app/models.ts @@ -0,0 +1,184 @@ +import { Model, TypedRelationMappings } from 'objection'; +import { BaseModel } from './base.js'; + +export class Organization extends BaseModel { + static tableName = 'organizations'; + name!: string; + slug!: string; + users?: User[]; + projects?: Project[]; + tags?: Tag[]; + + static relationMappings = () => + ({ + users: { + relation: Model.HasManyRelation, + modelClass: User, + join: { from: 'organizations.id', to: 'users.organizationId' }, + }, + projects: { + relation: Model.HasManyRelation, + modelClass: Project, + join: { from: 'organizations.id', to: 'projects.organizationId' }, + }, + tags: { + relation: Model.HasManyRelation, + modelClass: Tag, + join: { from: 'organizations.id', to: 'tags.organizationId' }, + }, + }) satisfies TypedRelationMappings; +} + +export class User extends BaseModel { + static tableName = 'users'; + organizationId!: number; + email!: string; + firstName!: string; + lastName!: string; + role!: 'admin' | 'member' | 'guest'; + lastLoginAt!: Date | null; + settings!: { theme: string; locale: string }; + organization?: Organization; + profile?: Profile | null; + projects?: Project[]; + assignedTasks?: Task[]; + comments?: Comment[]; + manager?: User | null; + reports?: User[]; + + get fullName() { + return `${this.firstName} ${this.lastName}`; + } + + static relationMappings = () => + ({ + organization: { + relation: Model.BelongsToOneRelation, + modelClass: Organization, + join: { from: 'users.organizationId', to: 'organizations.id' }, + }, + profile: { + relation: Model.HasOneRelation, + modelClass: Profile, + join: { from: 'users.id', to: 'profiles.userId' }, + }, + projects: { + relation: Model.ManyToManyRelation, + modelClass: Project, + join: { + from: 'users.id', + through: { from: 'projectMembers.userId', to: 'projectMembers.projectId' }, + to: 'projects.id', + }, + }, + assignedTasks: { + relation: Model.HasManyRelation, + modelClass: Task, + join: { from: 'users.id', to: 'tasks.assigneeId' }, + }, + comments: { + relation: Model.HasManyRelation, + modelClass: Comment, + join: { from: 'users.id', to: 'comments.authorId' }, + }, + manager: { + relation: Model.BelongsToOneRelation, + modelClass: User, + join: { from: 'users.managerId', to: 'users.id' }, + }, + reports: { + relation: Model.HasManyRelation, + modelClass: User, + join: { from: 'users.id', to: 'users.managerId' }, + }, + }) satisfies TypedRelationMappings; +} + +export class Profile extends BaseModel { + static tableName = 'profiles'; + userId!: number; + bio!: string | null; + avatarUrl!: string | null; + user?: User; +} + +export class Project extends BaseModel { + static tableName = 'projects'; + organizationId!: number; + name!: string; + description!: string | null; + status!: 'active' | 'archived'; + budget!: number; + organization?: Organization; + members?: User[]; + milestones?: Milestone[]; + tasks?: Task[]; + tags?: Tag[]; +} + +export class Milestone extends BaseModel { + static tableName = 'milestones'; + projectId!: number; + title!: string; + dueAt!: Date | null; + project?: Project; + tasks?: Task[]; +} + +export class Task extends BaseModel { + static tableName = 'tasks'; + projectId!: number; + milestoneId!: number | null; + assigneeId!: number | null; + title!: string; + done!: boolean; + priority!: number; + estimate!: number | null; + project?: Project; + milestone?: Milestone | null; + assignee?: User | null; + comments?: Comment[]; + attachments?: Attachment[]; + tags?: Tag[]; +} + +export class Comment extends BaseModel { + static tableName = 'comments'; + taskId!: number; + authorId!: number; + body!: string; + task?: Task; + author?: User; + attachments?: Attachment[]; +} + +export class Attachment extends BaseModel { + static tableName = 'attachments'; + taskId!: number | null; + commentId!: number | null; + fileName!: string; + size!: number; + task?: Task | null; + comment?: Comment | null; +} + +export class Tag extends BaseModel { + static tableName = 'tags'; + organizationId!: number; + label!: string; + color!: string; + organization?: Organization; + tasks?: Task[]; + projects?: Project[]; +} + +// A plain model without the custom query builder. +export class AuditLog extends Model { + static tableName = 'auditLogs'; + id!: number; + userId!: number | null; + action!: string; + payload!: object; + createdAt!: Date; + user?: User | null; +} diff --git a/tests/bench/typings/app/projects.ts b/tests/bench/typings/app/projects.ts new file mode 100644 index 000000000..28b4dbe45 --- /dev/null +++ b/tests/bench/typings/app/projects.ts @@ -0,0 +1,199 @@ +import { raw, fn, Transaction, Page } from 'objection'; +import { Project, Milestone, Task, Tag, Organization, User } from './models.js'; +import { AppQueryBuilder } from './base.js'; + +export async function listProjects(orgId: number) { + return Project.query().active().where('organizationId', orgId).orderBy('name'); +} + +export async function projectPage(orgId: number, page: number): Promise> { + return Project.query().where('organizationId', orgId).newest().page(page, 25); +} + +export async function getProject(id: number) { + return Project.query() + .findById(id) + .withGraphFetched('[organization, members, milestones.tasks, tags]') + .throwIfNotFound(); +} + +export async function projectSummary(id: number) { + const p = await getProject(id); + return { + org: p.organization.name, + members: p.members.map((m) => m.email), + tasks: p.milestones.reduce((n, m) => n + m.tasks.length, 0), + tags: p.tags.map((t) => t.label), + }; +} + +export async function projectsWithBudget(min: number, max: number) { + return Project.query() + .whereBetween('budget', [min, max]) + .where('status', 'active') + .orderBy('budget', 'desc'); +} + +export async function archiveProject(id: number) { + return Project.query().patch({ status: 'archived' }).findById(id); +} + +export async function createProject(orgId: number, name: string) { + return Project.query().insertAndFetch({ + organizationId: orgId, + name, + description: null, + status: 'active', + budget: 0, + deletedAt: null, + }); +} + +export async function createProjectGraph(orgId: number, name: string) { + return Project.query().insertGraph( + { + organizationId: orgId, + name, + status: 'active', + budget: 1000, + milestones: [ + { title: 'M1', dueAt: null, tasks: [{ title: 'T1', done: false, priority: 1 }] }, + { title: 'M2', dueAt: new Date() }, + ], + tags: [{ '#dbRef': 1 }], + }, + { relate: ['tags'] }, + ); +} + +export async function projectMilestones(project: Project) { + return project.$relatedQuery('milestones').orderBy('dueAt'); +} + +export async function projectTasks(projectId: number) { + return Project.relatedQuery('tasks') + .for(projectId) + .where('done', false) + .orderBy('priority', 'desc'); +} + +export async function nextMilestone(projectId: number) { + return Milestone.query() + .where('projectId', projectId) + .whereNotNull('dueAt') + .where('dueAt', '>', new Date()) + .orderBy('dueAt') + .first(); +} + +export async function milestoneWithTasks(id: number) { + const m = await Milestone.query().findById(id).withGraphJoined('[project, tasks.assignee]'); + return m?.tasks.map((t) => t.assignee?.email); +} + +export async function tagProject(project: Project, tagId: number) { + return project.$relatedQuery('tags').relate(tagId); +} + +export async function untagProject(project: Project, tagId: number) { + return project.$relatedQuery('tags').unrelate().where('tags.id', tagId); +} + +export async function projectsByTag(label: string) { + return Project.query().joinRelated('tags').where('tags.label', label).select('projects.*'); +} + +export async function projectsTaskCounts(orgId: number) { + return Project.query() + .where('organizationId', orgId) + .select('projects.id', 'projects.name', Project.relatedQuery('tasks').count().as('taskCount')) + .orderBy('taskCount', 'desc'); +} + +export async function budgetTotals() { + return Project.query() + .select('organizationId') + .sum('budget as total') + .groupBy('organizationId') + .orderBy('total', 'desc'); +} + +export async function copyProject(id: number, trx: Transaction) { + const source = await Project.query(trx) + .findById(id) + .withGraphFetched('milestones') + .throwIfNotFound(); + return Project.query(trx).insertGraph({ + organizationId: source.organizationId, + name: source.name + ' copy', + status: 'active', + budget: source.budget, + milestones: source.milestones.map((m) => ({ title: m.title, dueAt: m.dueAt })), + }); +} + +export async function deleteProject(id: number) { + return Project.transaction(async (trx) => { + await Task.query(trx).delete().where('projectId', id); + await Milestone.query(trx).delete().where('projectId', id); + return Project.query(trx).deleteById(id); + }); +} + +export async function upsertProject(project: Project) { + return Project.query().upsertGraphAndFetch( + { id: project.id, name: project.name, milestones: [{ id: 1, title: 'X' }] }, + { noDelete: true }, + ); +} + +export function scoped(qb: AppQueryBuilder, orgId: number) { + return qb.where('organizationId', orgId).active(); +} + +export async function scopedProjects(orgId: number) { + return Project.query().modify(scoped, orgId).orderBy('name'); +} + +export async function orgsWithProjects() { + const orgs = await Organization.query() + .withGraphFetched('projects(onlyActive).tags') + .modifiers({ onlyActive: (qb) => qb.where('status', 'active') }); + return orgs.map((o) => o.projects.map((p) => p.tags.length)); +} + +export async function orgUsersAndProjects(id: number) { + const org = await Organization.query() + .findById(id) + .withGraphFetched({ users: true, projects: { tasks: true } }); + return org && org.users.length + org.projects.reduce((n, p) => n + p.tasks.length, 0); +} + +export async function projectMembersSince(project: Project, since: Date) { + return project.$relatedQuery('members').where('lastLoginAt', '>=', since).select('id', 'email'); +} + +export async function coalesceDescriptions() { + return Project.query().select('id', fn.coalesce('description', raw('?', ['n/a'])).as('desc')); +} + +export async function orgMembers(org: Organization) { + return org.$relatedQuery('users').where('role', 'admin').first(); +} + +export async function lastProjectUpdate(orgId: number) { + return Project.query().where('organizationId', orgId).max('updatedAt as last').first(); +} + +export async function idsOnly(orgId: number) { + const rows = await Project.query().where('organizationId', orgId).select('id'); + return rows.map((r) => r.id); +} + +export async function membersOfMany(ids: number[]) { + return Project.relatedQuery('members').for(ids).distinct('users.id'); +} + +export async function findUserProjectsCount(user: User) { + return user.$relatedQuery('projects').resultSize(); +} diff --git a/tests/bench/typings/app/tasks.ts b/tests/bench/typings/app/tasks.ts new file mode 100644 index 000000000..6ef30dbf7 --- /dev/null +++ b/tests/bench/typings/app/tasks.ts @@ -0,0 +1,218 @@ +import { raw, ref, PartialModelRow, QueryBuilder } from 'objection'; +import { Task, Comment, Attachment, User, Tag, Project } from './models.js'; + +export async function openTasks(projectId: number) { + return Task.query().where({ projectId, done: false }).orderBy('priority', 'desc'); +} + +export async function myTasks(user: User) { + return user + .$relatedQuery('assignedTasks') + .where('done', false) + .withGraphFetched('[project, milestone]'); +} + +export async function taskDetail(id: number) { + const task = await Task.query() + .findById(id) + .withGraphFetched( + '[project.organization, assignee.profile, comments.[author, attachments], tags]', + ) + .throwIfNotFound(); + return { + org: task.project.organization.name, + assignee: task.assignee?.profile?.bio, + comments: task.comments.map((c) => c.author.email + c.attachments.length), + tags: task.tags.map((t) => t.color), + }; +} + +export async function completeTask(id: number) { + return Task.query().patchAndFetchById(id, { done: true }); +} + +export async function reassign(taskIds: number[], assigneeId: number | null) { + return Task.query().patch({ assigneeId }).whereIn('id', taskIds); +} + +export async function bumpPriority(projectId: number) { + return Task.query().where('projectId', projectId).increment('priority', 1); +} + +export async function createTask(projectId: number, title: string) { + return Task.query().insert({ + projectId, + title, + done: false, + priority: 0, + milestoneId: null, + assigneeId: null, + estimate: null, + deletedAt: null, + }); +} + +export async function createTasks(projectId: number, titles: string[]) { + return Task.query().insert( + titles.map((title) => ({ projectId, title, done: false, priority: 0 })), + ); +} + +export async function createTaskWithComment(projectId: number, authorId: number) { + return Task.query().insertGraphAndFetch({ + projectId, + title: 'with comment', + done: false, + priority: 1, + comments: [{ authorId, body: 'first', attachments: [{ fileName: 'a.png', size: 10 }] }], + }); +} + +export async function addComment(task: Task, authorId: number, body: string) { + return task.$relatedQuery('comments').insert({ authorId, body }); +} + +export async function taskComments(taskId: number) { + return Comment.query().where('taskId', taskId).withGraphFetched('author').orderBy('createdAt'); +} + +export async function commentPage(taskId: number, page: number) { + return Comment.query().where('taskId', taskId).newest().page(page, 50); +} + +export async function deleteComment(id: number) { + return Comment.query().deleteById(id); +} + +export async function editComment(id: number, body: string) { + return Comment.query().findById(id).patch({ body }); +} + +export async function bigAttachments(min: number) { + return Attachment.query().where('size', '>', min).orderBy('size', 'desc').limit(100); +} + +export async function attachmentsOfTask(task: Task) { + return task.$relatedQuery('attachments').select('fileName', 'size'); +} + +export async function overdue() { + return Task.query() + .joinRelated('milestone') + .where('milestone.dueAt', '<', new Date()) + .where('tasks.done', false) + .select('tasks.*'); +} + +export async function unassigned(projectId: number) { + return Task.query().where('projectId', projectId).whereNull('assigneeId').orderBy('createdAt'); +} + +export async function estimateSum(projectId: number) { + return Task.query().where('projectId', projectId).sum('estimate as total').first(); +} + +export async function taskTags(taskId: number) { + return Task.relatedQuery('tags').for(taskId).orderBy('label'); +} + +export async function tagTask(task: Task, tagIds: number[]) { + return task.$relatedQuery('tags').relate(tagIds); +} + +export async function tasksWithTag(label: string) { + return Task.query() + .whereExists(Task.relatedQuery('tags').where('label', label)) + .withGraphFetched('tags') + .orderBy('id'); +} + +export async function tasksByAssigneeOrg(orgId: number) { + return Task.query() + .joinRelated('assignee.organization') + .where('assignee:organization.id', orgId) + .select('tasks.*'); +} + +export async function rawWhere(projectId: number) { + return Task.query().whereRaw('project_id = ? and done = ?', [projectId, false]).select('id'); +} + +export async function jsonQuery() { + return User.query().whereJsonSupersetOf('settings', { theme: 'dark' }).select('id'); +} + +export async function taskUpdate(id: number, patch: PartialModelRow) { + return Task.query().findById(id).patch(patch).returning('*'); +} + +export async function firstTaskOfProject(project: Project) { + return project.$relatedQuery('tasks').orderBy('createdAt').first(); +} + +export async function tasksForUserIds(ids: number[]) { + return Task.query() + .whereIn('assigneeId', ids) + .withGraphFetched('assignee(selectEmail)') + .modifiers({ + selectEmail: (qb) => qb.select('id', 'email'), + }); +} + +export async function tasksUnion(a: number, b: number) { + return Task.query() + .where('projectId', a) + .union((qb) => qb.where('projectId', b)) + .orderBy('id'); +} + +export async function taskCounts(projectId: number) { + return Task.query().where('projectId', projectId).select('done').count('* as n').groupBy('done'); +} + +export async function tasksAfterId(id: number) { + return Task.query().where('id', '>', id).orderBy('id').limit(100).offset(0); +} + +export async function upsertTaskGraph(task: Task) { + return Task.query().upsertGraph({ + id: task.id, + comments: [ + { id: 1, body: 'x' }, + { body: 'new', authorId: 1 }, + ], + }); +} + +export function filterDone(qb: QueryBuilder) { + return qb.where('done', true); +} + +export async function doneTasks() { + return Task.query().modify(filterDone).orderBy(ref('tasks.updatedAt'), 'desc'); +} + +export async function taskFetchGraph(task: Task) { + const t = await Task.fetchGraph(task, 'comments.author'); + return t.comments.map((c) => c.author.firstName); +} + +export async function attachmentOwnerComment(a: Attachment) { + return a.$relatedQuery('comment').withGraphFetched('task'); +} + +export async function tagsWithCounts() { + return Tag.query() + .select('tags.*', Tag.relatedQuery('tasks').count().as('taskCount')) + .orderBy('taskCount', 'desc'); +} + +export async function tasksForMilestones(ids: number[]) { + return Task.query().whereIn('milestoneId', ids).where('done', false).first(); +} + +export async function updateTasksRaw(projectId: number) { + return Task.query() + .patch({ priority: raw('priority + 1') }) + .where('projectId', projectId); +} diff --git a/tests/bench/typings/app/users.ts b/tests/bench/typings/app/users.ts new file mode 100644 index 000000000..6fddd8c5d --- /dev/null +++ b/tests/bench/typings/app/users.ts @@ -0,0 +1,200 @@ +import { raw, ref, PartialModelRow, Transaction } from 'objection'; +import { User, Organization, Project, Task, AuditLog } from './models.js'; + +export async function listUsers(orgId: number, page: number) { + return User.query() + .active() + .where('organizationId', orgId) + .select('id', 'email', 'firstName', 'lastName') + .orderBy('lastName') + .page(page, 20); +} + +export async function getUser(id: number) { + const user = await User.query().findById(id).withGraphFetched('[organization, profile]'); + if (user) { + user.organization.name; + user.profile?.bio; + } + return user; +} + +export async function getUserOrFail(id: number) { + return User.query().findById(id).throwIfNotFound(); +} + +export async function findByEmail(email: string) { + return User.query().where('email', email).first(); +} + +export async function findAdmins(orgId: number) { + return User.query() + .where({ organizationId: orgId, role: 'admin' }) + .whereNotNull('lastLoginAt') + .orderBy('lastLoginAt', 'desc') + .limit(10); +} + +export async function searchUsers(term: string) { + return User.query() + .active() + .where((qb) => + qb.where('firstName', 'like', `%${term}%`).orWhere('lastName', 'like', `%${term}%`), + ) + .orderBy([{ column: 'lastName' }, { column: 'firstName', order: 'asc' }]) + .limit(50); +} + +export async function usersWithProjects(orgId: number) { + const users = await User.query() + .where('organizationId', orgId) + .withGraphFetched('projects.[tasks, milestones]') + .orderBy('id'); + return users.map((u) => u.projects.map((p) => p.tasks.length + p.milestones.length)); +} + +export async function usersJoinedOrganization(slug: string) { + return User.query() + .joinRelated('organization') + .where('organization.slug', slug) + .select('users.*'); +} + +export async function countUsers(orgId: number) { + const result = await User.query().where('organizationId', orgId).resultSize(); + return result; +} + +export async function updateUser(id: number, data: PartialModelRow) { + return User.query().patchAndFetchById(id, data); +} + +export async function renameUser(id: number, firstName: string) { + const n = await User.query().patch({ firstName }).where('id', id); + return n; +} + +export async function touchLogin(id: number) { + return User.query().findById(id).patch({ lastLoginAt: new Date() }); +} + +export async function deactivate(ids: number[]) { + return User.query().whereIn('id', ids).patch({ deletedAt: new Date() }); +} + +export async function deleteUser(id: number) { + return User.query().deleteById(id); +} + +export async function createUser(orgId: number, email: string) { + return User.query().insert({ + organizationId: orgId, + email, + firstName: 'New', + lastName: 'User', + role: 'member', + lastLoginAt: null, + settings: { theme: 'light', locale: 'en' }, + deletedAt: null, + }); +} + +export async function createUserWithProfile(orgId: number, email: string) { + return User.query().insertGraph({ + organizationId: orgId, + email, + firstName: 'New', + lastName: 'User', + role: 'member', + profile: { bio: 'hi', avatarUrl: null }, + }); +} + +export async function upsertUser(user: User) { + return User.query().upsertGraph({ id: user.id, firstName: user.firstName }, { relate: true }); +} + +export async function userProjects(user: User) { + return user.$relatedQuery('projects').where('status', 'active').orderBy('name'); +} + +export async function userManager(user: User) { + return user.$relatedQuery('manager'); +} + +export async function relateProject(user: User, projectId: number) { + return user.$relatedQuery('projects').relate(projectId); +} + +export async function unrelateProject(user: User, projectId: number) { + return user.$relatedQuery('projects').unrelate().where('projects.id', projectId); +} + +export async function reportsOf(managerId: number) { + return User.relatedQuery('reports').for(managerId).orderBy('lastName'); +} + +export async function userTaskStats(orgId: number) { + return User.query() + .where('organizationId', orgId) + .select('users.id') + .count('assignedTasks.id as taskCount') + .leftJoinRelated('assignedTasks') + .groupBy('users.id') + .having(raw('count(??)', 'assignedTasks.id'), '>', 5); +} + +export async function usersInTx(trx: Transaction) { + const users = await User.query(trx).active().newest().limit(5); + const org = await Organization.query(trx).findById(users[0].organizationId); + return org; +} + +export async function fetchGraphForUsers(users: User[]) { + const fetched = await User.fetchGraph(users, '[organization, manager.profile]'); + return fetched.map((u) => u.organization.name); +} + +export async function userWithFetchGraph(user: User) { + const u = await user.$fetchGraph('assignedTasks.comments'); + return u.assignedTasks.flatMap((t) => t.comments); +} + +export async function usersByRef() { + return User.query().where(ref('settings:theme').castText(), 'dark').select('id'); +} + +export async function logAction(userId: number, action: string) { + return AuditLog.query().insert({ userId, action, payload: {}, createdAt: new Date() }); +} + +export async function recentLogs(userId: number) { + return AuditLog.query() + .where('userId', userId) + .withGraphFetched('user.organization') + .orderBy('createdAt', 'desc') + .limit(20); +} + +export async function usersWithOpenTasks() { + return User.query() + .whereExists(User.relatedQuery('assignedTasks').where('done', false)) + .select('id', 'email'); +} + +export async function userProjectNames(userId: number) { + return Project.query() + .joinRelated('members') + .where('members.id', userId) + .distinct('projects.name') + .orderBy('projects.name'); +} + +export async function userTasksSubquery(userId: number) { + return Task.query() + .whereIn( + 'projectId', + Project.query().select('id').joinRelated('members').where('members.id', userId), + ) + .orderBy('priority', 'desc'); +} diff --git a/tests/bench/typings/bench.js b/tests/bench/typings/bench.js new file mode 100644 index 000000000..ec7441e76 --- /dev/null +++ b/tests/bench/typings/bench.js @@ -0,0 +1,130 @@ +// Measures the check time of the typings in strict and loose mode, on a +// sample app generated from the modules in ./app: `copies` copies of its +// models, each with `queries` copies of its query modules. +// +// node tests/bench/typings/bench.js [--runs 3] [--copies 2] [--queries 4] +// [--tsc path/to/tsc.js]... +// +// Without --tsc, the local TypeScript runs. Compilers that support +// --generateTrace (TypeScript 5) also report the slowest file. + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; + +const dirname = import.meta.dirname; +const root = path.resolve(dirname, '../../..'); + +const { values: options } = parseArgs({ + options: { + runs: { type: 'string', default: '3' }, + copies: { type: 'string', default: '2' }, + queries: { type: 'string', default: '4' }, + tsc: { type: 'string', multiple: true, default: [] }, + }, +}); + +const runs = +options.runs; +const compilers = options.tsc.length + ? options.tsc.map((file) => path.resolve(file)) + : [path.join(root, 'node_modules/typescript/bin/tsc')]; + +// Generate the sample app: +const generatedDir = path.join(dirname, '.generated'); +fs.rmSync(generatedDir, { recursive: true, force: true }); +const appDir = path.join(dirname, 'app'); +const modelFiles = ['base.ts', 'models.ts']; +let lines = 0; +for (let copy = 0; copy < +options.copies; copy++) { + const copyDir = path.join(generatedDir, 'app', `copy${copy}`); + fs.mkdirSync(copyDir, { recursive: true }); + for (const file of fs.readdirSync(appDir)) { + const source = fs.readFileSync(path.join(appDir, file), 'utf8'); + const count = modelFiles.includes(file) ? 1 : +options.queries; + for (let query = 0; query < count; query++) { + const name = count > 1 ? file.replace('.ts', `${query}.ts`) : file; + fs.writeFileSync(path.join(copyDir, name), source); + lines += source.split('\n').length; + } + } +} + +// One project per mode. Loose mode opts out with the `TypeConfig` augmentation. +// The checks for declaration emit of the type tests aren't measured: +const extendsConfig = path.relative(generatedDir, path.join(root, 'tsconfig.json')); +for (const mode of ['strict', 'loose']) { + const include = ['app/**/*.ts', ...(mode === 'loose' ? ['loose.ts'] : [])]; + const compilerOptions = { declaration: false }; + fs.writeFileSync( + path.join(generatedDir, `tsconfig.${mode}.json`), + JSON.stringify({ extends: extendsConfig, compilerOptions, include, exclude: [] }, null, 2), + ); +} +fs.writeFileSync( + path.join(generatedDir, 'loose.ts'), + `declare module 'objection' {\n interface TypeConfig {\n strict: false;\n }\n}\n\nexport {};\n`, +); + +console.log(`Sample: ${+options.copies} x 10 models, ${lines} lines, ${runs} runs.`); + +for (const compiler of compilers) { + const version = run(compiler, ['--version']).trim(); + const results = {}; + for (const mode of ['strict', 'loose']) { + const project = path.join(generatedDir, `tsconfig.${mode}.json`); + const times = []; + let instantiations; + for (let i = 0; i < runs; i++) { + const output = run(compiler, ['-p', project, '--extendedDiagnostics']); + times.push(parseFloat(output.match(/Check time:\s+([\d.]+)s/)[1])); + instantiations = output.match(/Instantiations:\s+(\d+)/)[1]; + } + times.sort((a, b) => a - b); + results[mode] = { time: times[runs >> 1], instantiations }; + console.log( + `${version}, ${mode}: check time ${results[mode].time}s (median), ${instantiations} instantiations`, + ); + } + console.log( + `${version}: strict / loose check time ${(results.strict.time / results.loose.time).toFixed(2)}x`, + ); + + // The slowest file in strict mode, from the trace of compilers that write one: + const traceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'objection-trace-')); + try { + run(compiler, [ + '-p', + path.join(generatedDir, 'tsconfig.strict.json'), + '--generateTrace', + traceDir, + ]); + const events = JSON.parse(fs.readFileSync(path.join(traceDir, 'trace.json'), 'utf8')); + // checkSourceFile is traced as begin and end events per thread, in + // microseconds: + const files = []; + const openFiles = {}; + for (const event of events.filter((event) => event.name === 'checkSourceFile')) { + if (event.ph === 'B') { + openFiles[event.tid] = { path: event.args.path, start: event.ts }; + files.push(openFiles[event.tid]); + } else { + openFiles[event.tid].duration = event.ts - openFiles[event.tid].start; + } + } + const slowest = files.sort((a, b) => b.duration - a.duration)[0]; + console.log( + `${version}, strict: slowest file ${slowest.path.split('/.generated/')[1]} ${(slowest.duration / 1e6).toFixed(2)}s`, + ); + } catch { + // The compiler doesn't support --generateTrace. + } finally { + fs.rmSync(traceDir, { recursive: true, force: true }); + } +} + +function run(compiler, args) { + // tsc exits with 1 on type errors, which the sample must not have. + return execFileSync(process.execPath, [compiler, ...args], { cwd: root, encoding: 'utf8' }); +} diff --git a/tests/bench/typings/tsconfig.json b/tests/bench/typings/tsconfig.json new file mode 100644 index 000000000..46e14e23c --- /dev/null +++ b/tests/bench/typings/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["app/**/*.ts"], + "exclude": [] +} diff --git a/tests/integration/belongsToMany.js b/tests/integration/belongsToMany.js new file mode 100644 index 000000000..c66408c4d --- /dev/null +++ b/tests/integration/belongsToMany.js @@ -0,0 +1,734 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import { Model } from 'objection'; + +export default (session) => { + describe('BelongsToManyRelation #2667', () => { + const { knex } = session; + let onBeforeUpdate = null; + let nextTagId = 10; + + class Tag extends Model { + static get tableName() { + return 'belongsToManyTag'; + } + + static async beforeUpdate(args) { + await onBeforeUpdate?.(args); + } + + // Generate ids like the database would, to be known only after the insert: + $beforeInsert() { + this.id ??= nextTagId++; + } + } + + class Post extends Model { + static get tableName() { + return 'belongsToManyPost'; + } + + static get jsonAttributes() { + return ['tagIds', 'relatedPostIds', 'data']; + } + + static get relationMappings() { + return { + tags: { + relation: Model.BelongsToManyRelation, + modelClass: Tag, + join: { + from: 'belongsToManyPost.tagIds', + to: 'belongsToManyTag.id', + }, + }, + + relatedPosts: { + relation: Model.BelongsToManyRelation, + modelClass: Post, + join: { + from: 'belongsToManyPost.relatedPostIds', + to: 'belongsToManyPost.id', + }, + }, + + filteredTags: { + relation: Model.BelongsToManyRelation, + modelClass: Tag, + modify: (query) => query.where('name', '<', 'c'), + join: { + from: 'belongsToManyPost.tagIds', + to: 'belongsToManyTag.id', + }, + }, + + dataTags: { + relation: Model.BelongsToManyRelation, + modelClass: Tag, + join: { + from: 'belongsToManyPost.data:tagIds', + to: 'belongsToManyTag.id', + }, + }, + + ...(session.isPostgres() && { + arrayTags: { + relation: Model.BelongsToManyRelation, + modelClass: Tag, + join: { + from: 'belongsToManyPost.tagIdsArray', + to: 'belongsToManyTag.id', + }, + }, + }), + }; + } + } + + async function populate() { + nextTagId = 10; + await knex('belongsToManyPost').delete(); + await knex('belongsToManyTag').delete(); + + await knex('belongsToManyTag').insert([ + { id: 1, name: 'c' }, + { id: 2, name: 'b' }, + { id: 3, name: 'a' }, + { id: 4, name: 'd' }, + ]); + + await knex('belongsToManyPost').insert( + [ + { id: 1, tagIds: [3, 1], relatedPostIds: [2, 3] }, + { id: 2, tagIds: [1, 2, 4], relatedPostIds: [1] }, + { id: 3, tagIds: [], relatedPostIds: null }, + { id: 4, tagIds: null, relatedPostIds: [] }, + { id: 5, tagIds: [9], relatedPostIds: [] }, + ].map((post) => ({ + ...post, + tagIds: post.tagIds && JSON.stringify(post.tagIds), + relatedPostIds: post.relatedPostIds && JSON.stringify(post.relatedPostIds), + // Documents without the keys and no document at all hold no keys: + data: + post.tagIds && + JSON.stringify(post.tagIds.length ? { tagIds: post.tagIds, other: post.id } : {}), + ...(session.isPostgres() && { tagIdsArray: post.tagIds }), + })), + ); + } + + beforeAll(async () => { + Tag.knex(knex); + Post.knex(knex); + + await knex.schema + .dropTableIfExists('belongsToManyPost') + .dropTableIfExists('belongsToManyTag') + .createTable('belongsToManyTag', (t) => { + t.integer('id').primary(); + t.string('name'); + }) + .createTable('belongsToManyPost', (t) => { + t.integer('id').primary(); + + if (session.isPostgres()) { + t.jsonb('tagIds'); + t.json('relatedPostIds'); + t.jsonb('data'); + t.specificType('tagIdsArray', 'integer[]'); + } else if (session.isMySql()) { + t.json('tagIds'); + t.json('relatedPostIds'); + t.json('data'); + } else { + t.text('tagIds'); + t.text('relatedPostIds'); + t.text('data'); + } + }); + + await populate(); + }); + + afterAll(async () => { + await knex.schema + .dropTableIfExists('belongsToManyPost') + .dropTableIfExists('belongsToManyTag'); + }); + + function tagIds(posts, relation = 'tags') { + return posts.map((post) => post[relation].map((tag) => tag.id).sort()); + } + + const allTagIds = [[1, 3], [1, 2, 4], [], [], []]; + + it('withGraphFetched() should fetch the related models of each owner', async () => { + const posts = await Post.query().withGraphFetched('tags').orderBy('id'); + + expect(tagIds(posts)).toEqual(allTagIds); + }); + + it('withGraphFetched() should keep the order of the query', async () => { + const posts = await Post.query() + .withGraphFetched('tags(byName)') + .modifiers({ byName: (query) => query.orderBy('name') }) + .orderBy('id'); + + expect(posts.map((post) => post.tags.map((tag) => tag.name))).toEqual([ + ['a', 'c'], + ['b', 'c', 'd'], + [], + [], + [], + ]); + }); + + it('withGraphFetched() should fetch nested relations of the same model class', async () => { + const posts = await Post.query() + .withGraphFetched('relatedPosts.[tags, relatedPosts]') + .whereIn('id', [1, 2]) + .orderBy('id'); + + expect(posts[0].relatedPosts.map((post) => post.id).sort()).toEqual([2, 3]); + expect(tagIds(posts[0].relatedPosts.sort((a, b) => a.id - b.id))).toEqual([[1, 2, 4], []]); + expect(posts[1].relatedPosts[0].relatedPosts.map((post) => post.id).sort()).toEqual([2, 3]); + }); + + it('withGraphFetched() should omit the implicitly selected keys', async () => { + const posts = await Post.query().select('id').withGraphFetched('tags').findById(1); + + expect(posts.tagIds).toBeUndefined(); + expect(posts.tags.map((tag) => tag.id).sort()).toEqual([1, 3]); + }); + + it('withGraphFetched() should throw for limit() for each owner', async () => { + await expect( + Post.query() + .withGraphFetched('tags(first)') + .modifiers({ first: (query) => query.limit(1) }), + ).rejects.toThrow( + 'Post.relationMappings.tags: limit(), offset() and first() are not supported for ' + + 'each owner. Pass `limitPerParent: false` to the graph options to limit all owners ' + + 'together.', + ); + }); + + it('withGraphFetched() should limit all owners together with limitPerParent: false', async () => { + const posts = await Post.query() + .withGraphFetched('tags(first)', { limitPerParent: false }) + .modifiers({ first: (query) => query.orderBy('id').limit(1) }) + .orderBy('id'); + + expect(tagIds(posts)).toEqual([[1], [1], [], [], []]); + }); + + it('$fetchGraph() should fetch the related models of owners built in memory', async () => { + const posts = [Post.fromJson({ tagIds: [2, 4] }), Post.fromJson({ tagIds: [4, 4] })]; + + await Post.fetchGraph(posts, 'tags'); + + expect(tagIds(posts)).toEqual([[2, 4], [4]]); + }); + + it('$fetchGraph() should fetch more keys than the databases take parameters', async () => { + const post = Post.fromJson({ tagIds: Array.from({ length: 33000 }, (_, i) => i + 4) }); + + await post.$fetchGraph('tags'); + + expect(post.tags.map((tag) => tag.id)).toEqual([4]); + }); + + it('$relatedQuery() should keep the result shape of queries for empty arrays', async () => { + const post = await Post.query().findById(3); + + expect(await post.$relatedQuery('tags').patch({ name: 'x' })).toBe(0); + expect(await post.$relatedQuery('tags').resultSize()).toBe(0); + }); + + // SQL Server doesn't support JSON field expressions: + if (!session.isMsSql()) { + it('relatedQuery() should find the related models of JSON field expressions', async () => { + const posts = await Post.query().withGraphFetched('dataTags').orderBy('id'); + + expect(tagIds(posts, 'dataTags')).toEqual(allTagIds); + expect( + (await Post.relatedQuery('dataTags').for([2]).orderBy('id')).map((tag) => tag.id), + ).toEqual([1, 2, 4]); + expect( + (await Post.relatedQuery('dataTags').for(Post.query().where('id', 1)).orderBy('id')).map( + (tag) => tag.id, + ), + ).toEqual([1, 3]); + }); + } + + it('$fetchGraph() should throw for keys that are no arrays', async () => { + const post = Post.fromJson({ id: 1 }); + post.tagIds = '[1]'; + + await expect(post.$fetchGraph('tags')).rejects.toThrow( + 'Post.relationMappings.tags: the join property "belongsToManyPost.tagIds" must hold an ' + + 'array of keys, not "[1]". Add it to the jsonAttributes of Post if it holds a JSON array.', + ); + }); + + it('$relatedQuery() should find the related models', async () => { + const post = await Post.query().findById(2); + const tags = await post.$relatedQuery('tags').orderBy('id'); + + expect(tags.map((tag) => tag.id)).toEqual([1, 2, 4]); + expect(post.tags).toBeUndefined(); + }); + + it('$relatedQuery() should find nothing for empty arrays', async () => { + const post = await Post.query().findById(3); + + expect(await post.$relatedQuery('tags')).toEqual([]); + }); + + it('relatedQuery() should find the related models of owner ids', async () => { + const tags = await Post.relatedQuery('tags').for([1, 2]).orderBy('id'); + + expect(tags.map((tag) => tag.id)).toEqual([1, 2, 3, 4]); + }); + + it('relatedQuery() should find the related models of an owner query', async () => { + const tags = await Post.relatedQuery('tags') + .for(Post.query().orderBy('id').limit(1)) + .orderBy('id'); + + expect(tags.map((tag) => tag.id)).toEqual([1, 3]); + }); + + it('relatedQuery() should work as a subquery', async () => { + const posts = await Post.query() + .select('id', Post.relatedQuery('tags').count().as('tagCount')) + .whereExists(Post.relatedQuery('tags').where('name', 'd')) + .orWhere('id', 1) + .orderBy('id'); + + expect(posts.map((post) => [post.id, Number(post.tagCount)])).toEqual([ + [1, 2], + [2, 3], + ]); + }); + + it('withGraphJoined() should fetch the related models of each owner', async () => { + const posts = await Post.query().withGraphJoined('tags').orderBy('id'); + + expect(tagIds(posts)).toEqual(allTagIds); + }); + + it('withGraphJoined() should fetch nested relations of the same model class', async () => { + const posts = await Post.query() + .withGraphJoined('relatedPosts.tags') + .whereIn('belongsToManyPost.id', [1, 2]) + .orderBy('belongsToManyPost.id') + .orderBy('relatedPosts.id'); + + expect(posts.map((post) => post.relatedPosts.map((it) => it.id))).toEqual([[2, 3], [1]]); + expect(tagIds(posts[0].relatedPosts)).toEqual([[1, 2, 4], []]); + }); + + it('joinRelated() should join the related rows', async () => { + const posts = await Post.query() + .select('belongsToManyPost.id') + .joinRelated('tags') + .where('tags.name', 'c') + .orderBy('belongsToManyPost.id'); + + expect(posts.map((post) => post.id)).toEqual([1, 2]); + }); + + it('modifyJoin() should modify the join', async () => { + const posts = await Post.query() + .select('belongsToManyPost.id', 'tags.id as tagId') + .joinRelated('tags') + .modifyJoin('tags', (join, { related }) => { + join.andOnVal(`${related}.name`, '<', 'c'); + }) + .orderBy('belongsToManyPost.id') + .orderBy('tags.id'); + + expect(posts.map((post) => [post.id, post.tagId])).toEqual([ + [1, 3], + [2, 2], + ]); + }); + + it('patch() should patch the related rows', async () => { + const post = Post.fromJson({ tagIds: [3] }); + + expect(await post.$relatedQuery('tags').patch({ name: 'e' })).toBe(1); + expect((await Tag.query().findById(3)).name).toBe('e'); + + await Tag.query().findById(3).patch({ name: 'a' }); + }); + + it('patch() should find the related rows in asFindQuery() of the static hooks', async () => { + const post = await Post.query().findById(2); + let found = null; + + onBeforeUpdate = async ({ asFindQuery }) => { + found = await asFindQuery().select('id').orderBy('id'); + }; + + try { + await post.$relatedQuery('tags').patch({ name: 'x' }).where('id', '>', 1); + } finally { + onBeforeUpdate = null; + } + + expect(found.map((tag) => tag.id)).toEqual([2, 4]); + + await knex('belongsToManyTag').update({ name: 'b' }).where('id', 2); + await knex('belongsToManyTag').update({ name: 'd' }).where('id', 4); + }); + + describe('mutations', () => { + beforeEach(populate); + afterAll(populate); + + async function allTagIds(prop = 'tagIds') { + const posts = await Post.query().orderBy('id'); + return posts.map((post) => post[prop]); + } + + async function allData() { + const posts = await Post.query().orderBy('id'); + return posts.map((post) => post.data); + } + + async function allTags() { + const tags = await Tag.query().orderBy('id'); + return tags.map((tag) => `${tag.id}${tag.name}`); + } + + it('relate() should add the keys to the array of the owner', async () => { + const post = await Post.query().findById(3); + + expect(await post.$relatedQuery('tags').relate([2, 1, 2])).toBe(1); + expect(post.tagIds).toEqual([2, 1]); + expect(await allTagIds()).toEqual([[3, 1], [1, 2, 4], [2, 1], null, [9]]); + }); + + it('relate() should add the keys to the arrays of owner ids and queries', async () => { + expect(await Post.relatedQuery('tags').for([1, 2, 4]).relate({ id: 4 })).toBe(2); + expect( + await Post.relatedQuery('tags') + .for(Post.query().where('id', '>', 2)) + .relate(Tag.fromJson({ id: 3 })), + ).toBe(3); + expect(await allTagIds()).toEqual([[3, 1, 4], [1, 2, 4], [3], [4, 3], [9, 3]]); + }); + + it('relate() should not change the arrays that hold the keys', async () => { + const post = await Post.query().findById(1); + + expect(await post.$relatedQuery('tags').relate(3)).toBe(0); + // The stored key keeps its value: + expect(await post.$relatedQuery('tags').relate('1')).toBe(0); + expect(await allTagIds()).toEqual([[3, 1], [1, 2, 4], [], null, [9]]); + }); + + it('unrelate() should remove the keys of the related rows that the query finds', async () => { + const post = await Post.query().findById(2); + + expect(await post.$relatedQuery('tags').unrelate().where('name', 'b')).toBe(1); + expect(post.tagIds).toEqual([1, 4]); + expect(await Post.relatedQuery('tags').for([1, 5]).unrelate()).toBe(1); + expect(await allTagIds()).toEqual([[], [1, 4], [], null, [9]]); + expect(await allTags()).toEqual(['1c', '2b', '3a', '4d']); + }); + + it('insert() should insert the related model and add its key', async () => { + const post = await Post.query().findById(4); + const tag = await post.$relatedQuery('tags').insert({ id: 5, name: 'e' }); + + expect(tag.id).toBe(5); + expect(post.tagIds).toEqual([5]); + expect(await allTagIds()).toEqual([[3, 1], [1, 2, 4], [], [5], [9]]); + expect(await allTags()).toEqual(['1c', '2b', '3a', '4d', '5e']); + }); + + it('relate(), unrelate(), insert() and delete() should not call the find hooks', async () => { + const post = await Post.query().findById(2); + const hooks = []; + + // Hooks that filter all rows out, e.g. for soft deletes or access control: + for (const modelClass of [Post, Tag]) { + modelClass.beforeFind = () => hooks.push(`${modelClass.name}.beforeFind`); + modelClass.afterFind = () => (hooks.push(`${modelClass.name}.afterFind`), []); + } + + try { + expect(await post.$relatedQuery('tags').relate(3)).toBe(1); + expect(await post.$relatedQuery('tags').unrelate().where('id', 1)).toBe(1); + await post.$relatedQuery('tags').insert({ id: 5, name: 'e' }); + expect(await post.$relatedQuery('tags').delete().where('id', 2)).toBe(1); + } finally { + for (const modelClass of [Post, Tag]) { + delete modelClass.beforeFind; + delete modelClass.afterFind; + } + } + + expect(hooks).toEqual([]); + expect(post.tagIds).toEqual([4, 3, 5]); + expect(await allTagIds()).toEqual([[3, 1], [4, 3, 5], [], null, [9]]); + }); + + it('delete() and insert() should do nothing if resolved without executing', async () => { + const post = await Post.query().findById(2); + + await post.$relatedQuery('tags').delete().resolve(0); + await post.$relatedQuery('tags').insert({ id: 5, name: 'e' }).resolve({ id: 5 }); + + expect(await allTagIds()).toEqual([[3, 1], [1, 2, 4], [], null, [9]]); + expect(await allTags()).toEqual(['1c', '2b', '3a', '4d']); + }); + + // SQL Server doesn't support JSON field expressions: + if (!session.isMsSql()) { + it('relate(), unrelate() and delete() should only write the array of a JSON field expression', async () => { + expect(await Post.relatedQuery('dataTags').for([1, 3, 4]).relate([4, 3])).toBe(3); + expect(await Post.relatedQuery('dataTags').for(1).unrelate().where('id', 1)).toBe(1); + expect(await Post.relatedQuery('dataTags').for(2).delete().where('id', 2)).toBe(1); + expect(await allData()).toEqual([ + { tagIds: [3, 4], other: 1 }, + { tagIds: [1, 4], other: 2 }, + { tagIds: [4, 3] }, + { tagIds: [4, 3] }, + { tagIds: [9], other: 5 }, + ]); + }); + + it('upsertGraph() should only write the array of a JSON field expression', async () => { + await Post.query().upsertGraph( + [ + { id: 1, dataTags: [{ id: 1 }, { id: 2 }] }, + { id: 3, dataTags: [{ id: 3 }] }, + { id: 4, dataTags: [{ id: 4 }] }, + ], + { relate: true, unrelate: true }, + ); + + expect(await allData()).toEqual([ + { tagIds: [1, 2], other: 1 }, + { tagIds: [1, 2, 4], other: 2 }, + { tagIds: [3] }, + { tagIds: [4] }, + { tagIds: [9], other: 5 }, + ]); + }); + } + + it('delete() should delete the related rows and remove their keys', async () => { + const post = await Post.query().findById(2); + + expect(await post.$relatedQuery('tags').delete().where('name', '<', 'd')).toBe(2); + expect(post.tagIds).toEqual([4]); + // Other owners keep the keys, as with any rows deleted without the relation: + expect(await allTagIds()).toEqual([[3, 1], [4], [], null, [9]]); + expect(await allTags()).toEqual(['3a', '4d']); + }); + + it('insertGraph() should insert and relate the related models', async () => { + const post = await Post.query().insertGraph( + { + id: 6, + tags: [{ name: 'e' }, { '#dbRef': 1 }], + relatedPosts: [{ id: 7, tags: [{ '#ref': 'f' }, { '#id': 'f', name: 'f' }] }], + }, + { allowRefs: true }, + ); + + const [e, f] = post.tags[0].id === 10 ? [10, 11] : [11, 10]; + + expect(post.tagIds).toEqual([e, 1]); + expect(post.relatedPostIds).toEqual([7]); + expect((await allTagIds()).slice(5)).toEqual([[e, 1], [f]]); + expect(await allTags()).toEqual( + ['1c', '2b', '3a', '4d'].concat(e === 10 ? ['10e', '11f'] : ['10f', '11e']), + ); + }); + + it('$relatedQuery().insertGraph() should add the keys to the owner', async () => { + const post = await Post.query().findById(3); + + await post.$relatedQuery('tags').insertGraph({ id: 5, name: 'e' }); + + expect(await allTagIds()).toEqual([[3, 1], [1, 2, 4], [5], null, [9]]); + }); + + for (const fetchStrategy of ['OnlyIdentifiers', 'OnlyNeeded', 'Everything']) { + describe(`upsertGraph() (fetchStrategy: ${fetchStrategy})`, () => { + const upsertGraph = (graph, options) => + Post.query().upsertGraph(graph, { fetchStrategy, ...options }); + + it('should write the keys of the graph and delete the rows it leaves out', async () => { + await upsertGraph( + { + id: 2, + tags: [ + { id: 4, name: 'x' }, + { id: 5, name: 'e' }, + ], + }, + { insertMissing: true }, + ); + + expect(await allTagIds()).toEqual([[3, 1], [4, 5], [], null, [9]]); + expect(await allTags()).toEqual(['3a', '4x', '5e']); + }); + + it('should only remove the keys with unrelate', async () => { + await upsertGraph( + { id: 2, tags: [{ id: 3 }, { id: 1 }] }, + { relate: true, unrelate: true }, + ); + + expect(await allTagIds()).toEqual([[3, 1], [1, 3], [], null, [9]]); + expect(await allTags()).toEqual(['1c', '2b', '3a', '4d']); + }); + + it('should keep the keys of the rows it leaves out with noUnrelate', async () => { + await upsertGraph({ id: 2, tags: [{ id: 1 }] }, { unrelate: true, noUnrelate: true }); + + expect(await allTagIds()).toEqual([[3, 1], [1, 2, 4], [], null, [9]]); + }); + + it('should keep the keys of rows that the relation filters out', async () => { + await upsertGraph( + { id: 2, filteredTags: [{ id: 3 }] }, + { relate: true, unrelate: true }, + ); + + expect(await allTagIds()).toEqual([[3, 1], [1, 4, 3], [], null, [9]]); + }); + + it('should keep the keys of missing rows', async () => { + await upsertGraph({ id: 5, tags: [{ id: 1 }] }, { relate: true }); + + expect(await allTagIds()).toEqual([[3, 1], [1, 2, 4], [], null, [9, 1]]); + }); + + it('should upsert the arrays of related owners in their own upsert', async () => { + await upsertGraph( + { id: 3, relatedPosts: [{ id: 2, tags: [{ id: 1 }] }] }, + { relate: true, noDelete: true }, + ); + + expect(await allTagIds()).toEqual([[3, 1], [1, 2, 4], [], null, [9]]); + expect(await allTagIds('relatedPostIds')).toEqual([[2, 3], [1], [2], [], []]); + + await upsertGraph({ id: 3, relatedPosts: [{ id: 2, tags: [{ id: 1 }] }] }); + + expect(await allTagIds()).toEqual([[3, 1], [1], [], null, [9]]); + expect(await allTags()).toEqual(['1c', '3a']); + }); + + it('should keep the keys of the rows it keeps', async () => { + await upsertGraph( + { id: 2, tags: [{ id: 5, name: 'e' }] }, + { insertMissing: true, noDelete: true }, + ); + + expect(await allTagIds()).toEqual([[3, 1], [1, 2, 4, 5], [], null, [9]]); + }); + + it('should remove the keys of models marked with #unrelate', async () => { + await upsertGraph( + { id: 2, tags: [{ id: 1 }, { id: 2, '#unrelate': true }, { id: 4 }] }, + { allowMarkers: true }, + ); + + expect(await allTagIds()).toEqual([[3, 1], [1, 4], [], null, [9]]); + expect(await allTags()).toEqual(['1c', '2b', '3a', '4d']); + }); + + it('should clear the array for an empty graph', async () => { + await upsertGraph({ id: 1, tags: [] }, { unrelate: true }); + + expect(await allTagIds()).toEqual([[], [1, 2, 4], [], null, [9]]); + }); + + it('should keep the array if the graph leaves out the relation', async () => { + await upsertGraph({ id: 1, relatedPosts: [{ id: 2, tags: [{ id: 1 }] }, { id: 3 }] }); + + expect(await allTagIds()).toEqual([[3, 1], [1], [], null, [9]]); + }); + + it('should not write unchanged arrays', async () => { + const queries = []; + const collect = ({ sql }) => queries.push(sql); + + knex.on('query', collect); + + try { + await upsertGraph({ id: 1, tags: [{ id: 3 }, { id: 1 }] }); + } finally { + knex.removeListener('query', collect); + } + + expect(queries.filter((sql) => /^update/i.test(sql))).toEqual([]); + }); + }); + } + + if (session.isPostgres()) { + it('relate() and upsertGraph() should write native arrays', async () => { + await Post.relatedQuery('arrayTags').for(3).relate([4, 2]); + await Post.query().upsertGraph( + { id: 1, arrayTags: [{ id: 2 }] }, + { relate: true, unrelate: true }, + ); + + expect(await allTagIds('tagIdsArray')).toEqual([[2], [1, 2, 4], [4, 2], null, [9]]); + }); + } + }); + + it('should throw for composite keys', () => { + class CompositePost extends Post { + static get relationMappings() { + return { + tags: { + relation: Model.BelongsToManyRelation, + modelClass: Tag, + join: { + from: ['belongsToManyPost.id', 'belongsToManyPost.tagIds'], + to: ['belongsToManyTag.id', 'belongsToManyTag.name'], + }, + }, + }; + } + } + + expect(() => CompositePost.getRelations()).toThrow( + 'CompositePost.relationMappings.tags: composite keys are not supported.', + ); + }); + + if (session.isPostgres()) { + describe('native arrays', () => { + it('withGraphFetched()', async () => { + const posts = await Post.query().withGraphFetched('arrayTags').orderBy('id'); + + expect(tagIds(posts, 'arrayTags')).toEqual(allTagIds); + }); + + it('withGraphJoined()', async () => { + const posts = await Post.query().withGraphJoined('arrayTags').orderBy('id'); + + expect(tagIds(posts, 'arrayTags')).toEqual(allTagIds); + }); + + it('relatedQuery()', async () => { + const tags = await Post.relatedQuery('arrayTags').for(1).orderBy('id'); + + expect(tags.map((tag) => tag.id)).toEqual([1, 3]); + }); + }); + } + }); +}; diff --git a/tests/integration/compositeKeys.js b/tests/integration/compositeKeys.js index cc837b6ef..7f859752f 100644 --- a/tests/integration/compositeKeys.js +++ b/tests/integration/compositeKeys.js @@ -1,9 +1,9 @@ -const { Model } = require('../../'); -const expect = require('expect.js'); -const mockKnexFactory = require('../../testUtils/mockKnex'); -const { sortBy } = require('../../testUtils/testUtils'); +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { Model } from 'objection'; +import mockKnexFactory from '../../testUtils/mockKnex.js'; +import { sortBy } from '../../testUtils/testUtils.js'; -module.exports = (session) => { +export default (session) => { describe('Composite keys', () => { let mockKnex; let queries; @@ -11,14 +11,14 @@ module.exports = (session) => { let A; let B; - before(() => { + beforeAll(() => { mockKnex = mockKnexFactory(session.knex, function (mock, oldImpl, args) { queries.push(this.toSQL()); return oldImpl.apply(this, args); }); }); - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('A_B_extra') .dropTableIfExists('A_B') @@ -53,7 +53,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema .dropTableIfExists('A_B_extra') .dropTableIfExists('A_B') @@ -61,7 +61,7 @@ module.exports = (session) => { .dropTableIfExists('B'); }); - before(() => { + beforeAll(() => { class ModelA extends Model { static get tableName() { return 'A'; @@ -162,33 +162,29 @@ module.exports = (session) => { return A.query() .insert({ id1: 1, id2: '1', aval: 'a' }) .then((ret) => { - expect(ret).to.eql({ id1: 1, id2: '1', aval: 'a' }); + expect(ret).toEqual({ id1: 1, id2: '1', aval: 'a' }); return A.query().insertAndFetch({ id1: 1, id2: '2', aval: 'b' }); }) .then((ret) => { - expect(ret.$toJson()).to.eql({ id1: 1, id2: '2', aval: 'b', bid3: null, bid4: null }); + expect(ret.$toJson()).toEqual({ id1: 1, id2: '2', aval: 'b', bid3: null, bid4: null }); return session.knex('A').orderBy('id2'); }) .then((rows) => { - expect(rows).to.eql([ + expect(rows).toEqual([ { id1: 1, id2: '1', aval: 'a', bid3: null, bid4: null }, { id1: 1, id2: '2', aval: 'b', bid3: null, bid4: null }, ]); }); }); - it('insert should fail (unique violation)', (done) => { - A.query() - .insert({ id1: 1, id2: '1', aval: 'a' }) - .then(() => { - return A.query().insert({ id1: 1, id2: '1', aval: 'b' }); - }) - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - done(); - }); + it('insert should fail (unique violation)', () => { + return expect( + A.query() + .insert({ id1: 1, id2: '1', aval: 'a' }) + .then(() => { + return A.query().insert({ id1: 1, id2: '1', aval: 'b' }); + }), + ).rejects.toThrow(); }); }); @@ -211,7 +207,7 @@ module.exports = (session) => { return A.query() .findById([2, '2']) .then((model) => { - expect(model.toJSON()).to.eql({ id1: 2, id2: '2', aval: 'c', bid3: null, bid4: null }); + expect(model.toJSON()).toEqual({ id1: 2, id2: '2', aval: 'c', bid3: null, bid4: null }); }); }); @@ -222,7 +218,7 @@ module.exports = (session) => { [2, '2'], ]) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id1: 1, id2: '1', aval: 'a', bid3: null, bid4: null }, { id1: 2, id2: '2', aval: 'c', bid3: null, bid4: null }, ]); @@ -234,7 +230,7 @@ module.exports = (session) => { .whereComposite(['id1', 'id2'], [2, '2']) .first() .then((model) => { - expect(model.toJSON()).to.eql({ id1: 2, id2: '2', aval: 'c', bid3: null, bid4: null }); + expect(model.toJSON()).toEqual({ id1: 2, id2: '2', aval: 'c', bid3: null, bid4: null }); }); }); @@ -250,7 +246,7 @@ module.exports = (session) => { ) .orderBy(['id1', 'id2']) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id1: 1, id2: '2', aval: 'b', bid3: null, bid4: null }, { id1: 2, id2: '3', aval: 'd', bid3: null, bid4: null }, { id1: 3, id2: '3', aval: 'e', bid3: null, bid4: null }, @@ -270,7 +266,7 @@ module.exports = (session) => { ) .orderBy(['id1', 'id2']) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id1: 1, id2: '1', aval: 'a', bid3: null, bid4: null }, { id1: 2, id2: '2', aval: 'c', bid3: null, bid4: null }, ]); @@ -298,11 +294,11 @@ module.exports = (session) => { .updateAndFetchById([1, '2'], { aval: 'updated' }) .orderBy(['id1', 'id2']) .then((model) => { - expect(model).to.eql({ id1: 1, id2: '2', aval: 'updated', bid3: null, bid4: null }); + expect(model).toEqual({ id1: 1, id2: '2', aval: 'updated', bid3: null, bid4: null }); return session.knex('A').orderBy(['id1', 'id2']); }) .then((rows) => { - expect(rows).to.eql([ + expect(rows).toEqual([ { id1: 1, id2: '1', aval: 'a', bid3: null, bid4: null }, { id1: 1, id2: '2', aval: 'updated', bid3: null, bid4: null }, { id1: 2, id2: '2', aval: 'c', bid3: null, bid4: null }, @@ -376,8 +372,8 @@ module.exports = (session) => { { insertMissing: true }, ); - expect(queries.length).to.equal(2); - expect(await A.query().findById([1000, "doesn't exist in the db"])).not.equal(undefined); + expect(queries.length).toBe(2); + expect(await A.query().findById([1000, "doesn't exist in the db"])).not.toBeUndefined(); }); it('should work when updating the root', async () => { @@ -387,18 +383,18 @@ module.exports = (session) => { aval: 'updated', }); - expect(result.aval).to.equal('updated'); - expect(queries.length).to.equal(2); - expect(queries[0].bindings).to.eql([1, '1']); + expect(result.aval).toBe('updated'); + expect(queries.length).toBe(2); + expect(queries[0].bindings).toEqual([1, '1']); if (session.isPostgres()) { - expect(queries[0].sql).to.equal( + expect(queries[0].sql).toBe( 'select "A"."id1", "A"."id2", "A"."aval" from "A" where ("A"."id1", "A"."id2") in ((?, ?))', ); } const fromDb = await A.query().findById([1, '1']); - expect(fromDb.aval).to.equal('updated'); + expect(fromDb.aval).toBe('updated'); }); it('should work when `insertMissing` option is true', () => { @@ -466,7 +462,7 @@ module.exports = (session) => { .modifyGraph('ba', (qb) => qb.orderBy(['id3', 'id4'])); }) .then((model) => { - expect(model).to.eql({ + expect(model).toEqual({ id1: 1, id2: '1', aval: 'x', @@ -523,13 +519,13 @@ module.exports = (session) => { ]); }) .then(([a, b]) => { - expect(a).to.eql([ + expect(a).toEqual([ { id1: 1, id2: '1', aval: 'x', bid3: 1, bid4: '1' }, { id1: 1, id2: '2', aval: 'w', bid3: 1, bid4: '1' }, { id1: 400, id2: '600', aval: 'new a', bid3: 1, bid4: '1' }, ]); - expect(b).to.eql([ + expect(b).toEqual([ { id3: 1, id4: '1', bval: 'z' }, { id3: 2, id4: '1', bval: 'y' }, { id3: 200, id4: '300', bval: 'new b' }, @@ -571,7 +567,7 @@ module.exports = (session) => { .modifyGraph('ba', (qb) => qb.orderBy(['id3', 'id4'])); }) .then((model) => { - expect(model).to.eql({ + expect(model).toEqual({ id1: 1, id2: '1', aval: 'aUpdated', @@ -615,11 +611,11 @@ module.exports = (session) => { .deleteById([1, '2']) .orderBy(['id1', 'id2']) .then((count) => { - expect(count).to.eql(1); + expect(count).toBe(1); return session.knex('A').orderBy(['id1', 'id2']); }) .then((rows) => { - expect(rows).to.eql([ + expect(rows).toEqual([ { id1: 1, id2: '1', aval: 'a', bid3: null, bid4: null }, { id1: 2, id2: '2', aval: 'c', bid3: null, bid4: null }, { id1: 2, id2: '3', aval: 'd', bid3: null, bid4: null }, @@ -630,18 +626,18 @@ module.exports = (session) => { it('patchById should accept a composite id', async () => { const count = await A.query().patchById([1, '2'], { aval: 'x' }); - expect(count).to.eql(1); + expect(count).toBe(1); const rows = await session.knex('A').orderBy(['id1', 'id2']); - expect(rows.map((row) => row.aval)).to.eql(['a', 'x', 'c', 'd', 'e']); + expect(rows.map((row) => row.aval)).toEqual(['a', 'x', 'c', 'd', 'e']); }); it('updateById should accept a composite id', async () => { const count = await A.query().updateById([2, '3'], { aval: 'y' }); - expect(count).to.eql(1); + expect(count).toBe(1); const rows = await session.knex('A').orderBy(['id1', 'id2']); - expect(rows.map((row) => row.aval)).to.eql(['a', 'b', 'c', 'y', 'e']); + expect(rows.map((row) => row.aval)).toEqual(['a', 'b', 'c', 'y', 'e']); }); }); @@ -718,7 +714,7 @@ module.exports = (session) => { it.ab = sortBy(it.ab, ['id1', 'id2']); }); - expect(models).to.eql([ + expect(models).toEqual([ { id3: 1, id4: '1', @@ -807,7 +803,7 @@ module.exports = (session) => { .then((b) => { b = sortBy(b, ['id1', 'id2']); - expect(b).to.eql([ + expect(b).toEqual([ { id1: 1, id2: '1', @@ -846,7 +842,7 @@ module.exports = (session) => { b = sortBy(b, ['id1', 'id2']); b[0].ba = sortBy(b[0].ba, ['id3', 'id4']); - expect(b).to.eql([ + expect(b).toEqual([ { id1: 11, id2: '11', @@ -888,7 +884,7 @@ module.exports = (session) => { return Promise.all([a1, a1.$relatedQuery('b')]); }) .then(([_, b1]) => { - expect(b1).to.eql({ id3: 1, id4: '1', bval: 'b1' }); + expect(b1).toEqual({ id3: 1, id4: '1', bval: 'b1' }); }); }); @@ -902,8 +898,8 @@ module.exports = (session) => { ]); }) .then(([a1, bNew]) => { - expect(bNew).to.eql({ id3: 1000, id4: '2000', bval: 'new' }); - expect(a1).to.eql({ + expect(bNew).toEqual({ id3: 1000, id4: '2000', bval: 'new' }); + expect(a1).toEqual({ id1: 1, id2: '1', aval: 'a1', @@ -916,8 +912,8 @@ module.exports = (session) => { ]); }) .then(([a1, bNew]) => { - expect(a1).to.eql({ id1: 1, id2: '1', aval: 'a1', bid3: 1000, bid4: '2000' }); - expect(bNew).to.eql({ id3: 1000, id4: '2000', bval: 'new' }); + expect(a1).toEqual({ id1: 1, id2: '1', aval: 'a1', bid3: 1000, bid4: '2000' }); + expect(bNew).toEqual({ id3: 1000, id4: '2000', bval: 'new' }); }); }); @@ -928,12 +924,12 @@ module.exports = (session) => { return Promise.all([a1, a1.$relatedQuery('b').update({ bval: 'updated' })]); }) .then(([a1, numUpdated]) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('B').where('bval', 'updated'); }) .then((rows) => { - expect(rows).to.have.length(1); - expect(rows[0]).to.eql({ id3: 1, id4: '1', bval: 'updated' }); + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ id3: 1, id4: '1', bval: 'updated' }); }); }); @@ -947,12 +943,12 @@ module.exports = (session) => { ]); }) .then(([a1, b1]) => { - expect(b1).to.eql({ id3: 1, id4: '1', bval: 'updated' }); + expect(b1).toEqual({ id3: 1, id4: '1', bval: 'updated' }); return session.knex('B').where('bval', 'updated'); }) .then((rows) => { - expect(rows).to.have.length(1); - expect(rows[0]).to.eql({ id3: 1, id4: '1', bval: 'updated' }); + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ id3: 1, id4: '1', bval: 'updated' }); }); }); @@ -963,12 +959,12 @@ module.exports = (session) => { return Promise.all([a1, a1.$relatedQuery('b').delete()]); }) .then(([a1, numDeleted]) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('B'); }) .then((rows) => { - expect(rows).to.have.length(1); - expect(rows[0].bval).to.equal('b1'); + expect(rows).toHaveLength(1); + expect(rows[0].bval).toBe('b1'); }); }); @@ -976,18 +972,18 @@ module.exports = (session) => { return A.query() .findById([2, '2']) .then((a1) => { - expect(a1.bid3).to.equal(1); - expect(a1.bid4).to.equal('2'); + expect(a1.bid3).toBe(1); + expect(a1.bid4).toBe('2'); return Promise.all([a1, a1.$relatedQuery('b').relate([1, '1'])]); }) .then(([a1]) => { - expect(a1.bid3).to.equal(1); - expect(a1.bid4).to.equal('1'); + expect(a1.bid3).toBe(1); + expect(a1.bid4).toBe('1'); return A.query().findById([2, '2']); }) .then((a1) => { - expect(a1.bid3).to.equal(1); - expect(a1.bid4).to.equal('1'); + expect(a1.bid3).toBe(1); + expect(a1.bid4).toBe('1'); }); }); @@ -995,18 +991,18 @@ module.exports = (session) => { return A.query() .findById([2, '2']) .then((a1) => { - expect(a1.bid3).to.equal(1); - expect(a1.bid4).to.equal('2'); + expect(a1.bid3).toBe(1); + expect(a1.bid4).toBe('2'); return Promise.all([a1, a1.$relatedQuery('b').unrelate()]); }) .then(([a1]) => { - expect(a1.bid3).to.equal(null); - expect(a1.bid4).to.equal(null); + expect(a1.bid3).toBeNull(); + expect(a1.bid4).toBeNull(); return A.query().findById([2, '2']); }) .then((a1) => { - expect(a1.bid3).to.equal(null); - expect(a1.bid4).to.equal(null); + expect(a1.bid3).toBeNull(); + expect(a1.bid4).toBeNull(); }); }); }); @@ -1019,7 +1015,7 @@ module.exports = (session) => { return Promise.all([b1, b1.$relatedQuery('a').orderBy(['id1', 'id2'])]); }) .then(([_, a]) => { - expect(a).to.eql([ + expect(a).toEqual([ { id1: 1, id2: '1', aval: 'a1', bid3: 1, bid4: '1' }, { id1: 1, id2: '2', aval: 'a2', bid3: 1, bid4: '1' }, { id1: 2, id2: '1', aval: 'a3', bid3: 1, bid4: '1' }, @@ -1037,7 +1033,7 @@ module.exports = (session) => { ]); }) .then(([b1, aNew]) => { - expect(aNew).to.eql({ + expect(aNew).toEqual({ id1: 1000, id2: '2000', aval: 'new', @@ -1047,7 +1043,7 @@ module.exports = (session) => { return session.knex('A').where({ id1: 1000, id2: '2000' }).first(); }) .then((aNew) => { - expect(aNew).to.eql({ id1: 1000, id2: '2000', aval: 'new', bid3: 1, bid4: '1' }); + expect(aNew).toEqual({ id1: 1000, id2: '2000', aval: 'new', bid3: 1, bid4: '1' }); }); }); @@ -1058,11 +1054,11 @@ module.exports = (session) => { return b1.$relatedQuery('a').update({ aval: 'up' }).where('id2', '>', '1'); }) .then((count) => { - expect(count).to.equal(1); + expect(count).toBe(1); return session.knex('A').orderBy(['id1', 'id2']); }) .then((rows) => { - expect(rows).to.eql([ + expect(rows).toEqual([ { id1: 1, id2: '1', aval: 'a1', bid3: 1, bid4: '1' }, { id1: 1, id2: '2', aval: 'up', bid3: 1, bid4: '1' }, { id1: 2, id2: '1', aval: 'a3', bid3: 1, bid4: '1' }, @@ -1086,11 +1082,11 @@ module.exports = (session) => { return b2.$relatedQuery('a').delete(); }) .then((count) => { - expect(count).to.equal(3); + expect(count).toBe(3); return session.knex('A').orderBy(['id1', 'id2']); }) .then((rows) => { - expect(rows).to.eql([ + expect(rows).toEqual([ { id1: 1, id2: '1', aval: 'a1', bid3: 1, bid4: '1' }, { id1: 1, id2: '2', aval: 'a2', bid3: 1, bid4: '1' }, { id1: 2, id2: '1', aval: 'a3', bid3: 1, bid4: '1' }, @@ -1114,7 +1110,7 @@ module.exports = (session) => { return session.knex('A').orderBy(['id1', 'id2']); }) .then((rows) => { - expect(rows).to.eql([ + expect(rows).toEqual([ { id1: 1, id2: '1', aval: 'a1', bid3: 1, bid4: '2' }, { id1: 1, id2: '2', aval: 'a2', bid3: 1, bid4: '1' }, { id1: 2, id2: '1', aval: 'a3', bid3: 1, bid4: '1' }, @@ -1141,7 +1137,7 @@ module.exports = (session) => { return session.knex('A').orderBy(['id1', 'id2']); }) .then((rows) => { - expect(rows).to.eql([ + expect(rows).toEqual([ { id1: 1, id2: '1', aval: 'a1', bid3: 1, bid4: '2' }, { id1: 1, id2: '2', aval: 'a2', bid3: 1, bid4: '1' }, { id1: 2, id2: '1', aval: 'a3', bid3: 1, bid4: '1' }, @@ -1168,7 +1164,7 @@ module.exports = (session) => { return session.knex('A').orderBy(['id1', 'id2']); }) .then((rows) => { - expect(rows).to.eql([ + expect(rows).toEqual([ { id1: 1, id2: '1', aval: 'a1', bid3: 1, bid4: '1' }, { id1: 1, id2: '2', aval: 'a2', bid3: 1, bid4: '1' }, { id1: 2, id2: '1', aval: 'a3', bid3: 1, bid4: '1' }, @@ -1194,7 +1190,7 @@ module.exports = (session) => { return b2.$relatedQuery('ab').orderBy(['id1', 'id2']); }) .then((ret) => { - expect(ret).to.eql([ + expect(ret).toEqual([ { id1: 1, id2: '1', aval: 'a1', bid3: 1, bid4: '1' }, { id1: 11, id2: '11', aval: 'a7', bid3: null, bid4: null }, { id1: 21, id2: '21', aval: 'a10', bid3: null, bid4: null }, @@ -1230,11 +1226,11 @@ module.exports = (session) => { ]); }) .then(([a, ab]) => { - expect(a).to.eql( + expect(a).toEqual( aOld.concat([{ id1: 1000, id2: '2000', aval: 'new', bid3: null, bid4: null }]), ); - expect(ab).to.eql(abOld.concat([{ aid1: 1000, aid2: '2000', bid3: 1, bid4: '2' }])); + expect(ab).toEqual(abOld.concat([{ aid1: 1000, aid2: '2000', bid3: 1, bid4: '2' }])); }); }); @@ -1248,7 +1244,7 @@ module.exports = (session) => { return session.knex('A').orderBy(['id1', 'id2']); }) .then((rows) => { - expect(rows).to.eql([ + expect(rows).toEqual([ { id1: 1, id2: '1', aval: 'XX', bid3: 1, bid4: '1' }, { id1: 1, id2: '2', aval: 'a2', bid3: 1, bid4: '1' }, { id1: 2, id2: '1', aval: 'a3', bid3: 1, bid4: '1' }, @@ -1284,11 +1280,11 @@ module.exports = (session) => { .patch({ aval: 'XX', abval: 'z' }) .where('A.aval', 'a4'); - expect(numUpdated).to.equal(1); - expect(await session.knex('A').where('aval', 'XX').select('id1', 'id2')).to.eql([ + expect(numUpdated).toBe(1); + expect(await session.knex('A').where('aval', 'XX').select('id1', 'id2')).toEqual([ { id1: 2, id2: '2' }, ]); - expect(await extras()).to.eql(['z', 'y']); + expect(await extras()).toEqual(['z', 'y']); }); it('should patch nothing if no related rows match', async () => { @@ -1298,9 +1294,48 @@ module.exports = (session) => { .patch({ aval: 'XX', abval: 'z' }) .where('A.aval', 'does not exist'); - expect(numUpdated).to.equal(0); - expect(await session.knex('A').where('aval', 'XX')).to.eql([]); - expect(await extras()).to.eql(['x', 'y']); + expect(numUpdated).toBe(0); + expect(await session.knex('A').where('aval', 'XX')).toEqual([]); + expect(await extras()).toEqual(['x', 'y']); + }); + + it('should patch and find more related rows than one statement binds values for (#287)', async () => { + // Two values per id, more than SQL Server accepts in one statement: + const ids = Array.from({ length: 1100 }, (_, index) => [100 + index, `${index}`]); + await session.knex.batchInsert( + 'A', + ids.map(([id1, id2]) => ({ id1, id2, aval: 'many' })), + 300, + ); + await session.knex.batchInsert( + 'A_B_extra', + ids.map(([aid1, aid2]) => ({ aid1, aid2, bid3: 1, bid4: '2', abval: 'many' })), + 300, + ); + + const sortIds = (rows) => + rows.map(({ id1, id2 }) => [id1, id2]).sort(([a], [b]) => a - b); + let foundIds = null; + A.afterUpdate = async ({ asFindQuery }) => { + foundIds = sortIds(await asFindQuery().select('A.id1', 'A.id2')); + }; + + try { + const b2 = await B.query().findById([1, '2']); + const numUpdated = await b2 + .$relatedQuery('abWithExtra') + .patch({ aval: 'XX', abval: 'z' }) + .where('A_B_extra.abval', 'many'); + + expect(numUpdated).toBe(ids.length); + expect(foundIds).toEqual(ids); + expect(sortIds(await session.knex('A').where('aval', 'XX'))).toEqual(ids); + expect( + await session.knex('A_B_extra').where('abval', 'z').pluck('aid1'), + ).toHaveLength(ids.length); + } finally { + delete A.afterUpdate; + } }); }); @@ -1314,7 +1349,7 @@ module.exports = (session) => { return session.knex('A').orderBy(['id1', 'id2']); }) .then((rows) => { - expect(rows).to.eql([ + expect(rows).toEqual([ { id1: 1, id2: '2', aval: 'a2', bid3: 1, bid4: '1' }, { id1: 2, id2: '1', aval: 'a3', bid3: 1, bid4: '1' }, { id1: 2, id2: '2', aval: 'a4', bid3: 1, bid4: '2' }, @@ -1352,8 +1387,8 @@ module.exports = (session) => { ]); }) .then(([a, ab]) => { - expect(a).to.eql(aOld); - expect(ab).to.eql( + expect(a).toEqual(aOld); + expect(ab).toEqual( sortBy(abOld.concat([{ aid1: 1, aid2: '2', bid3: 1, bid4: '2' }]), [ 'bid3', 'bid4', @@ -1390,8 +1425,8 @@ module.exports = (session) => { ]); }) .then(([a, ab]) => { - expect(a).to.eql(aOld); - expect(ab).to.eql(abOld.filter((it) => !(it.bid3 === 1 && it.bid4 === '2'))); + expect(a).toEqual(aOld); + expect(ab).toEqual(abOld.filter((it) => !(it.bid3 === 1 && it.bid4 === '2'))); }); }); }); diff --git a/tests/integration/crossDb/index.js b/tests/integration/crossDb/index.js index d038e5a39..a3b521cb6 100644 --- a/tests/integration/crossDb/index.js +++ b/tests/integration/crossDb/index.js @@ -1,9 +1,11 @@ -const knexUtils = require('../../../lib/utils/knexUtils'); +import { describe } from 'vitest'; +import mysql from './mysql.js'; -module.exports = (session) => { - describe('cross db', () => { - if (knexUtils.isMySql(session.knex)) { - require('./mysql')(session); - } - }); +export default (session) => { + // vitest fails on empty suites, so only add the suite for the databases that have tests. + if (session.isMySql()) { + describe('cross db', () => { + mysql(session); + }); + } }; diff --git a/tests/integration/crossDb/mysql.js b/tests/integration/crossDb/mysql.js index caa0d7eb3..e9dcf6a16 100644 --- a/tests/integration/crossDb/mysql.js +++ b/tests/integration/crossDb/mysql.js @@ -1,15 +1,15 @@ -const Knex = require('knex'); -const { Model } = require('../../../'); -const expect = require('expect.js'); -const { cloneDeep } = require('../../../testUtils/testUtils'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import Knex from 'knex'; +import { Model } from 'objection'; +import { cloneDeep } from '../../../testUtils/testUtils.js'; -module.exports = (session) => { +export default (session) => { describe('mysql', () => { let db2Knex; let T1; let T2; - before(async function () { + beforeAll(async () => { await session.knex.raw('CREATE DATABASE IF NOT EXISTS objection_test_2'); const db2Config = cloneDeep(session.opt.knexConfig); @@ -31,7 +31,7 @@ module.exports = (session) => { }); }); - after(async function () { + afterAll(async () => { await db2Knex.schema.dropTableIfExists('t2'); await db2Knex.schema.dropTableIfExists('t1'); await db2Knex.destroy(); @@ -79,7 +79,7 @@ module.exports = (session) => { T2 = T2Model.bindKnex(session.knex); }); - beforeEach(async function () { + beforeEach(async () => { await db2Knex('t2').delete(); await db2Knex('t1').delete(); }); @@ -91,7 +91,7 @@ module.exports = (session) => { return db2Knex('t1'); }) .then((rows) => { - expect(rows).to.eql([{ id: 1, foo: 1 }]); + expect(rows).toEqual([{ id: 1, foo: 1 }]); }); }); @@ -111,7 +111,7 @@ module.exports = (session) => { return Promise.all([db2Knex('t1'), db2Knex('t2')]); }) .then((res) => { - expect(res).to.eql([[{ id: 1, foo: 1 }], [{ id: 1, bar: 2, t1_id: 1 }]]); + expect(res).toEqual([[{ id: 1, foo: 1 }], [{ id: 1, bar: 2, t1_id: 1 }]]); }); }); @@ -122,13 +122,13 @@ module.exports = (session) => { return T1.query().select('objection_test_2.t1.*'); }) .then((models) => { - expect(models).to.eql([{ id: 1, foo: 1 }]); + expect(models).toEqual([{ id: 1, foo: 1 }]); }) .then(() => { return T1.query().select('objection_test_2.t1.id'); }) .then((models) => { - expect(models).to.eql([{ id: 1 }]); + expect(models).toEqual([{ id: 1 }]); }); }); @@ -148,7 +148,7 @@ module.exports = (session) => { return T1.query().withGraphFetched('manyT2').select('objection_test_2.t1.*'); }) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, foo: 1, @@ -171,7 +171,7 @@ module.exports = (session) => { }); }) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { foo: 1, manyT2: [ diff --git a/tests/integration/delete.js b/tests/integration/delete.js index 1250ea544..b3e9e1b83 100644 --- a/tests/integration/delete.js +++ b/tests/integration/delete.js @@ -1,9 +1,8 @@ -const expect = require('expect.js'); -const expectPartEql = require('./../../testUtils/testUtils').expectPartialEqual; -const isPostgres = require('../../lib/utils/knexUtils').isPostgres; -const { resetWarnings } = require('../../lib/utils/warnings'); +import { describe, it, expect, beforeEach } from 'vitest'; +import { expectPartialEqual as expectPartEql } from '../../testUtils/testUtils.js'; +import { isPostgres } from '../../lib/utils/knexUtils.js'; -module.exports = (session) => { +export default (session) => { const Model1 = session.models.Model1; const Model2 = session.models.Model2; @@ -43,11 +42,11 @@ module.exports = (session) => { .delete() .where('id', '=', 2) .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 3, model1Prop1: 'hello 3' }); }); @@ -58,11 +57,11 @@ module.exports = (session) => { .del() .where('model2_prop2', 1) .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(1); + expect(rows).toHaveLength(1); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'text 1', model2_prop2: 2 }); }); }); @@ -71,11 +70,11 @@ module.exports = (session) => { return Model1.query() .deleteById(2) .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 3, model1Prop1: 'hello 3' }); }); @@ -87,9 +86,9 @@ module.exports = (session) => { .where('model1Prop1', 'hello 2') .orWhere('model1Prop1', 'hello 3'); - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); const rows = await session.knex('Model1').orderBy('id'); - expect(rows.map((row) => row.id)).to.eql([1, 3]); + expect(rows.map((row) => row.id)).toEqual([1, 3]); }); if (session.isPostgres()) { @@ -98,7 +97,7 @@ module.exports = (session) => { .deleteById(2) .returning('*') .then((deletedRow) => { - expect(deletedRow).to.eql({ + expect(deletedRow).toEqual({ id: 2, model1Id: null, model1Prop1: 'hello 2', @@ -108,7 +107,7 @@ module.exports = (session) => { return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 3, model1Prop1: 'hello 3' }); }); @@ -120,90 +119,54 @@ module.exports = (session) => { .delete() .where('model1Prop1', '<', 'hello 3') .then((numDeleted) => { - expect(numDeleted).to.equal(2); + expect(numDeleted).toBe(2); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(1); + expect(rows).toHaveLength(1); expectPartEql(rows[0], { id: 3, model1Prop1: 'hello 3' }); }); }); - it('should warn about an order with a limit on MySQL, as knex leaves out the order', async function () { - // Only MySQL supports a limit in deletes. - if (!session.isMySql()) { - this.skip(); - } - - // The warning is only observable through `console.warn()`. - const warnings = []; - const originalWarn = console.warn; - console.warn = (message) => warnings.push(message); - resetWarnings(); - - try { + it.skipIf(!session.isMySql())( + 'should throw for an order with a limit on MySQL, as knex leaves out the order', + async () => { + // Only MySQL supports a limit in deletes. + const message = + 'orderBy() is not supported in delete queries with limit() on MySQL, ' + + 'as knex leaves out the order.'; const query = () => Model1.query().delete().where('id', '>', 1).orderBy('id').limit(1); - // Building the query doesn't warn, only executing it. + // Building the query doesn't throw, only executing it. query().toKnexQuery().toString(); - expect(warnings).to.have.length(0); - - // Older knex versions leave out the limit as well, so the count of deleted - // rows depends on the knex version. - await query(); - expect(warnings).to.eql([ - '[objection] orderBy() has no effect in delete queries with limit() on MySQL, so the ' + - 'limit deletes rows in any order. This will throw an error in objection 4.0.', - ]); + await expect(query()).rejects.toThrow(message); - resetWarnings(); const model1 = await Model1.query().findById(1); - await model1.$relatedQuery('model1Relation2').delete().orderBy('id_col').limit(1); - expect(warnings).to.have.length(2); - - // A limit without an order doesn't warn. - resetWarnings(); - await Model1.query().delete().where('id', '>', 1).limit(1); - expect(warnings).to.have.length(2); - } finally { - console.warn = originalWarn; - resetWarnings(); - } - }); - - it('should warn about an offset, as knex leaves it out', async () => { - // The warning is only observable through `console.warn()`. - const warnings = []; - const originalWarn = console.warn; - console.warn = (message) => warnings.push(message); - resetWarnings(); - - try { - const query = () => Model1.query().delete().where('id', '>', 1).offset(1); - - // Building the query doesn't warn, only executing it. - query().toKnexQuery().toString(); - expect(warnings).to.have.length(0); - - await query(); - expect(warnings).to.eql([ - '[objection] offset() has no effect in delete queries, so no rows are skipped. ' + - 'This will throw an error in objection 4.0.', - ]); - - resetWarnings(); - const model1 = await Model1.query().findById(1); - await model1.$relatedQuery('model1Relation2').delete().offset(1); - expect(warnings).to.have.length(2); - - // A delete without an offset doesn't warn. - resetWarnings(); - await Model1.query().delete().where('id', '>', 1); - expect(warnings).to.have.length(2); - } finally { - console.warn = originalWarn; - resetWarnings(); - } + await expect( + model1.$relatedQuery('model1Relation2').delete().orderBy('id_col').limit(1), + ).rejects.toThrow(message); + expect(await session.knex('Model1').orderBy('id').pluck('id')).toEqual([1, 2, 3]); + expect(await session.knex('model2').orderBy('id_col').pluck('id_col')).toEqual([1, 2]); + + // A limit without an order deletes: + expect(await Model1.query().delete().where('id', '>', 1).limit(1)).toBe(1); + }, + ); + + it('should throw for an offset, as knex leaves it out', async () => { + const message = 'offset() is not supported in delete queries, as knex leaves it out.'; + const query = () => Model1.query().delete().where('id', '>', 1).offset(1); + + // Building the query doesn't throw, only executing it. + query().toKnexQuery().toString(); + await expect(query()).rejects.toThrow(message); + + const model1 = await Model1.query().findById(1); + await expect(model1.$relatedQuery('model1Relation2').delete().offset(1)).rejects.toThrow( + message, + ); + expect(await session.knex('Model1').orderBy('id').pluck('id')).toEqual([1, 2, 3]); + expect(await session.knex('model2').orderBy('id_col').pluck('id_col')).toEqual([1, 2]); }); if (isPostgres(session.knex)) { @@ -215,31 +178,29 @@ module.exports = (session) => { .where('model1Prop1', '<', 'hello 3') .returning('*') .then((deletedObjects) => { - expect(deletedObjects).to.have.length(2); + expect(deletedObjects).toHaveLength(2); deleted1 = deletedObjects.find((it) => it.id === 1); - expect(deleted1).to.be.a(Model1); + expect(deleted1).toBeInstanceOf(Model1); expectPartEql(deleted1, { id: 1, model1Prop1: 'hello 1' }); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(1); + expect(rows).toHaveLength(1); expectPartEql(rows[0], { id: 3, model1Prop1: 'hello 3' }); }); }); } }); - it('an error with a clear message should be thrown if undefined is passed to deleteById', (done) => { - Model1.query() + it('an error with a clear message should be thrown if undefined is passed to deleteById', () => { + return Model1.query() .deleteById(undefined) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal('undefined was passed to deleteById'); - done(); - }) - .catch(done); + expect(err.message).toBe('undefined was passed to deleteById'); + }); }); describe('.$query().delete()', () => { @@ -263,13 +224,13 @@ module.exports = (session) => { .$query() .delete() .then((numDeleted) => { - expect(numDeleted).to.equal(1); - expect(model.$beforeDeleteCalled).to.equal(1); - expect(model.$afterDeleteCalled).to.equal(1); + expect(numDeleted).toBe(1); + expect(model.$beforeDeleteCalled).toBe(1); + expect(model.$afterDeleteCalled).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(1); + expect(rows).toHaveLength(1); expectPartEql(rows[0], { id: 2, model1Prop1: 'hello 2' }); }); }); @@ -284,12 +245,12 @@ module.exports = (session) => { .returning('model1Prop1', 'model1Prop2') .then((deleted) => { const expected = { model1Prop1: 'hello 1', model1Prop2: null }; - expect(deleted).to.be.a(Model1); - expect(deleted).to.eql(expected); + expect(deleted).toBeInstanceOf(Model1); + expect(deleted).toEqual(expected); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(1); + expect(rows).toHaveLength(1); expectPartEql(rows[0], { id: 2, model1Prop1: 'hello 2' }); }); }); @@ -308,12 +269,12 @@ module.exports = (session) => { model1Prop1: 'hello 2', model1Prop2: null, }; - expect(deleted).to.be.a(Model1); - expect(deleted).to.eql(expected); + expect(deleted).toBeInstanceOf(Model1); + expect(deleted).toEqual(expected); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(1); + expect(rows).toHaveLength(1); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); }); }); @@ -344,50 +305,46 @@ module.exports = (session) => { .$query() .delete() .then(() => { - expect(model.before.id).to.equal(model.id); - expect(model.after).to.equal(null); + expect(model.before.id).toBe(model.id); + expect(model.after).toBeNull(); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(1); + expect(rows).toHaveLength(1); expectPartEql(rows[0], { id: 2, model1Prop1: 'hello 2' }); }); }); - it('should throw if the id is undefiend', (done) => { + it('should throw if the id is undefiend', () => { let model = Model1.fromJson({ model1Prop2: 1 }); - model + return model .$query() .delete() .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( `one of the identifier columns [id] is null or undefined. Have you specified the correct identifier column for the model 'Model1' using the 'idColumn' property?`, ); - done(); - }) - .catch(done); + }); }); - it('should throw if the id is null', (done) => { + it('should throw if the id is null', () => { let model = Model1.fromJson({ id: null }); - model + return model .$query() .delete() .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( `one of the identifier columns [id] is null or undefined. Have you specified the correct identifier column for the model 'Model1' using the 'idColumn' property?`, ); - done(); - }) - .catch(done); + }); }); }); @@ -429,11 +386,11 @@ module.exports = (session) => { .$relatedQuery('model1Relation1') .delete() .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 3, model1Prop1: 'hello 3' }); expectPartEql(rows[2], { id: 4, model1Prop1: 'hello 4' }); @@ -448,12 +405,12 @@ module.exports = (session) => { .first() .returning('*') .then((deletedObject) => { - expect(deletedObject).to.be.a(Model1); + expect(deletedObject).toBeInstanceOf(Model1); expectPartEql(deletedObject, { id: 2, model1Prop1: 'hello 2' }); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 3, model1Prop1: 'hello 3' }); expectPartEql(rows[2], { id: 4, model1Prop1: 'hello 4' }); @@ -466,11 +423,11 @@ module.exports = (session) => { .$relatedQuery('model1Relation1') .delete() .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -541,11 +498,11 @@ module.exports = (session) => { .$relatedQuery('model1Relation2') .delete() .then((numDeleted) => { - expect(numDeleted).to.equal(3); + expect(numDeleted).toBe(3); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id_col: 4, model2_prop1: 'text 4' }); expectPartEql(rows[1], { id_col: 5, model2_prop1: 'text 5' }); expectPartEql(rows[2], { id_col: 6, model2_prop1: 'text 6' }); @@ -561,14 +518,14 @@ module.exports = (session) => { .delete() .returning('*') .then((deletedObjects) => { - expect(deletedObjects).to.have.length(3); + expect(deletedObjects).toHaveLength(3); child1 = deletedObjects.find((it) => it.idCol === 1); - expect(child1).to.be.a(Model2); + expect(child1).toBeInstanceOf(Model2); expectPartEql(child1, { idCol: 1, model2Prop1: 'text 1' }); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id_col: 4, model2_prop1: 'text 4' }); expectPartEql(rows[1], { id_col: 5, model2_prop1: 'text 5' }); expectPartEql(rows[2], { id_col: 6, model2_prop1: 'text 6' }); @@ -582,11 +539,11 @@ module.exports = (session) => { .delete() .where('id_col', 2) .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(5); + expect(rows).toHaveLength(5); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(rows[1], { id_col: 3, model2_prop1: 'text 3' }); expectPartEql(rows[2], { id_col: 4, model2_prop1: 'text 4' }); @@ -602,11 +559,11 @@ module.exports = (session) => { .where('model2_prop2', '<', 6) .where('model2_prop1', 'like', 'text %') .then((numDeleted) => { - expect(numDeleted).to.equal(2); + expect(numDeleted).toBe(2); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(rows[1], { id_col: 4, model2_prop1: 'text 4' }); expectPartEql(rows[2], { id_col: 5, model2_prop1: 'text 5' }); @@ -690,11 +647,11 @@ module.exports = (session) => { .$relatedQuery('model2Relation1') .delete() .then((numDeleted) => { - expect(numDeleted).to.equal(3); + expect(numDeleted).toBe(3); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(5); + expect(rows).toHaveLength(5); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 6, model1Prop1: 'blaa 4' }); @@ -709,11 +666,11 @@ module.exports = (session) => { .delete() .where('Model1.id', 5) .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(7); + expect(rows).toHaveLength(7); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -729,11 +686,11 @@ module.exports = (session) => { .$relatedQuery('model2Relation1') .deleteById(5) .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(7); + expect(rows).toHaveLength(7); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -751,7 +708,7 @@ module.exports = (session) => { .returning('*') .deleteById(5) .then((deletedRow) => { - expect(deletedRow).to.eql({ + expect(deletedRow).toEqual({ id: 5, model1Id: null, model1Prop1: 'blaa 3', @@ -761,7 +718,7 @@ module.exports = (session) => { return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(7); + expect(rows).toHaveLength(7); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -780,11 +737,11 @@ module.exports = (session) => { .where('model1Prop1', 'like', 'blaa 4') .orWhere('model1Prop1', 'like', 'blaa 6') .then((numDeleted) => { - expect(numDeleted).to.equal(2); + expect(numDeleted).toBe(2); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(6); + expect(rows).toHaveLength(6); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -805,14 +762,14 @@ module.exports = (session) => { .orWhere('model1Prop1', 'like', 'blaa 6') .returning('*') .then((deletedObjects) => { - expect(deletedObjects).to.have.length(2); + expect(deletedObjects).toHaveLength(2); child1 = deletedObjects.find((it) => it.id === 6); - expect(child1).to.be.a(Model1); + expect(child1).toBeInstanceOf(Model1); expectPartEql(child1, { id: 6, model1Prop1: 'blaa 4' }); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(6); + expect(rows).toHaveLength(6); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -829,11 +786,11 @@ module.exports = (session) => { .delete() .where('model1Prop2', '<', 6) .then((numDeleted) => { - expect(numDeleted).to.equal(2); + expect(numDeleted).toBe(2); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(6); + expect(rows).toHaveLength(6); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -893,11 +850,11 @@ module.exports = (session) => { .$relatedQuery('model2Relation2') .delete() .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -912,12 +869,12 @@ module.exports = (session) => { .first() .returning('*') .then((deletedObject) => { - expect(deletedObject).to.be.a(Model1); + expect(deletedObject).toBeInstanceOf(Model1); expectPartEql(deletedObject, { id: 4, model1Prop1: 'blaa 2' }); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -955,11 +912,11 @@ module.exports = (session) => { .for(1) .delete() .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 3, model1Prop1: 'hello 3' }); expectPartEql(rows[2], { id: 4, model1Prop1: 'hello 4' }); @@ -974,12 +931,12 @@ module.exports = (session) => { .first() .returning('*') .then((deletedObject) => { - expect(deletedObject).to.be.a(Model1); + expect(deletedObject).toBeInstanceOf(Model1); expectPartEql(deletedObject, { id: 2, model1Prop1: 'hello 2' }); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 3, model1Prop1: 'hello 3' }); expectPartEql(rows[2], { id: 4, model1Prop1: 'hello 4' }); @@ -992,11 +949,11 @@ module.exports = (session) => { .for(Model1.query().findById(3)) .delete() .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -1068,11 +1025,11 @@ module.exports = (session) => { .for(1) .delete() .then((numDeleted) => { - expect(numDeleted).to.equal(3); + expect(numDeleted).toBe(3); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); expectPartEql(rows[0], { id_col: 4, model2_prop1: 'text 4' }); expectPartEql(rows[1], { id_col: 5, model2_prop1: 'text 5' }); expectPartEql(rows[2], { id_col: 6, model2_prop1: 'text 6' }); @@ -1089,14 +1046,14 @@ module.exports = (session) => { .delete() .returning('*') .then((deletedObjects) => { - expect(deletedObjects).to.have.length(3); + expect(deletedObjects).toHaveLength(3); child1 = deletedObjects.find((it) => it.idCol === 1); - expect(child1).to.be.a(Model2); + expect(child1).toBeInstanceOf(Model2); expectPartEql(child1, { idCol: 1, model2Prop1: 'text 1' }); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); expectPartEql(rows[0], { id_col: 4, model2_prop1: 'text 4' }); expectPartEql(rows[1], { id_col: 5, model2_prop1: 'text 5' }); expectPartEql(rows[2], { id_col: 6, model2_prop1: 'text 6' }); @@ -1111,11 +1068,11 @@ module.exports = (session) => { .delete() .where('id_col', 2) .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(6); + expect(rows).toHaveLength(6); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(rows[1], { id_col: 3, model2_prop1: 'text 3' }); expectPartEql(rows[2], { id_col: 4, model2_prop1: 'text 4' }); @@ -1132,11 +1089,11 @@ module.exports = (session) => { .where('model2_prop2', '<', 6) .where('model2_prop1', 'like', 'text %') .then((numDeleted) => { - expect(numDeleted).to.equal(5); + expect(numDeleted).toBe(5); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(rows[1], { id_col: 7, model2_prop1: 'text 7' }); }); @@ -1208,11 +1165,11 @@ module.exports = (session) => { .for(1) .delete() .then((numDeleted) => { - expect(numDeleted).to.equal(3); + expect(numDeleted).toBe(3); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(5); + expect(rows).toHaveLength(5); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 6, model1Prop1: 'blaa 4' }); @@ -1227,11 +1184,11 @@ module.exports = (session) => { .delete() .where('Model1.id', 5) .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(7); + expect(rows).toHaveLength(7); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -1247,11 +1204,11 @@ module.exports = (session) => { .for(1) .deleteById(5) .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(7); + expect(rows).toHaveLength(7); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -1269,7 +1226,7 @@ module.exports = (session) => { .returning('*') .deleteById(5) .then((deletedRow) => { - expect(deletedRow).to.eql({ + expect(deletedRow).toEqual({ id: 5, model1Id: null, model1Prop1: 'blaa 3', @@ -1279,7 +1236,7 @@ module.exports = (session) => { return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(7); + expect(rows).toHaveLength(7); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -1298,11 +1255,11 @@ module.exports = (session) => { .where('model1Prop1', 'like', 'blaa 4') .orWhere('model1Prop1', 'like', 'blaa 6') .then((numDeleted) => { - expect(numDeleted).to.equal(2); + expect(numDeleted).toBe(2); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(6); + expect(rows).toHaveLength(6); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -1323,14 +1280,14 @@ module.exports = (session) => { .orWhere('model1Prop1', 'like', 'blaa 6') .returning('*') .then((deletedObjects) => { - expect(deletedObjects).to.have.length(2); + expect(deletedObjects).toHaveLength(2); child1 = deletedObjects.find((it) => it.id === 6); - expect(child1).to.be.a(Model1); + expect(child1).toBeInstanceOf(Model1); expectPartEql(child1, { id: 6, model1Prop1: 'blaa 4' }); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(6); + expect(rows).toHaveLength(6); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -1347,11 +1304,11 @@ module.exports = (session) => { .delete() .where('model1Prop2', '<', 6) .then((numDeleted) => { - expect(numDeleted).to.equal(2); + expect(numDeleted).toBe(2); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(6); + expect(rows).toHaveLength(6); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -1367,11 +1324,11 @@ module.exports = (session) => { .delete() .where('model1Prop2', '<', 6) .then((numDeleted) => { - expect(numDeleted).to.equal(5); + expect(numDeleted).toBe(5); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -1384,11 +1341,11 @@ module.exports = (session) => { .delete() .where('model1Prop2', '<', 6) .then((numDeleted) => { - expect(numDeleted).to.equal(5); + expect(numDeleted).toBe(5); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); diff --git a/tests/integration/find.js b/tests/integration/find.js index ceadc2c7b..3ea8febc7 100644 --- a/tests/integration/find.js +++ b/tests/integration/find.js @@ -1,11 +1,13 @@ -const utils = require('../../lib/utils/knexUtils'); -const expect = require('expect.js'); +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import * as utils from '../../lib/utils/knexUtils.js'; -const { KnexTimeoutError } = require('knex'); -const { raw, ref, val, fn, Model, QueryBuilderOperation, DBError } = require('../..'); -const { range, sortBy } = require('../../testUtils/testUtils'); +import knex from 'knex'; +import { raw, ref, val, fn, Model, QueryBuilderOperation, DBError } from 'objection'; +import { range, sortBy } from '../../testUtils/testUtils.js'; -module.exports = (session) => { +const { KnexTimeoutError } = knex; + +export default (session) => { let Model1 = session.models.Model1; let Model2 = session.models.Model2; @@ -44,23 +46,23 @@ module.exports = (session) => { it('should return all rows when no knex methods are chained', () => { return Model1.query() .then((models) => { - expect(models[0]).to.be.a(Model1); - expect(models[1]).to.be.a(Model1); - expect(models.map((it) => it.model1Prop1).sort()).to.eql(['hello 1', 'hello 2']); - expect(models.map((it) => it.id).sort()).to.eql([1, 2]); + expect(models[0]).toBeInstanceOf(Model1); + expect(models[1]).toBeInstanceOf(Model1); + expect(models.map((it) => it.model1Prop1).sort()).toEqual(['hello 1', 'hello 2']); + expect(models.map((it) => it.id).sort()).toEqual([1, 2]); return Model2.query(); }) .then((models) => { - expect(models[0]).to.be.a(Model2); - expect(models[1]).to.be.a(Model2); - expect(models[2]).to.be.a(Model2); - expect(models.map((it) => it.model2Prop1).sort()).to.eql([ + expect(models[0]).toBeInstanceOf(Model2); + expect(models[1]).toBeInstanceOf(Model2); + expect(models[2]).toBeInstanceOf(Model2); + expect(models.map((it) => it.model2Prop1).sort()).toEqual([ 'hejsan 1', 'hejsan 2', 'hejsan 3', ]); - expect(models.map((it) => it.model2Prop2).sort()).to.eql([10, 20, 30]); - expect(models.map((it) => it.idCol).sort()).to.eql([1, 2, 3]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([10, 20, 30]); + expect(models.map((it) => it.idCol).sort()).toEqual([1, 2, 3]); }); }); @@ -69,10 +71,10 @@ module.exports = (session) => { .range(1, 2) .orderBy('model2_prop2', 'desc') .then((result) => { - expect(result.results[0]).to.be.a(Model2); - expect(result.results[1]).to.be.a(Model2); - expect(result.total === 3).to.equal(true); - expect(result.results.map((it) => it.model2Prop2)).to.eql([20, 10]); + expect(result.results[0]).toBeInstanceOf(Model2); + expect(result.results[1]).toBeInstanceOf(Model2); + expect(result.total).toBe(3); + expect(result.results.map((it) => it.model2Prop2)).toEqual([20, 10]); }); }); @@ -83,42 +85,41 @@ module.exports = (session) => { .range() .orderBy('model2_prop2', 'desc') .then((result) => { - expect(result.results[0]).to.be.a(Model2); - expect(result.results[1]).to.be.a(Model2); - expect(result.total === 3).to.equal(true); - expect(result.results.map((it) => it.model2Prop2)).to.eql([20, 10]); + expect(result.results[0]).toBeInstanceOf(Model2); + expect(result.results[1]).toBeInstanceOf(Model2); + expect(result.total).toBe(3); + expect(result.results.map((it) => it.model2Prop2)).toEqual([20, 10]); }); }); - it('should give the reasons of several errors on SQL Server', async function () { - if (!session.isMsSql()) { - this.skip(); - } - - // SQL Server needs an order for an offset, and reports two errors. - let error; + it.skipIf(!session.isMsSql())( + 'should give the reasons of several errors on SQL Server', + async () => { + // SQL Server needs an order for an offset, and reports two errors. + let error; - try { - await Model2.query().offset(1).limit(2); - } catch (err) { - error = err; - } + try { + await Model2.query().offset(1).limit(2); + } catch (err) { + error = err; + } - expect(error).to.be.a(DBError); - expect(error.nativeError).to.be.an(AggregateError); - expect(error.message).to.contain( - " - Incorrect syntax near '@p0'. Invalid usage of the option next in the FETCH statement.", - ); - }); + expect(error).toBeInstanceOf(DBError); + expect(error.nativeError).toBeInstanceOf(AggregateError); + expect(error.message).toContain( + " - Incorrect syntax near '@p0'. Invalid usage of the option next in the FETCH statement.", + ); + }, + ); it('should return the given page and total count when page() is called', () => { return Model2.query() .page(1, 2) .orderBy('model2_prop2', 'desc') .then((result) => { - expect(result.results[0]).to.be.a(Model2); - expect(result.total === 3).to.equal(true); - expect(result.results.map((it) => it.model2Prop2)).to.eql([10]); + expect(result.results[0]).toBeInstanceOf(Model2); + expect(result.total).toBe(3); + expect(result.results.map((it) => it.model2Prop2)).toEqual([10]); }); }); @@ -128,9 +129,9 @@ module.exports = (session) => { .page(0, 2) .orderBy('model2_prop2', 'desc') .then((result) => { - expect(result.results[0]).to.be.a(Model2); - expect(result.total === 3).to.equal(true); - expect(result.results.map((it) => it.model2Prop2)).to.eql([30, 20]); + expect(result.results[0]).toBeInstanceOf(Model2); + expect(result.total).toBe(3); + expect(result.results.map((it) => it.model2Prop2)).toEqual([30, 20]); }); }); @@ -139,16 +140,16 @@ module.exports = (session) => { return Model2.query() .select('model2.id_col', 'model2_prop2') .then((models) => { - expect(models[0]).to.be.a(Model2); + expect(models[0]).toBeInstanceOf(Model2); // Test that only the selected columns (and stuff set by the $afterFind hook) were returned. - expect([...new Set(models.flatMap((it) => Object.keys(it)))].sort()).to.eql([ + expect([...new Set(models.flatMap((it) => Object.keys(it)))].sort()).toEqual([ '$afterFindCalled', 'idCol', 'model2Prop2', ]); - expect(models.map((it) => it.idCol).sort()).to.eql([1, 2, 3]); - expect(models.map((it) => it.model2Prop2).sort()).to.eql([10, 20, 30]); - expect(models.map((it) => it.$afterFindCalled).sort()).to.eql([1, 1, 1]); + expect(models.map((it) => it.idCol).sort()).toEqual([1, 2, 3]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([10, 20, 30]); + expect(models.map((it) => it.$afterFindCalled).sort()).toEqual([1, 1, 1]); }); }); @@ -156,7 +157,7 @@ module.exports = (session) => { return Model2.query() .where('model2_prop2', '>', 15) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20, 30]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20, 30]); }); }); @@ -164,7 +165,7 @@ module.exports = (session) => { return Model2.query() .findOne('model2_prop2', '>', 20) .then((model) => { - expect(model.model2Prop2).to.eql(30); + expect(model.model2Prop2).toBe(30); }); }); @@ -172,7 +173,7 @@ module.exports = (session) => { return Model2.query() .findById(2) .then((model) => { - expect(model.model2Prop2).to.eql(20); + expect(model.model2Prop2).toBe(20); }); }); @@ -186,7 +187,7 @@ module.exports = (session) => { return Model2.query(trx).findByIds([1, 2]).orderBy('id_col'); }) .then((models) => { - expect(models.map((it) => it.model2Prop1)).to.eql(['what', 'what']); + expect(models.map((it) => it.model2Prop1)).toEqual(['what', 'what']); }) .then(() => { throw new Error(); @@ -196,7 +197,7 @@ module.exports = (session) => { return Model2.query().findByIds([1, 2]).orderBy('id_col'); }) .then((models) => { - expect(models.map((it) => it.model2Prop1)).to.eql(['hejsan 1', 'hejsan 2']); + expect(models.map((it) => it.model2Prop1)).toEqual(['hejsan 1', 'hejsan 2']); }); }); @@ -214,7 +215,7 @@ module.exports = (session) => { ) .then((models) => { // Three items because Model1 (id = 1) has three related Model2 instances. - expect(models.length).to.equal(3); + expect(models.length).toBe(3); }); }); @@ -227,7 +228,7 @@ module.exports = (session) => { }) .orderBy('model2.id_col') .then((result) => { - expect(result).to.eql([ + expect(result).toEqual([ { model1Id: 1, model2Id: 1, $afterFindCalled: 1 }, { model1Id: 1, model2Id: 2, $afterFindCalled: 1 }, { model1Id: 1, model2Id: 3, $afterFindCalled: 1 }, @@ -239,7 +240,7 @@ module.exports = (session) => { return Model2.query() .where(raw('model2_prop2 = 20')) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -249,7 +250,7 @@ module.exports = (session) => { builder.where(raw('model2_prop2 = 20')); }) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -257,7 +258,7 @@ module.exports = (session) => { return Model2.query() .where(session.knex.raw('model2_prop2 = 20')) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -265,7 +266,7 @@ module.exports = (session) => { return Model2.query() .where({ model2_prop2: 20 }) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -277,7 +278,7 @@ module.exports = (session) => { }, }) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20, 30]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20, 30]); }); }); @@ -291,7 +292,7 @@ module.exports = (session) => { }, }) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -301,7 +302,7 @@ module.exports = (session) => { model2_prop2: Model2.query().max('model2_prop2'), }) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([30]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([30]); }); }); @@ -317,7 +318,7 @@ module.exports = (session) => { ) .orderBy('m1.model2_prop2') .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([10, 20, 30]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([10, 20, 30]); }); }); @@ -338,7 +339,7 @@ module.exports = (session) => { ) .orderBy('m1.model2_prop2') .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([10, 20, 30]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([10, 20, 30]); }); }); @@ -348,7 +349,7 @@ module.exports = (session) => { model2_prop2: session.knex('model2').max('model2_prop2'), }) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([30]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([30]); }); }); @@ -358,7 +359,7 @@ module.exports = (session) => { model2_prop2: session.knex.raw('10 + 10'), }) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -368,7 +369,7 @@ module.exports = (session) => { model2_prop2: raw('10 + 10'), }) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -376,7 +377,7 @@ module.exports = (session) => { return Model2.query() .where('id_col', raw('?', Model2.query().select('id_col').where('model2_prop2', 20))) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -384,7 +385,7 @@ module.exports = (session) => { return Model2.query() .where('model2_prop2', raw(20)) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -397,7 +398,7 @@ module.exports = (session) => { }), ) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -410,7 +411,7 @@ module.exports = (session) => { }), ) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -421,7 +422,7 @@ module.exports = (session) => { raw('?', Model2.query().select('id_col').where('model2_prop2', 20).toKnexQuery()), ) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -429,7 +430,7 @@ module.exports = (session) => { return Model2.query() .where('id_col', raw('?', [Model2.query().select('id_col').where('model2_prop2', 20)])) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -440,7 +441,7 @@ module.exports = (session) => { raw('?', [Model2.query().select('id_col').where('model2_prop2', 20).toKnexQuery()]), ) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20]); }); }); @@ -450,7 +451,7 @@ module.exports = (session) => { return Model1.query() .where(where) .then((models) => { - expect(models.map((it) => it.model1Prop1).sort()).to.eql(['hello 1']); + expect(models.map((it) => it.model1Prop1).sort()).toEqual(['hello 1']); }); }); @@ -459,7 +460,7 @@ module.exports = (session) => { .where('model2_prop2', '>', 15) .orderBy('model2_prop2') .then((models) => { - expect(models.map((it) => it.model2Prop2)).to.eql([20, 30]); + expect(models.map((it) => it.model2Prop2)).toEqual([20, 30]); }); }); @@ -469,8 +470,8 @@ module.exports = (session) => { .where('model2_prop2', '>', 15) .join('Model1', 'model2.model1_id', 'Model1.id') .then((models) => { - expect(models.map((it) => it.model2Prop1).sort()).to.eql(['hejsan 1', 'hejsan 2']); - expect(models.map((it) => it.model1Prop1)).to.eql(['hello 1', 'hello 1']); + expect(models.map((it) => it.model2Prop1).sort()).toEqual(['hejsan 1', 'hejsan 2']); + expect(models.map((it) => it.model1Prop1)).toEqual(['hello 1', 'hello 1']); }); }); @@ -482,7 +483,7 @@ module.exports = (session) => { .orderBy('Model1.model1Prop1') .page(0, 1) .then((res) => { - expect(res.results[0].model1Prop1).to.equal('hello 1'); + expect(res.results[0].model1Prop1).toBe('hello 1'); }); }); @@ -516,8 +517,8 @@ module.exports = (session) => { .select('model1Relation1.*') .joinRelated('model1Relation1') .then((res) => { - expect(res).to.have.length(1); - expect(res[0].id).to.equal(3); + expect(res).toHaveLength(1); + expect(res[0].id).toBe(3); }); }); @@ -544,7 +545,7 @@ module.exports = (session) => { .where('model2Relation1.id', '<', 5) .orderBy('id_col'); - expect(result).to.eql([ + expect(result).toEqual([ { idCol: 1, model1Id: null, @@ -568,7 +569,7 @@ module.exports = (session) => { .count() .first() .then((res) => { - expect(res[Object.keys(res)[0]]).to.eql(3); + expect(Number(res[Object.keys(res)[0]])).toBe(3); }); }); @@ -577,7 +578,7 @@ module.exports = (session) => { .countDistinct('id_col') .first() .then((res) => { - expect(res[Object.keys(res)[0]]).to.eql(3); + expect(Number(res[Object.keys(res)[0]])).toBe(3); }); }); @@ -586,8 +587,8 @@ module.exports = (session) => { .select('sub.*') .from(Model1.query().where('id', 2).as('sub')) .then((res) => { - expect(res.length).to.equal(1); - expect(res[0].id).to.equal(2); + expect(res.length).toBe(1); + expect(res[0].id).toBe(2); }); }); @@ -596,8 +597,8 @@ module.exports = (session) => { .select('sub.*') .from(session.knex('Model1').where('id', 2).as('sub')) .then((res) => { - expect(res.length).to.equal(1); - expect(res[0].id).to.equal(2); + expect(res.length).toBe(1); + expect(res[0].id).toBe(2); }); }); @@ -606,8 +607,8 @@ module.exports = (session) => { .select('sub.*') .from(session.knex.raw('(select * from ?? where ?? = 2) as sub', ['Model1', 'id'])) .then((res) => { - expect(res.length).to.equal(1); - expect(res[0].id).to.equal(2); + expect(res.length).toBe(1); + expect(res[0].id).toBe(2); }); }); @@ -616,8 +617,8 @@ module.exports = (session) => { .select('sub.*') .from(raw('(select * from ?? where ?? = 2) as sub', ['Model1', 'id'])) .then((res) => { - expect(res.length).to.equal(1); - expect(res[0].id).to.equal(2); + expect(res.length).toBe(1); + expect(res[0].id).toBe(2); }); }); @@ -626,41 +627,37 @@ module.exports = (session) => { .select('sub.*') .from((builder) => builder.from('Model1').where('id', 2).as('sub')) .then((res) => { - expect(res.length).to.equal(1); - expect(res[0].id).to.equal(2); + expect(res.length).toBe(1); + expect(res[0].id).toBe(2); }); }); - it('.throwIfNotFound() with empty result', (done) => { - Model1.query() + it('.throwIfNotFound() with empty result', () => { + return Model1.query() .where('model1Prop1', 'There is no value like me') .throwIfNotFound() .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(Model1.NotFoundError); - expect(err.type).to.equal('NotFound'); - expect(err.modelClass).to.equal(Model1); - done(); - }) - .catch(done); + expect(err).toBeInstanceOf(Model1.NotFoundError); + expect(err.type).toBe('NotFound'); + expect(err.modelClass).toBe(Model1); + }); }); - it('custom .throwIfNotFound() with message', (done) => { - Model1.query() + it('custom .throwIfNotFound() with message', () => { + return Model1.query() .where('model1Prop1', 'There is no value like me') .throwIfNotFound({ message: 'customMessage' }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(Model1.NotFoundError); - expect(err.data.message).to.equal('customMessage'); - expect(err.modelClass).to.equal(Model1); - done(); - }) - .catch(done); + expect(err).toBeInstanceOf(Model1.NotFoundError); + expect(err.data.message).toBe('customMessage'); + expect(err.modelClass).toBe(Model1); + }); }); it('.throwIfNotFound() with non-empty result', () => { @@ -668,67 +665,59 @@ module.exports = (session) => { .throwIfNotFound() .where('model2_prop2', '>', 15) .then((models) => { - expect(models.map((it) => it.model2Prop2).sort()).to.eql([20, 30]); + expect(models.map((it) => it.model2Prop2).sort()).toEqual([20, 30]); }); }); - it('.throwIfNotFound() with single result', (done) => { - Model1.query() + it('.throwIfNotFound() with single result', () => { + return Model1.query() .where('model1Prop1', 'There is no value like me') .first() .throwIfNotFound() .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(Model1.NotFoundError); - done(); - }) - .catch(done); + expect(err).toBeInstanceOf(Model1.NotFoundError); + }); }); - it('.throwIfNotFound() with result equal to 0', (done) => { - Model1.query() + it('.throwIfNotFound() with result equal to 0', () => { + return Model1.query() .where('model1Prop1', 'There is no value like me') .delete() .throwIfNotFound() .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(Model1.NotFoundError); - done(); - }) - .catch(done); + expect(err).toBeInstanceOf(Model1.NotFoundError); + }); }); - it('an error with a clear message should be thrown if undefined is passed to findById', (done) => { - Model1.query() + it('an error with a clear message should be thrown if undefined is passed to findById', () => { + return Model1.query() .findById(undefined) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal('undefined was passed to findById'); - done(); - }) - .catch(done); + expect(err.message).toBe('undefined was passed to findById'); + }); }); - it('an error with a clear message should be thrown if undefined is passed to findById (composite key)', (done) => { - Model1.query() + it('an error with a clear message should be thrown if undefined is passed to findById (composite key)', () => { + return Model1.query() .findById([undefined, 1]) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal('undefined was passed to findById'); - done(); - }) - .catch(done); + expect(err.message).toBe('undefined was passed to findById'); + }); }); - it('.throwIfNotFound() should throw error returned by `createNotFoundError`', (done) => { + it('.throwIfNotFound() should throw error returned by `createNotFoundError`', () => { class CustomError extends Error { constructor(ctx) { super('CustomError'); @@ -742,19 +731,17 @@ module.exports = (session) => { } } - TestModel.query() + return TestModel.query() .where('model1Prop1', 'There is no value like me') .context({ foo: 'bar' }) .throwIfNotFound() .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(CustomError); - expect(err.ctx).to.eql({ foo: 'bar' }); - done(); - }) - .catch(done); + expect(err).toBeInstanceOf(CustomError); + expect(err.ctx).toEqual({ foo: 'bar' }); + }); }); it('complex nested subquery', () => { @@ -777,8 +764,8 @@ module.exports = (session) => { }) .where('t.concatProp', 'hejsan 310') .then((models) => { - expect(models).to.have.length(1); - expect(models[0]).to.eql({ + expect(models).toHaveLength(1); + expect(models[0]).toEqual({ idCol: 3, model1Id: 1, model2Prop1: 'hejsan 3', @@ -803,7 +790,7 @@ module.exports = (session) => { ) .castTo(Model2) .then((models) => { - expect(models.map((it) => it.model2Prop2)).to.eql([10, 20, 30]); + expect(models.map((it) => it.model2Prop2)).toEqual([10, 20, 30]); }); }); @@ -816,8 +803,8 @@ module.exports = (session) => { ) .orderBy('id_col') .then((models) => { - expect(models.map((it) => it.idCol)).to.eql([1, 2, 3]); - expect(models.map((it) => it.model2Prop2)).to.eql([40, 30, 20]); + expect(models.map((it) => it.idCol)).toEqual([1, 2, 3]); + expect(models.map((it) => it.model2Prop2)).toEqual([40, 30, 20]); }); }); @@ -827,8 +814,8 @@ module.exports = (session) => { .select('model2.*', fn('concat', ref('model2_prop2'), '10').as('model2_prop2')) .orderBy('id_col') .then((models) => { - expect(models.map((it) => it.idCol)).to.eql([1, 2, 3]); - expect(models.map((it) => it.model2Prop2)).to.eql(['3010', '2010', '1010']); + expect(models.map((it) => it.idCol)).toEqual([1, 2, 3]); + expect(models.map((it) => it.model2Prop2)).toEqual(['3010', '2010', '1010']); }); }); @@ -837,8 +824,8 @@ module.exports = (session) => { .select('model2.*', fn.concat(ref('model2_prop2'), '10').as('model2_prop2')) .orderBy('id_col') .then((models) => { - expect(models.map((it) => it.idCol)).to.eql([1, 2, 3]); - expect(models.map((it) => it.model2Prop2)).to.eql(['3010', '2010', '1010']); + expect(models.map((it) => it.idCol)).toEqual([1, 2, 3]); + expect(models.map((it) => it.model2Prop2)).toEqual(['3010', '2010', '1010']); }); }); } @@ -852,8 +839,8 @@ module.exports = (session) => { ) .orderBy('id_col') .then((models) => { - expect(models.map((it) => it.idCol)).to.eql([1, 2, 3]); - expect(models.map((it) => it.model2Prop2)).to.eql(['3010', '2010', '1010']); + expect(models.map((it) => it.idCol)).toEqual([1, 2, 3]); + expect(models.map((it) => it.model2Prop2)).toEqual(['3010', '2010', '1010']); }); }); @@ -865,8 +852,8 @@ module.exports = (session) => { ) .orderBy('id_col') .then((models) => { - expect(models.map((it) => it.idCol)).to.eql([1, 2, 3]); - expect(models.map((it) => it.model2Prop2)).to.eql(['3010', '2010', '1010']); + expect(models.map((it) => it.idCol)).toEqual([1, 2, 3]); + expect(models.map((it) => it.model2Prop2)).toEqual(['3010', '2010', '1010']); }); }); @@ -875,8 +862,8 @@ module.exports = (session) => { .select('model2.*', fn.coalesce(null, ref('model2_prop2')).as('foo')) .orderBy('id_col') .then((models) => { - expect(models.map((it) => it.idCol)).to.eql([1, 2, 3]); - expect(models.map((it) => it.foo)).to.eql([30, 20, 10]); + expect(models.map((it) => it.idCol)).toEqual([1, 2, 3]); + expect(models.map((it) => it.foo)).toEqual([30, 20, 10]); }); }); @@ -885,8 +872,8 @@ module.exports = (session) => { .select('model2.*', fn.now().as('lultz')) .orderBy('id_col') .then((models) => { - expect(models.map((it) => it.idCol)).to.eql([1, 2, 3]); - expect(models[0].lultz).to.be.a(Date); + expect(models.map((it) => it.idCol)).toEqual([1, 2, 3]); + expect(models[0].lultz).toBeInstanceOf(Date); }); }); @@ -895,9 +882,9 @@ module.exports = (session) => { .select('model2.*', fn.now(0).as('lultz')) .orderBy('id_col') .then((models) => { - expect(models.map((it) => it.idCol)).to.eql([1, 2, 3]); - expect(models[0].lultz).to.be.a(Date); - expect(models[0].lultz.getMilliseconds()).to.equal(0); + expect(models.map((it) => it.idCol)).toEqual([1, 2, 3]); + expect(models[0].lultz).toBeInstanceOf(Date); + expect(models[0].lultz.getMilliseconds()).toBe(0); }); }); } @@ -907,8 +894,8 @@ module.exports = (session) => { .where('model2_prop2', raw(':value', { value: 20 })) .orderBy('id_col') .then((models) => { - expect(models.map((it) => it.idCol)).to.eql([2]); - expect(models.map((it) => it.model2Prop2)).to.eql([20]); + expect(models.map((it) => it.idCol)).toEqual([2]); + expect(models.map((it) => it.model2Prop2)).toEqual([20]); }); }); @@ -919,8 +906,8 @@ module.exports = (session) => { }) .orderBy('id_col') .then((models) => { - expect(models.map((it) => it.idCol)).to.eql([2]); - expect(models.map((it) => it.model2Prop2)).to.eql([20]); + expect(models.map((it) => it.idCol)).toEqual([2]); + expect(models.map((it) => it.model2Prop2)).toEqual([20]); }); }); @@ -935,8 +922,10 @@ module.exports = (session) => { ) .orderBy('id') .then((models) => { - expect(models.map((it) => it.id)).to.eql([1, 2]); - expect(models.map((it) => it.sum)).to.eql([60, null]); + expect(models.map((it) => it.id)).toEqual([1, 2]); + expect(models.map((it) => it.sum)).toEqual( + session.isPostgres() ? ['60', null] : [60, null], + ); }); }); @@ -951,8 +940,10 @@ module.exports = (session) => { ]) .orderBy('id') .then((models) => { - expect(models.map((it) => it.id)).to.eql([1, 2]); - expect(models.map((it) => it.sum)).to.eql([60, null]); + expect(models.map((it) => it.id)).toEqual([1, 2]); + expect(models.map((it) => it.sum)).toEqual( + session.isPostgres() ? ['60', null] : [60, null], + ); }); }); @@ -968,8 +959,8 @@ module.exports = (session) => { ) .orderBy('id') .then((res) => { - expect(res[0].model1Ids).to.eql([1, 2, 3]); - expect(res[1].model1Ids).to.eql([]); + expect(res[0].model1Ids).toEqual([1, 2, 3]); + expect(res[1].model1Ids).toEqual([]); }); }); @@ -989,8 +980,8 @@ module.exports = (session) => { ) .orderBy('id') .then((res) => { - expect(res[0].model1Ids).to.eql([1, 2, 3]); - expect(res[1].model1Ids).to.eql([]); + expect(res[0].model1Ids).toEqual([1, 2, 3]); + expect(res[1].model1Ids).toEqual([]); }); }); @@ -1008,8 +999,8 @@ module.exports = (session) => { ) .orderBy('id') .then((res) => { - expect(res[0].model1Ids).to.eql([1, 2, 3]); - expect(res[1].model1Ids).to.eql([]); + expect(res[0].model1Ids).toEqual([1, 2, 3]); + expect(res[1].model1Ids).toEqual([]); }); }); } @@ -1032,7 +1023,7 @@ module.exports = (session) => { .select('m1.*', Model1.relatedQuery('model1Relation1').select('id').as('foo')); }) .then((res) => { - expect(res.foo).to.equal(2); + expect(res.foo).toBe(2); }); }); @@ -1058,8 +1049,8 @@ module.exports = (session) => { }); }) .then((res) => { - expect(res).to.have.length(1); - expect(res[0].id).to.equal(1); + expect(res).toHaveLength(1); + expect(res[0].id).toBe(1); }); }); @@ -1091,8 +1082,8 @@ module.exports = (session) => { ); }) .then((res) => { - expect(res).to.have.length(1); - expect(res[0].id).to.equal(1001); + expect(res).toHaveLength(1); + expect(res[0].id).toBe(1001); }); }); @@ -1102,10 +1093,10 @@ module.exports = (session) => { return builder .modify( (modifyBuilder, arg1, arg2, arg3) => { - expect(modifyBuilder).to.equal(builder); - expect(arg1).to.equal('foo'); - expect(arg2).to.equal(undefined); - expect(arg3).to.equal(10); + expect(modifyBuilder).toBe(builder); + expect(arg1).toBe('foo'); + expect(arg2).toBeUndefined(); + expect(arg3).toBe(10); builder.where('model2_prop1', '>=', 'hejsan 2'); }, 'foo', @@ -1113,7 +1104,7 @@ module.exports = (session) => { 10, ) .then((models) => { - expect(models.map((it) => it.model2Prop1).sort()).to.eql(['hejsan 2', 'hejsan 3']); + expect(models.map((it) => it.model2Prop1).sort()).toEqual(['hejsan 2', 'hejsan 3']); }); }); @@ -1144,7 +1135,7 @@ module.exports = (session) => { ); }) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { idCol: 1, model1Id: 1, @@ -1177,38 +1168,36 @@ module.exports = (session) => { }) .withGraphFetched('model1Relation2') .then((results) => { - expect(results.length).to.equal(1); - expect(results[0].model1Prop1).to.equal('hello 1'); + expect(results.length).toBe(1); + expect(results[0].model1Prop1).toBe('hello 1'); }); }); - if (!session.isMySql()) { - it('with', () => { - return Model1.query() - .with('subquery1', Model1.query().unionAll(Model1.query())) - .with('subquery2', Model1.query()) - .count('* as count') - .from('subquery1') - .first() - .then((result) => { - expect(result.count).to.eql(4); - }); - }); + it('with', () => { + return Model1.query() + .with('subquery1', Model1.query().unionAll(Model1.query())) + .with('subquery2', Model1.query()) + .count('* as count') + .from('subquery1') + .first() + .then((result) => { + expect(Number(result.count)).toBe(4); + }); + }); - it('with column list', () => { - return Model1.query() - .with('subquery', ['subId'], Model1.query().select('id')) - .select('subId') - .from('subquery') - .orderBy('subId') - .then((results) => { - expect(results.map((it) => it.subId)).to.eql([1, 2]); - }); - }); - } + it('with column list', () => { + return Model1.query() + .with('subquery', ['subId'], Model1.query().select('id')) + .select('subId') + .from('subquery') + .orderBy('subId') + .then((results) => { + expect(results.map((it) => it.subId)).toEqual([1, 2]); + }); + }); if (session.isPostgres()) { - it('timeout should throw a KnexTimeoutError', (done) => { + it('timeout should throw a KnexTimeoutError', () => { const knexQuery = Model1.query().timeout(50).toKnexQuery(); // Now the tricky part. We add `pg_sleep` as another source table so that the query @@ -1218,13 +1207,13 @@ module.exports = (session) => { Model1: 'Model1', }); - knexQuery - .then(() => done(new Error('should not get here'))) - .catch((err) => { - expect(err).to.be.a(KnexTimeoutError); - done(); + return knexQuery + .then(() => { + throw new Error('should not get here'); }) - .catch(done); + .catch((err) => { + expect(err).toBeInstanceOf(KnexTimeoutError); + }); }); it('smoke test for various methods', () => { @@ -1320,7 +1309,15 @@ module.exports = (session) => { }); describe('relatedQuery()', () => { - before(() => { + // Depending on the database, the counts are either numbers or strings. + const countsToNumbers = ({ rel1Count, rel2Count, rel3Count, ...model }) => ({ + ...model, + rel1Count: Number(rel1Count), + rel2Count: Number(rel2Count), + rel3Count: Number(rel3Count), + }); + + beforeAll(() => { return session.populate([ { id: 1, @@ -1382,11 +1379,11 @@ module.exports = (session) => { ]) .orderBy('id') .then((res) => { - expect(res).to.eql([ - { id: 1, rel1Count: 1, rel2Count: '2', rel3Count: '1', $afterFindCalled: 1 }, - { id: 2, rel1Count: 1, rel2Count: '1', rel3Count: '2', $afterFindCalled: 1 }, - { id: 3, rel1Count: 0, rel2Count: '0', rel3Count: '0', $afterFindCalled: 1 }, - { id: 4, rel1Count: 0, rel2Count: '0', rel3Count: '0', $afterFindCalled: 1 }, + expect(res.map(countsToNumbers)).toEqual([ + { id: 1, rel1Count: 1, rel2Count: 2, rel3Count: 1, $afterFindCalled: 1 }, + { id: 2, rel1Count: 1, rel2Count: 1, rel3Count: 2, $afterFindCalled: 1 }, + { id: 3, rel1Count: 0, rel2Count: 0, rel3Count: 0, $afterFindCalled: 1 }, + { id: 4, rel1Count: 0, rel2Count: 0, rel3Count: 0, $afterFindCalled: 1 }, ]); }); }); @@ -1405,11 +1402,11 @@ module.exports = (session) => { ]) .orderBy('id') .then((res) => { - expect(res).to.eql([ - { id: 1, rel1Count: 1, rel2Count: '2', rel3Count: '1', $afterFindCalled: 1 }, - { id: 2, rel1Count: 1, rel2Count: '1', rel3Count: '2', $afterFindCalled: 1 }, - { id: 3, rel1Count: 0, rel2Count: '0', rel3Count: '0', $afterFindCalled: 1 }, - { id: 4, rel1Count: 0, rel2Count: '0', rel3Count: '0', $afterFindCalled: 1 }, + expect(res.map(countsToNumbers)).toEqual([ + { id: 1, rel1Count: 1, rel2Count: 2, rel3Count: 1, $afterFindCalled: 1 }, + { id: 2, rel1Count: 1, rel2Count: 1, rel3Count: 2, $afterFindCalled: 1 }, + { id: 3, rel1Count: 0, rel2Count: 0, rel3Count: 0, $afterFindCalled: 1 }, + { id: 4, rel1Count: 0, rel2Count: 0, rel3Count: 0, $afterFindCalled: 1 }, ]); }); }); @@ -1419,7 +1416,7 @@ module.exports = (session) => { .select(['id', Model1.relatedQuery('model1Relation1').select('id').as('relId')]) .orderBy('id') .then((res) => { - expect(res).to.eql([ + expect(res).toEqual([ { id: 1, relId: 3, $afterFindCalled: 1 }, { id: 2, relId: 4, $afterFindCalled: 1 }, { id: 3, relId: null, $afterFindCalled: 1 }, @@ -1437,7 +1434,7 @@ module.exports = (session) => { .alias('a1') .orderBy('id') .then((res) => { - expect(res).to.eql([ + expect(res).toEqual([ { id: 1, relId: 3, $afterFindCalled: 1 }, { id: 2, relId: 4, $afterFindCalled: 1 }, { id: 3, relId: null, $afterFindCalled: 1 }, @@ -1451,7 +1448,7 @@ module.exports = (session) => { .where(val(3), Model1.relatedQuery('model1Relation1').select('id')) .first() .then((res) => { - expect(res.id).to.equal(1); + expect(res.id).toBe(1); }); }); @@ -1460,8 +1457,8 @@ module.exports = (session) => { it('find using single id', async () => { const result = await Model1.relatedQuery('model1Relation1').for(1).orderBy('id'); - expect(result.length).to.equal(1); - expect(result[0].id).to.equal(3); + expect(result.length).toBe(1); + expect(result[0].id).toBe(3); }); it('find using single model instance', async () => { @@ -1469,8 +1466,8 @@ module.exports = (session) => { const result = await Model1.relatedQuery('model1Relation1').for(model).orderBy('id'); - expect(result.length).to.equal(1); - expect(result[0].id).to.equal(3); + expect(result.length).toBe(1); + expect(result[0].id).toBe(3); }); it('find using multiple model instances', async () => { @@ -1481,17 +1478,17 @@ module.exports = (session) => { .for([model1, model2]) .orderBy('id'); - expect(result.length).to.equal(2); - expect(result[0].id).to.equal(3); - expect(result[1].id).to.equal(4); + expect(result.length).toBe(2); + expect(result[0].id).toBe(3); + expect(result[1].id).toBe(4); }); it('find using multiple ids', async () => { const result = await Model1.relatedQuery('model1Relation1').for([1, 2]).orderBy('id'); - expect(result.length).to.equal(2); - expect(result[0].id).to.equal(3); - expect(result[1].id).to.equal(4); + expect(result.length).toBe(2); + expect(result[0].id).toBe(3); + expect(result[1].id).toBe(4); }); it('find using multiple ids and a filter', async () => { @@ -1500,8 +1497,8 @@ module.exports = (session) => { .whereNotIn('id', [1, 2, 3]) .orderBy('id'); - expect(result.length).to.equal(1); - expect(result[0].id).to.equal(4); + expect(result.length).toBe(1); + expect(result[0].id).toBe(4); }); it('find using query builder with one result', async () => { @@ -1509,8 +1506,8 @@ module.exports = (session) => { .for(Model1.query().findById(1)) .orderBy('id'); - expect(result.length).to.equal(1); - expect(result[0].id).to.equal(3); + expect(result.length).toBe(1); + expect(result[0].id).toBe(3); }); it('find using query builder with multiple results', async () => { @@ -1518,9 +1515,9 @@ module.exports = (session) => { .for(Model1.query().findByIds([1, 2])) .orderBy('id'); - expect(result.length).to.equal(2); - expect(result[0].id).to.equal(3); - expect(result[1].id).to.equal(4); + expect(result.length).toBe(2); + expect(result[0].id).toBe(3); + expect(result[1].id).toBe(4); }); }); @@ -1528,9 +1525,9 @@ module.exports = (session) => { it('find using single id', async () => { const result = await Model1.relatedQuery('model1Relation2').for(1).orderBy('id_col'); - expect(result.length).to.equal(2); - expect(result[0].idCol).to.equal(1); - expect(result[1].idCol).to.equal(2); + expect(result.length).toBe(2); + expect(result[0].idCol).toBe(1); + expect(result[1].idCol).toBe(2); }); it('find using multiple ids', async () => { @@ -1538,10 +1535,10 @@ module.exports = (session) => { .for([1, 2]) .orderBy('id_col'); - expect(result.length).to.equal(3); - expect(result[0].idCol).to.equal(1); - expect(result[1].idCol).to.equal(2); - expect(result[2].idCol).to.equal(3); + expect(result.length).toBe(3); + expect(result[0].idCol).toBe(1); + expect(result[1].idCol).toBe(2); + expect(result[2].idCol).toBe(3); }); it('find using query builder with one result', async () => { @@ -1549,9 +1546,9 @@ module.exports = (session) => { .for(Model1.query().findById(1)) .orderBy('id_col'); - expect(result.length).to.equal(2); - expect(result[0].idCol).to.equal(1); - expect(result[1].idCol).to.equal(2); + expect(result.length).toBe(2); + expect(result[0].idCol).toBe(1); + expect(result[1].idCol).toBe(2); }); it('find using query builder with multiple results', async () => { @@ -1559,10 +1556,10 @@ module.exports = (session) => { .for(Model1.query().findByIds([1, 2])) .orderBy('id_col'); - expect(result.length).to.equal(3); - expect(result[0].idCol).to.equal(1); - expect(result[1].idCol).to.equal(2); - expect(result[2].idCol).to.equal(3); + expect(result.length).toBe(3); + expect(result[0].idCol).toBe(1); + expect(result[1].idCol).toBe(2); + expect(result[2].idCol).toBe(3); }); }); @@ -1570,8 +1567,8 @@ module.exports = (session) => { it('find using single id', async () => { const result = await Model1.relatedQuery('model1Relation3').for(1).orderBy('id_col'); - expect(result.length).to.equal(1); - expect(result[0].idCol).to.equal(4); + expect(result.length).toBe(1); + expect(result[0].idCol).toBe(4); }); it('find using multiple ids', async () => { @@ -1579,10 +1576,10 @@ module.exports = (session) => { .for([1, 2]) .orderBy('id_col'); - expect(result.length).to.equal(3); - expect(result[0].idCol).to.equal(4); - expect(result[1].idCol).to.equal(5); - expect(result[2].idCol).to.equal(6); + expect(result.length).toBe(3); + expect(result[0].idCol).toBe(4); + expect(result[1].idCol).toBe(5); + expect(result[2].idCol).toBe(6); }); it('find using query builder with one result', async () => { @@ -1590,8 +1587,8 @@ module.exports = (session) => { .for(Model1.query().findById(1)) .orderBy('id_col'); - expect(result.length).to.equal(1); - expect(result[0].idCol).to.equal(4); + expect(result.length).toBe(1); + expect(result[0].idCol).toBe(4); }); it('find using query builder with multiple results', async () => { @@ -1599,17 +1596,17 @@ module.exports = (session) => { .for(Model1.query().where('id', 1).orWhere('id', 2)) .orderBy('id_col'); - expect(result.length).to.equal(3); - expect(result[0].idCol).to.equal(4); - expect(result[1].idCol).to.equal(5); - expect(result[2].idCol).to.equal(6); + expect(result.length).toBe(3); + expect(result[0].idCol).toBe(4); + expect(result[1].idCol).toBe(5); + expect(result[2].idCol).toBe(6); }); }); }); }); describe('joinRelated()', () => { - before(() => { + beforeAll(() => { return session.populate([ { id: 1, @@ -1688,8 +1685,8 @@ module.exports = (session) => { [joinMethod]('model1Relation1') .orderBy('Model1.id') .then((models) => { - expect(models.map((it) => it.id)).to.eql([1, 2, 3, 7]); - expect(models.map((it) => it.rel_model1Prop1)).to.eql([ + expect(models.map((it) => it.id)).toEqual([1, 2, 3, 7]); + expect(models.map((it) => it.rel_model1Prop1)).toEqual([ 'hello 2', 'hello 3', 'hello 4', @@ -1706,7 +1703,7 @@ module.exports = (session) => { [joinMethod]('model1Relation1') .orderBy('Model1.id') .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, rel_model1Prop1: 'hello 2', $afterFindCalled: 1 }, { id: 2, rel_model1Prop1: 'hello 3', $afterFindCalled: 1 }, { id: 3, rel_model1Prop1: 'hello 4', $afterFindCalled: 1 }, @@ -1731,7 +1728,7 @@ module.exports = (session) => { .select('*') .orderBy(['id', 'm2r2Id']) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, m2r2Id: 1, $afterFindCalled: 1 }, { id: 1, m2r2Id: 2, $afterFindCalled: 1 }, { id: 4, m2r2Id: 4, $afterFindCalled: 1 }, @@ -1754,7 +1751,7 @@ module.exports = (session) => { .select('*') .orderBy(['id', 'm2r2Id']) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, m2r2Id: 1, $afterFindCalled: 1 }, { id: 1, m2r2Id: 2, $afterFindCalled: 1 }, { id: 4, m2r2Id: 4, $afterFindCalled: 1 }, @@ -1769,8 +1766,8 @@ module.exports = (session) => { .joinRelated('model1Relation2') .then((models) => { models = sortBy(models, ['id', 'id_col']); - expect(models.map((it) => it.id)).to.eql([1, 1, 4, 7]); - expect(models.map((it) => it.id_col)).to.eql([1, 2, 4, 3]); + expect(models.map((it) => it.id)).toEqual([1, 1, 4, 7]); + expect(models.map((it) => it.id_col)).toEqual([1, 2, 4, 3]); }); }); @@ -1781,8 +1778,8 @@ module.exports = (session) => { .where('model1Relation2.id_col', '<', 4) .then((models) => { models = sortBy(models, ['id', 'id_col']); - expect(models.map((it) => it.id)).to.eql([1, 1, 7]); - expect(models.map((it) => it.id_col)).to.eql([1, 2, 3]); + expect(models.map((it) => it.id)).toEqual([1, 1, 7]); + expect(models.map((it) => it.id_col)).toEqual([1, 2, 3]); }); }); @@ -1792,8 +1789,8 @@ module.exports = (session) => { .joinRelated('model2Relation1') .then((models) => { models = sortBy(models, ['idCol', 'id']); - expect(models.map((it) => it.idCol)).to.eql([1, 2, 2]); - expect(models.map((it) => it.id)).to.eql([5, 6, 7]); + expect(models.map((it) => it.idCol)).toEqual([1, 2, 2]); + expect(models.map((it) => it.id)).toEqual([5, 6, 7]); }); }); @@ -1804,8 +1801,8 @@ module.exports = (session) => { .whereBetween('model2Relation1.id', [5, 6]) .then((models) => { models = sortBy(models, ['idCol', 'id']); - expect(models.map((it) => it.idCol)).to.eql([1, 2]); - expect(models.map((it) => it.id)).to.eql([5, 6]); + expect(models.map((it) => it.idCol)).toEqual([1, 2]); + expect(models.map((it) => it.id)).toEqual([5, 6]); }); }); @@ -1817,8 +1814,8 @@ module.exports = (session) => { .where('m1m2.model1Id', '>', 5) .then((models) => { models = sortBy(models, ['idCol', 'id']); - expect(models.map((it) => it.idCol)).to.eql([2, 2]); - expect(models.map((it) => it.id)).to.eql([6, 7]); + expect(models.map((it) => it.idCol)).toEqual([2, 2]); + expect(models.map((it) => it.id)).toEqual([6, 7]); }); }); @@ -1826,7 +1823,7 @@ module.exports = (session) => { return Model1.query() .innerJoinRelated('model1Relation1') .then((models) => { - expect(models.length).to.equal(4); + expect(models.length).toBe(4); }); }); @@ -1834,7 +1831,7 @@ module.exports = (session) => { return Model1.query() .leftJoinRelated('model1Relation1') .then((models) => { - expect(models.length).to.equal(8); + expect(models.length).toBe(8); }); }); @@ -1845,7 +1842,7 @@ module.exports = (session) => { .where('model1Relation1:model1Relation1.model1Prop1', 'hello 4') .first() .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 2, model1Prop1: 'hello 2', }); @@ -1859,7 +1856,7 @@ module.exports = (session) => { .where('model1Relation1:model1Relation2.model2_prop1', 'hejsan 4') .first() .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 3, model1Prop1: 'hello 3', }); @@ -1873,7 +1870,7 @@ module.exports = (session) => { .where('a:b.model2_prop1', 'hejsan 4') .first() .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 3, model1Prop1: 'hello 3', }); @@ -1887,7 +1884,7 @@ module.exports = (session) => { .where('model1Relation1:model1Relation1:model1Relation2.model2_prop1', 'hejsan 4') .first() .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 2, model1Prop1: 'hello 2', }); @@ -1901,7 +1898,7 @@ module.exports = (session) => { .where('model1Relation2.model2_prop1', 'hejsan 1') .first() .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 1, model1Prop1: 'hello 1', model2Prop1: 'hejsan 1', @@ -1920,7 +1917,7 @@ module.exports = (session) => { .where('model1Relation2:model2Relation1.model1Prop1', 'hello 6') .first() .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 1, model2Prop1: 'hejsan 2', foo: 'hello 6', @@ -1944,7 +1941,7 @@ module.exports = (session) => { .joinRelated('model1Relation2.model2Relation1') .orderBy(['Model1.id', 'model1Relation2.id_col', 'model1Relation2:model2Relation1.id']) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, m1r1Id: 2, @@ -1982,7 +1979,7 @@ module.exports = (session) => { }) .orderBy('Model1.id') .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, m1r1Id: 2, m1r1Id2: 2, $afterFindCalled: 1 }, { id: 2, m1r1Id: 3, m1r1Id2: 3, $afterFindCalled: 1 }, { id: 3, m1r1Id: 4, m1r1Id2: 4, $afterFindCalled: 1 }, @@ -1998,7 +1995,7 @@ module.exports = (session) => { .joinRelated('model1Relation1 as m1r1_2') .orderBy('Model1.id') .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, m1r1Id: 2, m1r1Id2: 2, $afterFindCalled: 1 }, { id: 2, m1r1Id: 3, m1r1Id2: 3, $afterFindCalled: 1 }, { id: 3, m1r1Id: 4, m1r1Id2: 4, $afterFindCalled: 1 }, @@ -2016,7 +2013,7 @@ module.exports = (session) => { .leftJoinRelated('model1Relation2') .orderBy(['Model1.id', 'model1Relation2.id_col']) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, m1r1Id: 2, m1r2Id: 1, $afterFindCalled: 1 }, { id: 1, m1r1Id: 2, m1r2Id: 2, $afterFindCalled: 1 }, { id: 2, m1r1Id: 3, m1r2Id: null, $afterFindCalled: 1 }, @@ -2041,7 +2038,7 @@ module.exports = (session) => { }) .orderBy('Model1.id') .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, m1r1Id: 2, m1r1Id2: 2, $afterFindCalled: 1 }, { id: 2, m1r1Id: 3, m1r1Id2: 3, $afterFindCalled: 1 }, { id: 3, m1r1Id: 4, m1r1Id2: 4, $afterFindCalled: 1 }, @@ -2066,7 +2063,7 @@ module.exports = (session) => { .where('model1Relation2:model2Relation1.model1Prop1', 'hello 6') .first() .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 1, model2Prop1: 'hejsan 2', foo: 'hello 6', @@ -2093,7 +2090,7 @@ module.exports = (session) => { .where('m1r2:m2r1.model1Prop1', 'hello 6') .first() .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 1, model2Prop1: 'hejsan 2', foo: 'hello 6', @@ -2123,7 +2120,7 @@ module.exports = (session) => { .where('m1r2:m2r1.model1Prop1', 'hello 6') .first() .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 1, model2Prop1: 'hejsan 2', foo: 'hello 6', @@ -2160,7 +2157,7 @@ module.exports = (session) => { .where('m1r2:m2r1.model1Prop1', 'hello 6') .first() .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 1, model2Prop1: 'hejsan 2', foo: 'hello 6', @@ -2176,8 +2173,8 @@ module.exports = (session) => { .where('model2.id_col', '<', 4) .then((models) => { models = sortBy(models, ['id', 'id_col']); - expect(models.map((it) => it.id)).to.eql([1, 1, 7]); - expect(models.map((it) => it.id_col)).to.eql([1, 2, 3]); + expect(models.map((it) => it.id)).toEqual([1, 1, 7]); + expect(models.map((it) => it.id_col)).toEqual([1, 2, 3]); }); }); @@ -2188,8 +2185,8 @@ module.exports = (session) => { .where('model1Relation2.id_col', '<', 4) .then((models) => { models = sortBy(models, ['id', 'id_col']); - expect(models.map((it) => it.id)).to.eql([1, 1, 7]); - expect(models.map((it) => it.id_col)).to.eql([1, 2, 3]); + expect(models.map((it) => it.id)).toEqual([1, 1, 7]); + expect(models.map((it) => it.id_col)).toEqual([1, 2, 3]); }); }); @@ -2200,8 +2197,8 @@ module.exports = (session) => { .where('fooBarBaz.id_col', '<', 4) .then((models) => { models = sortBy(models, ['id', 'id_col']); - expect(models.map((it) => it.id)).to.eql([1, 1, 7]); - expect(models.map((it) => it.id_col)).to.eql([1, 2, 3]); + expect(models.map((it) => it.id)).toEqual([1, 1, 7]); + expect(models.map((it) => it.id_col)).toEqual([1, 2, 3]); }); }); @@ -2214,8 +2211,8 @@ module.exports = (session) => { ]) .castTo(Model2) .then((models) => { - expect(models[0]).to.be.a(Model2); - expect(models).to.eql([ + expect(models[0]).toBeInstanceOf(Model2); + expect(models).toEqual([ { idCol: 8, model1Id: 7, @@ -2234,10 +2231,10 @@ module.exports = (session) => { ]) .castTo(Model) .then((models) => { - expect(models[0]).to.be.a(Model); - expect(models[0]).to.not.be.a(Model1); + expect(models[0]).toBeInstanceOf(Model); + expect(models[0]).not.toBeInstanceOf(Model1); - expect(models).to.eql([ + expect(models).toEqual([ { someId: 8, someOtherId: 7, @@ -2255,9 +2252,9 @@ module.exports = (session) => { ]) .castTo() .then((models) => { - expect(models[0]).to.be.a(Model1); + expect(models[0]).toBeInstanceOf(Model1); - expect(models).to.eql([ + expect(models).toEqual([ { $afterFindCalled: 1, someId: 8, @@ -2282,7 +2279,7 @@ module.exports = (session) => { }); }) .then((res) => { - expect(res).to.eql([ + expect(res).toEqual([ { id: 1, model1Prop1: 'hello 1', relCount: 2, $afterFindCalled: 1 }, { id: 2, model1Prop1: 'hello 2', relCount: 1, $afterFindCalled: 1 }, ]); @@ -2297,7 +2294,7 @@ module.exports = (session) => { filterArgs: [5], }) .then((models) => { - expect(models.map((it) => it.id)).to.not.contain(5); + expect(models.map((it) => it.id)).not.toContain(5); }); }); @@ -2333,14 +2330,14 @@ module.exports = (session) => { .findById(1) .where('model1Relation2.id_col', 2) .then((model) => { - expect(model.rawSelect).to.equal('hejsan 2 hejsan 2'); + expect(model.rawSelect).toBe('hejsan 2 hejsan 2'); }); }); } }); describe('.$query()', () => { - before(() => { + beforeAll(() => { return session.populate([ { id: 1, @@ -2373,52 +2370,48 @@ module.exports = (session) => { it('should find the model itself', () => { return Model1.query() .then((models) => { - expect(models.map((it) => it.model1Prop1).sort()).to.eql(['hello 1', 'hello 2']); + expect(models.map((it) => it.model1Prop1).sort()).toEqual(['hello 1', 'hello 2']); models[0].model1Prop1 = 'blaa'; return models[0].$query(); }) .then((model) => { - expect(model).to.be.a(Model1); - expect(model.model1Prop1).to.equal('hello 1'); + expect(model).toBeInstanceOf(Model1); + expect(model.model1Prop1).toBe('hello 1'); }); }); - it('should throw if the id is undefined', (done) => { - Model1.query() + it('should throw if the id is undefined', () => { + return Model1.query() .then((models) => { - expect(models.map((it) => it.model1Prop1).sort()).to.eql(['hello 1', 'hello 2']); + expect(models.map((it) => it.model1Prop1).sort()).toEqual(['hello 1', 'hello 2']); delete models[0].id; return models[0].$query(); }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( `one of the identifier columns [id] is null or undefined. Have you specified the correct identifier column for the model 'Model1' using the 'idColumn' property?`, ); - done(); - }) - .catch(done); + }); }); - it('should throw if the id is null', (done) => { - Model1.query() + it('should throw if the id is null', () => { + return Model1.query() .then((models) => { - expect(models.map((it) => it.model1Prop1).sort()).to.eql(['hello 1', 'hello 2']); + expect(models.map((it) => it.model1Prop1).sort()).toEqual(['hello 1', 'hello 2']); models[0].id = null; return models[0].$query(); }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( `one of the identifier columns [id] is null or undefined. Have you specified the correct identifier column for the model 'Model1' using the 'idColumn' property?`, ); - done(); - }) - .catch(done); + }); }); }); @@ -2427,7 +2420,7 @@ module.exports = (session) => { let parent1; let parent2; - before(() => { + beforeAll(() => { return session.populate([ { id: 1, @@ -2457,9 +2450,9 @@ module.exports = (session) => { it('should return all related rows when no knex methods are chained', () => { return parent1.$relatedQuery('model1Relation1').then((related) => { - expect(related).to.be.a(Model1); - expect(parent1.model1Relation1).to.eql(undefined); - expect(related).to.eql({ + expect(related).toBeInstanceOf(Model1); + expect(parent1.model1Relation1).toBeUndefined(); + expect(related).toEqual({ id: 2, model1Id: null, model1Prop1: 'hello 2', @@ -2473,17 +2466,17 @@ module.exports = (session) => { const parent = await Model1.query().findById(2); const result1 = await parent.$relatedQuery('model1Relation1'); - expect(result1).to.be.equal(undefined); + expect(result1).toBeUndefined(); const result2 = await Model1.query().from( Model1.relatedQuery('model1Relation1').for(parent).as('model1'), ); - expect(result2).to.eql([]); + expect(result2).toEqual([]); const result3 = await Model1.query().from( Model1.relatedQuery('model1Relation1').for(parent.id).as('model1'), ); - expect(result3).to.eql([]); + expect(result3).toEqual([]); }); describe('knex methods', () => { @@ -2492,8 +2485,8 @@ module.exports = (session) => { .$relatedQuery('model1Relation1') .select('id') .then((related) => { - expect(related).to.be.a(Model1); - expect(Object.keys(related).sort()).to.eql(['$afterFindCalled', 'id']); + expect(related).toBeInstanceOf(Model1); + expect(Object.keys(related).sort()).toEqual(['$afterFindCalled', 'id']); }); }); @@ -2502,7 +2495,7 @@ module.exports = (session) => { .$relatedQuery('model1Relation1') .first() .then((value) => { - expect(value).to.eql({ + expect(value).toEqual({ id: 2, model1Id: null, model1Prop1: 'hello 2', @@ -2519,7 +2512,7 @@ module.exports = (session) => { .join('Model1 as Parent', 'Parent.model1Id', 'Model1.id') .first() .then((related) => { - expect(related).to.eql({ + expect(related).toEqual({ id: 2, model1Id: null, model1Prop1: 'hello 2', @@ -2536,7 +2529,7 @@ module.exports = (session) => { let parent1; let parent2; - before(() => { + beforeAll(() => { return session.populate([ { id: 1, @@ -2596,17 +2589,17 @@ module.exports = (session) => { .$relatedQuery('model1Relation2') .orderBy('id_col') .then((related) => { - expect(related.length).to.equal(3); - expect(parent1.model1Relation2).to.equal(undefined); - expect(related[0]).to.be.a(Model2); - expect(related[1]).to.be.a(Model2); - expect(related[2]).to.be.a(Model2); - expect(related.map((it) => it.model2Prop1).sort()).to.eql([ + expect(related.length).toBe(3); + expect(parent1.model1Relation2).toBeUndefined(); + expect(related[0]).toBeInstanceOf(Model2); + expect(related[1]).toBeInstanceOf(Model2); + expect(related[2]).toBeInstanceOf(Model2); + expect(related.map((it) => it.model2Prop1).sort()).toEqual([ 'text 1', 'text 2', 'text 3', ]); - expect(related[0]).to.eql({ + expect(related[0]).toEqual({ idCol: 1, model1Id: parent1.id, model2Prop1: 'text 1', @@ -2618,17 +2611,17 @@ module.exports = (session) => { .$relatedQuery('model1Relation2') .orderBy('id_col') .then((related) => { - expect(related.length).to.equal(3); - expect(parent2.model1Relation2).to.equal(undefined); - expect(related[0]).to.be.a(Model2); - expect(related[1]).to.be.a(Model2); - expect(related[2]).to.be.a(Model2); - expect(related.map((it) => it.model2Prop1).sort()).to.eql([ + expect(related.length).toBe(3); + expect(parent2.model1Relation2).toBeUndefined(); + expect(related[0]).toBeInstanceOf(Model2); + expect(related[1]).toBeInstanceOf(Model2); + expect(related[2]).toBeInstanceOf(Model2); + expect(related.map((it) => it.model2Prop1).sort()).toEqual([ 'text 4', 'text 5', 'text 6', ]); - expect(related[0]).to.eql({ + expect(related[0]).toEqual({ idCol: 4, model1Id: parent2.id, model2Prop1: 'text 4', @@ -2645,12 +2638,12 @@ module.exports = (session) => { .$relatedQuery('model1Relation2') .select('id_col') .then((related) => { - expect(related.length).to.equal(3); - expect(related[0]).to.be.a(Model2); - expect(related[1]).to.be.a(Model2); - expect(related[2]).to.be.a(Model2); - expect(related.map((it) => it.idCol).sort()).to.eql([1, 2, 3]); - expect([...new Set(related.flatMap((it) => Object.keys(it)))].sort()).to.eql([ + expect(related.length).toBe(3); + expect(related[0]).toBeInstanceOf(Model2); + expect(related[1]).toBeInstanceOf(Model2); + expect(related[2]).toBeInstanceOf(Model2); + expect(related.map((it) => it.idCol).sort()).toEqual([1, 2, 3]); + expect([...new Set(related.flatMap((it) => Object.keys(it)))].sort()).toEqual([ '$afterFindCalled', 'idCol', ]); @@ -2662,7 +2655,7 @@ module.exports = (session) => { .$relatedQuery('model1Relation2') .where('model2_prop2', '=', '2') .then((related) => { - expect(related.map((it) => it.model2Prop2)).to.eql([2]); + expect(related.map((it) => it.model2Prop2)).toEqual([2]); }); }); @@ -2671,7 +2664,7 @@ module.exports = (session) => { .$relatedQuery('model1Relation2') .max('model2_prop2 as max'); - expect(max).to.equal(3); + expect(max).toBe(3); }); it('.orWhere()', () => { @@ -2682,7 +2675,7 @@ module.exports = (session) => { }) .orderBy('model2_prop2') .then((related) => { - expect(related.map((it) => it.model2Prop2)).to.eql([1, 3]); + expect(related.map((it) => it.model2Prop2)).toEqual([1, 3]); }); }); @@ -2692,7 +2685,7 @@ module.exports = (session) => { .orderBy('id_col') .first() .then(({ idCol: value }) => { - expect(value).to.eql(4); + expect(value).toBe(4); }); }); @@ -2703,9 +2696,9 @@ module.exports = (session) => { .join('Model1 as Parent', 'model2.model1_id', 'Parent.id') .orderBy('model2.id_col', 'desc') .then((related) => { - expect(related).to.have.length(3); - expect(related[0]).to.be.a(Model2); - expect(related[0]).to.eql({ + expect(related).toHaveLength(3); + expect(related[0]).toBeInstanceOf(Model2); + expect(related[0]).toEqual({ idCol: 6, model1Id: parent2.id, model2Prop1: 'text 6', @@ -2722,7 +2715,7 @@ module.exports = (session) => { let parent1; let parent2; - before(() => { + beforeAll(() => { return session.populate([ { id: 1, @@ -2796,18 +2789,18 @@ module.exports = (session) => { .$relatedQuery('model2Relation1') .orderBy('id') .then((related) => { - expect(related.length).to.equal(3); - expect(parent1.model2Relation1).to.equal(undefined); - expect(related[0]).to.be.a(Model1); - expect(related[1]).to.be.a(Model1); - expect(related[2]).to.be.a(Model1); - expect(related.map((it) => it.model1Prop1).sort()).to.eql([ + expect(related.length).toBe(3); + expect(parent1.model2Relation1).toBeUndefined(); + expect(related[0]).toBeInstanceOf(Model1); + expect(related[1]).toBeInstanceOf(Model1); + expect(related[2]).toBeInstanceOf(Model1); + expect(related.map((it) => it.model1Prop1).sort()).toEqual([ 'blaa 1', 'blaa 2', 'blaa 3', ]); - expect(related.map((it) => it.aliasedExtra).sort()).to.eql([null, null, null]); - expect(related[0]).to.eql({ + expect(related.map((it) => it.aliasedExtra).sort()).toEqual([null, null, null]); + expect(related[0]).toEqual({ id: 3, model1Id: null, model1Prop1: 'blaa 1', @@ -2820,22 +2813,22 @@ module.exports = (session) => { .$relatedQuery('model2Relation1') .orderBy('id') .then((related) => { - expect(related.length).to.equal(3); - expect(parent2.model2Relation1).to.equal(undefined); - expect(related[0]).to.be.a(Model1); - expect(related[1]).to.be.a(Model1); - expect(related[2]).to.be.a(Model1); - expect(related.map((it) => it.model1Prop1).sort()).to.eql([ + expect(related.length).toBe(3); + expect(parent2.model2Relation1).toBeUndefined(); + expect(related[0]).toBeInstanceOf(Model1); + expect(related[1]).toBeInstanceOf(Model1); + expect(related[2]).toBeInstanceOf(Model1); + expect(related.map((it) => it.model1Prop1).sort()).toEqual([ 'blaa 4', 'blaa 5', 'blaa 6', ]); - expect(related.map((it) => it.aliasedExtra).sort()).to.eql([ + expect(related.map((it) => it.aliasedExtra).sort()).toEqual([ 'extra 4', 'extra 6', null, ]); - expect(related[0]).to.eql({ + expect(related[0]).toEqual({ id: 6, model1Id: null, model1Prop1: 'blaa 4', @@ -2855,9 +2848,9 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation3'); }) .then((models) => { - expect(models).to.have.length(1); - expect(models[0]).to.be.a(Model2); - expect(models[0].idCol).to.equal(2); + expect(models).toHaveLength(1); + expect(models[0]).toBeInstanceOf(Model2); + expect(models[0].idCol).toBe(2); }); }); @@ -2866,7 +2859,7 @@ module.exports = (session) => { .$relatedQuery('model2Relation1') .where('extra3', 'extra 6') .then((related) => { - expect(related).to.eql([ + expect(related).toEqual([ { id: 8, model1Id: null, @@ -2885,7 +2878,7 @@ module.exports = (session) => { .aliasFor('Model1Model2', 'm1m2') .where('m1m2.extra3', 'extra 6') .then((related) => { - expect(related).to.eql([ + expect(related).toEqual([ { id: 8, model1Id: null, @@ -2904,12 +2897,12 @@ module.exports = (session) => { .$relatedQuery('model2Relation1') .select('Model1.id') .then((related) => { - expect(related.length).to.equal(3); - expect(related[0]).to.be.a(Model1); - expect(related[1]).to.be.a(Model1); - expect(related[2]).to.be.a(Model1); - expect(related.map((it) => it.id).sort()).to.eql([3, 4, 5]); - expect([...new Set(related.flatMap((it) => Object.keys(it)))].sort()).to.eql([ + expect(related.length).toBe(3); + expect(related[0]).toBeInstanceOf(Model1); + expect(related[1]).toBeInstanceOf(Model1); + expect(related[2]).toBeInstanceOf(Model1); + expect(related.map((it) => it.id).sort()).toEqual([3, 4, 5]); + expect([...new Set(related.flatMap((it) => Object.keys(it)))].sort()).toEqual([ '$afterFindCalled', 'id', ]); @@ -2921,7 +2914,7 @@ module.exports = (session) => { .$relatedQuery('model2Relation1') .where('model1Prop2', '=', '2') .then((related) => { - expect(related.map((it) => it.model1Prop2)).to.eql([2]); + expect(related.map((it) => it.model1Prop2)).toEqual([2]); }); }); @@ -2930,7 +2923,7 @@ module.exports = (session) => { .$relatedQuery('model2Relation1') .min('model1Prop1 as min'); - expect(min).to.equal('blaa 4'); + expect(min).toBe('blaa 4'); }); it('.orWhere()', () => { @@ -2941,7 +2934,7 @@ module.exports = (session) => { }) .orderBy('model1Prop2') .then((related) => { - expect(related.map((it) => it.model1Prop2)).to.eql([1, 3]); + expect(related.map((it) => it.model1Prop2)).toEqual([1, 3]); }); }); @@ -2951,7 +2944,7 @@ module.exports = (session) => { .orderBy('Model1.id') .first() .then(({ id: value }) => { - expect(value).to.eql(3); + expect(value).toBe(3); }); }); }); @@ -2960,7 +2953,7 @@ module.exports = (session) => { describe('has one through relation', () => { let parent; - before(() => { + beforeAll(() => { return session.populate([ { id: 1, @@ -3005,7 +2998,7 @@ module.exports = (session) => { it('should fetch a related model', () => { return parent.$relatedQuery('model2Relation2').then((related) => { - expect(related).to.eql({ + expect(related).toEqual({ id: 3, model1Id: null, model1Prop1: 'blaa 1', diff --git a/tests/integration/graph/GraphInsert.js b/tests/integration/graph/GraphInsert.js index be2ec6694..47bc254de 100644 --- a/tests/integration/graph/GraphInsert.js +++ b/tests/integration/graph/GraphInsert.js @@ -1,15 +1,9 @@ -const mockKnexFactory = require('../../../testUtils/mockKnex'); - -const { expect } = require('chai'); -const { Model, raw } = require('../../../'); -const { ModelGraph } = require('../../../lib/model/graph/ModelGraph'); -const { GraphInsert } = require('../../../lib/queryBuilder/graph/insert/GraphInsert'); -const { GraphOptions } = require('../../../lib/queryBuilder/graph/GraphOptions'); -const { GraphNodeDbExistence } = require('../../../lib/queryBuilder/graph/GraphNodeDbExistence'); -const { GraphFetcher } = require('../../../lib/queryBuilder/graph/GraphFetcher'); -const { asArray } = require('../../../lib/utils/objectUtils'); - -module.exports = (session) => { +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import mockKnexFactory from '../../../testUtils/mockKnex.js'; + +import { Model, raw } from 'objection'; + +export default (session) => { const ID_NOT_IN_DB = 1000000; // SQL Server has no boolean expressions, so it gets a bound value instead. const rawTrue = () => (session.isMsSql() ? raw('?', [true]) : raw('1 = 1')); @@ -20,7 +14,7 @@ module.exports = (session) => { let mockKnex = null; let numExecutedQueries = 0; - before(createSchema); + beforeAll(createSchema); beforeEach(createModels); beforeEach(() => { return session @@ -30,7 +24,7 @@ module.exports = (session) => { .then(() => session.knex('persons').delete()); }); - after(dropSchema); + afterAll(dropSchema); it('should insert one object', () => { return test({ @@ -284,6 +278,10 @@ module.exports = (session) => { it("should only insert rows that don't exists in db", () => { return test({ modelClass: Person, + // `insertGraph()` doesn't fetch the existing rows, so leave them + // untouched with `upsertGraph()`: + upsert: true, + graphOptions: { noUpdate: true, noDelete: true }, graphIn: { id: matti.id, @@ -337,7 +335,8 @@ module.exports = (session) => { ], }, - postgresNumQueries: 3, + // 3 queries to fetch the current graph and 3 to insert the new rows: + postgresNumQueries: 6, }); }); }); @@ -443,7 +442,7 @@ module.exports = (session) => { postgresNumQueries: 2, check(graph) { - expect(graph[0].id).to.equal(graph[0].pets[0].favoritePerson.id); + expect(graph[0].id).toBe(graph[0].pets[0].favoritePerson.id); }, }); }); @@ -782,7 +781,7 @@ module.exports = (session) => { postgresNumQueries: 2, check(graphOut) { - expect(graphOut[0].id).to.equal(graphOut[0].relatives[0].id); + expect(graphOut[0].id).toBe(graphOut[0].relatives[0].id); }, }); }); @@ -1025,46 +1024,27 @@ module.exports = (session) => { function test({ modelClass, graphIn, - graphOptions: rawGraphOptions = {}, + graphOptions = {}, + upsert = false, queryOut, graphOut, postgresNumQueries = null, check, }) { - const builder = modelClass.query(); - const models = modelClass.ensureModelArray(graphIn, { skipValidation: true }); - rawGraphOptions = Object.assign({}, rawGraphOptions, { insertMissing: true }); - const graphOptions = new GraphOptions(rawGraphOptions); - const graph = assignDbRefsAsRelateProps(ModelGraph.create(modelClass, models)); - const nodeDbExistence = GraphNodeDbExistence.createEveryNodeExistsExistence(); - - return GraphFetcher.fetchCurrentGraph({ builder, graph, graphOptions }) - .then((currentGraph) => { - numExecutedQueries = 0; - - const graphInsert = new GraphInsert({ - graph, - currentGraph, - graphOptions, - nodeDbExistence, - }); - const actions = graphInsert.createActions(); - let promise = Promise.resolve(); + numExecutedQueries = 0; - actions.forEach((action) => { - promise = promise.then(() => action.run(builder)); - }); + const query = modelClass.query(); + const options = { allowRefs: true, ...graphOptions }; - return promise; - }) + return (upsert ? query.upsertGraph(graphIn, options) : query.insertGraph(graphIn, options)) .then(() => { if (session.isPostgres() && postgresNumQueries) { - expect(numExecutedQueries).to.equal(postgresNumQueries); + expect(numExecutedQueries).toBe(postgresNumQueries); } }) .then(() => queryOut(modelClass.query())) .then((result) => { - expect(result).to.containSubset(graphOut); + expect(result).toContainSubset(graphOut); if (check) { check(result); @@ -1072,18 +1052,6 @@ module.exports = (session) => { }); } - function assignDbRefsAsRelateProps(graph) { - for (const node of graph.nodes) { - if (!node.parentEdge || !node.parentEdge.relation || !node.isDbReference) { - continue; - } - - node.parentEdge.relation.setRelateProp(node.obj, asArray(node.dbReference)); - } - - return graph; - } - async function createSchema() { await session.knex.schema .dropTableIfExists('relatives') @@ -1092,7 +1060,7 @@ module.exports = (session) => { await session.createTable('persons', (table, { increments }) => { increments('id'); table.string('name'); - table.text('data'); + table.json('data'); }); await session.createTable('pets', (table, { increments }) => { increments('id'); diff --git a/tests/integration/index.js b/tests/integration/index.js index 5ddc9d232..76a250ba2 100644 --- a/tests/integration/index.js +++ b/tests/integration/index.js @@ -1,7 +1,40 @@ -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const TestSession = require('./../../testUtils/TestSession'); +import { describe, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import TestSession from '../../testUtils/TestSession.js'; +import misc from './misc/index.js'; +import find from './find.js'; +import insert from './insert.js'; +import insertGraph from './insertGraph.js'; +import upsertGraph from './upsertGraph.js'; +import update from './update.js'; +import patch from './patch.js'; +import deleteTests from './delete.js'; +import relate from './relate.js'; +import unrelate from './unrelate.js'; +import manyToManyModify from './manyToManyModify.js'; +import withGraph from './withGraph.js'; +import transactions from './transactions.js'; +import queryContext from './queryContext.js'; +import compositeKeys from './compositeKeys.js'; +import crossDb from './crossDb/index.js'; +import viewsAndAliases from './viewsAndAliases.js'; +import schema from './schema.js'; +import knexSnakeCase from './knexSnakeCase.js'; +import snakeCase from './snakeCase.js'; +import knexIdentifierMapping from './knexIdentifierMapping.js'; +import GraphInsert from './graph/GraphInsert.js'; +import relationModify from './relationModify.js'; +import nonPrimaryKeyRelations from './nonPrimaryKeyRelations.js'; +import staticHooks from './staticHooks.js'; +import modifiers from './modifiers.js'; +import modifyJoin from './modifyJoin.js'; +import toKnexQuery from './toKnexQuery.js'; +import relationOwnerGrouping from './relationOwnerGrouping.js'; +import jsonQueries from './jsonQueries.js'; +import jsonRelations from './jsonRelations.js'; +import belongsToMany from './belongsToMany.js'; // DATABASES environment variable can contain a comma separated list // of databases to test. @@ -88,53 +121,51 @@ describe('integration tests', () => { }); describe(knexConfig.client, () => { - before(() => { + beforeAll(() => { return session.createDb(); }); - require('./misc')(session); - require('./find')(session); - require('./insert')(session); - require('./insertGraph')(session); - require('./upsertGraph')(session); - require('./update')(session); - require('./patch')(session); - require('./delete')(session); - require('./relate')(session); - require('./unrelate')(session); - require('./manyToManyModify')(session); - require('./withGraph')(session); - require('./transactions')(session); - require('./queryContext')(session); - require('./compositeKeys')(session); - require('./crossDb')(session); - require('./viewsAndAliases')(session); - require('./schema')(session); - require('./knexSnakeCase')(session); - require('./snakeCase')(session); - require('./knexIdentifierMapping')(session); - require('./graph/GraphInsert')(session); - require('./relationModify')(session); - require('./nonPrimaryKeyRelations')(session); - require('./staticHooks')(session); - require('./modifiers')(session); - require('./toKnexQuery')(session); - require('./relationOwnerGrouping')(session); + misc(session); + find(session); + insert(session); + insertGraph(session); + upsertGraph(session); + update(session); + patch(session); + deleteTests(session); + relate(session); + unrelate(session); + manyToManyModify(session); + withGraph(session); + transactions(session); + queryContext(session); + compositeKeys(session); + crossDb(session); + viewsAndAliases(session); + schema(session); + knexSnakeCase(session); + snakeCase(session); + knexIdentifierMapping(session); + GraphInsert(session); + relationModify(session); + nonPrimaryKeyRelations(session); + staticHooks(session); + modifiers(session); + modifyJoin(session); + toKnexQuery(session); + relationOwnerGrouping(session); + belongsToMany(session); - if (session.isPostgres()) { - require('./jsonQueries')(session); - require('./jsonRelations')(session); - } - - if (session.isMySql()) { - require('./jsonQueriesMySql')(session); + if (!session.isMsSql()) { + jsonQueries(session); + jsonRelations(session); } }); return session; }); - after(async () => { + afterAll(async () => { await Promise.all( sessions.map((session) => { return session.destroy(); diff --git a/tests/integration/insert.js b/tests/integration/insert.js index 9552863ef..68af03403 100644 --- a/tests/integration/insert.js +++ b/tests/integration/insert.js @@ -1,10 +1,9 @@ -const chai = require('chai'); -const expect = require('expect.js'); -const { inheritModel } = require('../../lib/model/inheritModel'); -const { ValidationError, UniqueViolationError } = require('../../'); -const { delay } = require('../../testUtils/testUtils'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { inheritModel } from '../../lib/model/inheritModel.js'; +import { Model, ValidationError, UniqueViolationError } from 'objection'; +import { delay } from '../../testUtils/testUtils.js'; -module.exports = (session) => { +export default (session) => { let Model1 = session.models.Model1; let Model2 = session.models.Model2; @@ -39,15 +38,15 @@ module.exports = (session) => { return Model1.query() .insert(model) .then((inserted) => { - expect(inserted).to.be.a(Model1); - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.eql(3); - expect(inserted.model1Prop1).to.equal('hello 3'); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(3); + expect(inserted.model1Prop1).toBe('hello 3'); return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', @@ -60,7 +59,7 @@ module.exports = (session) => { await Model1.query().insert({ id: 1 }); throw new Error('should not get here'); } catch (err) { - expect(err).to.be.a(UniqueViolationError); + expect(err).toBeInstanceOf(UniqueViolationError); } }); @@ -79,15 +78,15 @@ module.exports = (session) => { return Mod.query() .insert({ model1Prop1: 'hello 3' }) .then((inserted) => { - expect(inserted).to.be.a(Mod); - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.eql(3); - expect(inserted.model1Prop1).to.equal('hello 3'); + expect(inserted).toBeInstanceOf(Mod); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(3); + expect(inserted.model1Prop1).toBe('hello 3'); return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', @@ -105,15 +104,15 @@ module.exports = (session) => { return Model1.query() .insert(model) .then((inserted) => { - expect(inserted).to.be.a(Model1); - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.eql(3); - expect(inserted.model1Prop1).to.equal('hello 3'); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(3); + expect(inserted.model1Prop1).toBe('hello 3'); return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', @@ -143,15 +142,15 @@ module.exports = (session) => { return Mod.query() .insert(model) .then((inserted) => { - expect(inserted).to.be.a(Model1); - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.eql(3); - expect(inserted.model1Prop1).to.equal('hello 3'); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(3); + expect(inserted.model1Prop1).toBe('hello 3'); return session.knex(Mod.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', @@ -169,15 +168,15 @@ module.exports = (session) => { return Model1.query() .insert(model) .then((inserted) => { - expect(inserted).to.be.a(Model1); - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.eql(3); - expect(inserted.model1Prop1).to.equal('hello 3'); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(3); + expect(inserted.model1Prop1).toBe('hello 3'); return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', @@ -191,18 +190,18 @@ module.exports = (session) => { return Model1.query() .insert(model) .then((inserted) => { - expect(inserted).to.be.a(Model1); - expect(inserted.id).to.equal(1000); - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.model1Prop1).to.equal('hello 3'); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.id).toBe(1000); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.model1Prop1).toBe('hello 3'); return session.knex(Model1.getTableName()); }) .then((rows) => { expect( rows.filter((it) => it.id === 1000 && it.model1Prop1 === 'hello 3'), - ).to.have.length(1); - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + ).toHaveLength(1); + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', @@ -210,7 +209,8 @@ module.exports = (session) => { }); }); - if (session.isPostgres()) { + // MySQL doesn't return the ids of several inserted rows. + if (!session.isMySql()) { it('should accept an array', () => { let models = [ Model1.fromJson({ model1Prop1: 'hello 3' }), @@ -220,24 +220,24 @@ module.exports = (session) => { return Model1.query() .insert(models) .then((inserted) => { - expect(inserted[0]).to.be.a(Model1); - expect(inserted[1]).to.be.a(Model1); - expect(inserted[0].$beforeInsertCalled).to.equal(1); - expect(inserted[0].$afterInsertCalled).to.equal(1); - expect(inserted[1].$beforeInsertCalled).to.equal(1); - expect(inserted[1].$afterInsertCalled).to.equal(1); - expect(inserted.map((it) => it.id).sort()).to.eql([3, 4]); - expect(inserted.map((it) => it.model1Prop1).sort()).to.eql(['hello 3', 'hello 4']); + expect(inserted[0]).toBeInstanceOf(Model1); + expect(inserted[1]).toBeInstanceOf(Model1); + expect(inserted[0].$beforeInsertCalled).toBe(1); + expect(inserted[0].$afterInsertCalled).toBe(1); + expect(inserted[1].$beforeInsertCalled).toBe(1); + expect(inserted[1].$afterInsertCalled).toBe(1); + expect(inserted.map((it) => it.id).sort()).toEqual([3, 4]); + expect(inserted.map((it) => it.model1Prop1).sort()).toEqual(['hello 3', 'hello 4']); return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', 'hello 4', ]); - expect(rows.map((it) => it.id).sort()).to.eql([1, 2, 3, 4]); + expect(rows.map((it) => it.id).sort()).toEqual([1, 2, 3, 4]); }); }); } @@ -246,13 +246,13 @@ module.exports = (session) => { return Model1.query() .insert({ model1Prop1: 'hello 3' }) .then((inserted) => { - expect(inserted).to.be.a(Model1); - expect(inserted.id).to.eql(3); - expect(inserted.model1Prop1).to.equal('hello 3'); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.id).toBe(3); + expect(inserted.model1Prop1).toBe('hello 3'); return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', @@ -267,51 +267,98 @@ module.exports = (session) => { model1Prop2: Model1.raw('5 + 8'), }) .then((inserted) => { - expect(inserted).to.be.a(Model1); - expect(inserted.id).to.eql(3); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.id).toBe(3); return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'test 2', ]); - expect( - rows.find((it) => it.id === 3 && it.model1Prop1 === 'test 2').model1Prop2, - ).to.equal(13); + expect(rows.find((it) => it.id === 3 && it.model1Prop1 === 'test 2').model1Prop2).toBe( + 13, + ); }); }); - if (session.isPostgres()) { - it('should accept a json array', () => { - return Model1.query() - .insert([{ model1Prop1: 'hello 3' }, { model1Prop1: 'hello 4' }]) - .then((inserted) => { - expect(inserted[0]).to.be.a(Model1); - expect(inserted[1]).to.be.a(Model1); - expect(inserted.map((it) => it.id).sort()).to.eql([3, 4]); - expect(inserted.map((it) => it.model1Prop1).sort()).to.eql(['hello 3', 'hello 4']); - return session.knex(Model1.getTableName()); - }) - .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ - 'hello 1', - 'hello 2', - 'hello 3', - 'hello 4', - ]); - expect(rows.map((it) => it.id).sort()).to.eql([1, 2, 3, 4]); - }); - }); + it('should accept a json array of models that get their ids in $beforeInsert', async () => { + let nextId = 200; + + class IdModel extends Model1 { + $beforeInsert(context) { + super.$beforeInsert(context); + this.id = nextId++; + } + } + + const inserted = await IdModel.query().insert([ + { model1Prop1: 'hello 200' }, + { model1Prop1: 'hello 201' }, + ]); + + expect(inserted.map((it) => it.id)).toEqual([200, 201]); + expect(await session.knex(Model1.getTableName()).whereIn('id', [200, 201])).toHaveLength(2); + }); + + it('should accept a json array of models whose insert a hook cancels', async () => { + class CancelModel extends Model1 { + static beforeInsert({ cancelQuery }) { + cancelQuery([]); + } + } + await CancelModel.query().insert([ + { model1Prop1: 'cancelled 1' }, + { model1Prop1: 'cancelled 2' }, + ]); + + expect( + await session.knex(Model1.getTableName()).where('model1Prop1', 'like', 'cancelled%'), + ).toEqual([]); + }); + + it('should accept a json array of models with ids', async () => { + const inserted = await Model1.query().insert([ + { id: 100, model1Prop1: 'hello 100' }, + { id: 101, model1Prop1: 'hello 101' }, + ]); + + expect(inserted.map((it) => it.id)).toEqual([100, 101]); + expect(await session.knex(Model1.getTableName()).whereIn('id', [100, 101])).toHaveLength(2); + }); + + // MySQL doesn't return the ids of all rows inserted by one query. + it.skipIf(session.isMySql())('should accept a json array', () => { + return Model1.query() + .insert([{ model1Prop1: 'hello 3' }, { model1Prop1: 'hello 4' }]) + .then((inserted) => { + expect(inserted[0]).toBeInstanceOf(Model1); + expect(inserted[1]).toBeInstanceOf(Model1); + expect(inserted.map((it) => it.id).sort()).toEqual([3, 4]); + expect(inserted.map((it) => it.model1Prop1).sort()).toEqual(['hello 3', 'hello 4']); + return session.knex(Model1.getTableName()); + }) + .then((rows) => { + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ + 'hello 1', + 'hello 2', + 'hello 3', + 'hello 4', + ]); + expect(rows.map((it) => it.id).sort()).toEqual([1, 2, 3, 4]); + }); + }); + + if (session.isPostgres()) { it('returning("*") should return all columns', () => { return Model1.query() .insert({ model1Prop1: 'hello 3' }) .returning('*') .then((inserted) => { - expect(inserted).to.be.a(Model1); - expect(inserted.$toJson()).to.eql({ + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.$toJson()).toEqual({ id: 3, model1Id: null, model1Prop1: 'hello 3', @@ -320,12 +367,12 @@ module.exports = (session) => { return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', ]); - expect(rows.map((it) => it.id).sort()).to.eql([1, 2, 3]); + expect(rows.map((it) => it.id).sort()).toEqual([1, 2, 3]); }); }); @@ -334,22 +381,22 @@ module.exports = (session) => { .insert({ model1Prop1: Model1.raw("'hello' || ' 3'") }) .returning('model1Prop1') .then((inserted) => { - expect(inserted).to.be.a(Model1); - expect(inserted.$toJson()).to.eql({ model1Prop1: 'hello 3' }); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.$toJson()).toEqual({ model1Prop1: 'hello 3' }); return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', ]); - expect(rows.map((it) => it.id).sort()).to.eql([1, 2, 3]); + expect(rows.map((it) => it.id).sort()).toEqual([1, 2, 3]); }); }); } - it('should validate', (done) => { + it('should validate', () => { let ModelWithSchema = subClassWithSchema(Model1, { type: 'object', properties: { @@ -359,25 +406,23 @@ module.exports = (session) => { }, }); - ModelWithSchema.query() + return ModelWithSchema.query() .insert({ model1Prop1: 666 }) .then((x) => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal('model1Prop1: must be string'); - expect(err).to.be.a(ValidationError); - expect(err).to.be.a(ModelWithSchema.ValidationError); + expect(err.message).toBe('model1Prop1: must be string'); + expect(err).toBeInstanceOf(ValidationError); + expect(err).toBeInstanceOf(ModelWithSchema.ValidationError); return session.knex(Model1.getTableName()).then((rows) => { - expect(rows.map((it) => it.id).sort()).to.eql([1, 2]); - done(); + expect(rows.map((it) => it.id).sort()).toEqual([1, 2]); }); - }) - .catch(done); + }); }); - it('should use `Model.createValidationError` to create the error', (done) => { + it('should use `Model.createValidationError` to create the error', () => { class MyError extends Error { constructor({ data }) { super('MyError'); @@ -398,14 +443,14 @@ module.exports = (session) => { return new MyError(props); }; - ModelWithSchema.query() + return ModelWithSchema.query() .insert({ model1Prop1: 666 }) .then((x) => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(MyError); - expect(err.errors).to.eql({ + expect(err).toBeInstanceOf(MyError); + expect(err.errors).toEqual({ model1Prop1: [ { message: 'must be string', @@ -418,11 +463,9 @@ module.exports = (session) => { }); return session.knex(Model1.getTableName()).then((rows) => { - expect(rows.map((it) => it.id).sort()).to.eql([1, 2]); - done(); + expect(rows.map((it) => it.id).sort()).toEqual([1, 2]); }); - }) - .catch(done); + }); }); it('should allow properties with same names as relations', () => { @@ -440,7 +483,7 @@ module.exports = (session) => { return Mod.query() .insert({ model1Prop1: 123, model1Relation1: 666 }) .then((inserted) => { - expect(inserted.model1Prop1).to.equal(666); + expect(inserted.model1Prop1).toBe(666); }); }); }); @@ -475,9 +518,9 @@ module.exports = (session) => { return Model1.query() .insertAndFetch(model) .then((inserted) => { - expect(inserted).to.be.a(Model1); - expect(inserted).to.equal(model); - expect(inserted).to.eql({ + expect(inserted).toBeInstanceOf(Model1); + expect(inserted).toBe(model); + expect(inserted).toEqual({ id: 3, model1Prop1: 'hello 3', model1Prop2: null, @@ -488,7 +531,7 @@ module.exports = (session) => { return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', @@ -504,11 +547,11 @@ module.exports = (session) => { return Model1.query() .insertAndFetch([model1, model2]) .then((inserted) => { - expect(inserted).to.have.length(2); + expect(inserted).toHaveLength(2); - expect(inserted[0]).to.be.a(Model1); - expect(inserted[0]).to.equal(model1); - expect(inserted[0]).to.eql({ + expect(inserted[0]).toBeInstanceOf(Model1); + expect(inserted[0]).toBe(model1); + expect(inserted[0]).toEqual({ id: 3, model1Prop1: 'hello 3', model1Prop2: null, @@ -517,9 +560,9 @@ module.exports = (session) => { $afterInsertCalled: 1, }); - expect(inserted[1]).to.be.a(Model1); - expect(inserted[1]).to.equal(model2); - expect(inserted[1]).to.eql({ + expect(inserted[1]).toBeInstanceOf(Model1); + expect(inserted[1]).toBe(model2); + expect(inserted[1]).toEqual({ id: 4, model1Prop1: 'hello 4', model1Prop2: 10, @@ -531,7 +574,7 @@ module.exports = (session) => { return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', @@ -561,15 +604,15 @@ module.exports = (session) => { .$query() .insert() .then((inserted) => { - expect(inserted).to.be.a(Model1); - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.eql(3); - expect(inserted.model1Prop1).to.equal('hello 3'); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(3); + expect(inserted.model1Prop1).toBe('hello 3'); return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', @@ -591,12 +634,12 @@ module.exports = (session) => { .$query() .insert() .then((inserted) => { - expect(inserted).to.be.a(Model1); - expect(inserted.model1Prop1).to.equal('hello 3'); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.model1Prop1).toBe('hello 3'); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', @@ -637,8 +680,8 @@ module.exports = (session) => { return parent1 .$relatedQuery('model1Relation1') .then((model) => { - expect(parent1.model1Id).to.equal(null); - expect(model).to.eql(undefined); + expect(parent1.model1Id).toBeNull(); + expect(model).toBeUndefined(); return parent1 .$relatedQuery('model1Relation1') @@ -646,18 +689,18 @@ module.exports = (session) => { }) .then(($inserted) => { inserted = $inserted; - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.equal(3); - expect(inserted).to.be.a(Model1); - expect(inserted.model1Prop1).to.equal('test'); - expect(parent1.model1Relation1).to.equal(undefined); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(3); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.model1Prop1).toBe('test'); + expect(parent1.model1Relation1).toBeUndefined(); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows.find((it) => it.id === parent1.id).model1Id).to.equal(3); - expect(rows.find((it) => it.id === inserted.id).model1Prop1).to.equal('test'); + expect(rows).toHaveLength(3); + expect(rows.find((it) => it.id === parent1.id).model1Id).toBe(3); + expect(rows.find((it) => it.id === inserted.id).model1Prop1).toBe('test'); }); }); @@ -668,18 +711,18 @@ module.exports = (session) => { .insert({ model1Prop1: 'inserted' }) .then(($inserted) => { inserted = $inserted; - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.equal(3); - expect(inserted).to.be.a(Model1); - expect(inserted.model1Prop1).to.equal('inserted'); - expect(parent1.model1Relation1).to.equal(undefined); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(3); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.model1Prop1).toBe('inserted'); + expect(parent1.model1Relation1).toBeUndefined(); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows.find((it) => it.id === parent1.id).model1Id).to.equal(3); - expect(rows.find((it) => it.id === inserted.id).model1Prop1).to.equal('inserted'); + expect(rows).toHaveLength(3); + expect(rows.find((it) => it.id === parent1.id).model1Id).toBe(3); + expect(rows.find((it) => it.id === inserted.id).model1Prop1).toBe('inserted'); }); }); @@ -693,16 +736,16 @@ module.exports = (session) => { }) .then(($inserted) => { inserted = $inserted; - expect(inserted).to.be.a(Model1); - expect(inserted.id).to.equal(4); - expect(inserted.model1Prop1).to.equal('inserted 2'); - expect(parent1.model1Relation1).to.equal(undefined); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.id).toBe(4); + expect(inserted.model1Prop1).toBe('inserted 2'); + expect(parent1.model1Relation1).toBeUndefined(); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(4); - expect(rows.find((it) => it.id === parent1.id).model1Id).to.equal(4); - expect(rows.find((it) => it.id === inserted.id).model1Prop1).to.equal('inserted 2'); + expect(rows).toHaveLength(4); + expect(rows.find((it) => it.id === parent1.id).model1Id).toBe(4); + expect(rows.find((it) => it.id === inserted.id).model1Prop1).toBe('inserted 2'); }); }); @@ -712,10 +755,10 @@ module.exports = (session) => { .insert({ model1Prop1: 'inserted' }) .resolve([]); - expect(parent1.model1Id).to.equal(null); + expect(parent1.model1Id).toBe(null); const rows = await session.knex('Model1'); - expect(rows).to.have.length(2); - expect(rows.find((it) => it.id === parent1.id).model1Id).to.equal(null); + expect(rows).toHaveLength(2); + expect(rows.find((it) => it.id === parent1.id).model1Id).toBe(null); }); }); @@ -750,7 +793,7 @@ module.exports = (session) => { return parent1 .$relatedQuery('model1Relation1Inverse') .then((model) => { - expect(model).to.eql(undefined); + expect(model).toBeUndefined(); return parent1 .$relatedQuery('model1Relation1Inverse') @@ -758,18 +801,18 @@ module.exports = (session) => { }) .then(($inserted) => { inserted = $inserted; - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.equal(3); - expect(inserted).to.be.a(Model1); - expect(inserted.model1Prop1).to.equal('test'); - expect(parent1.model1Relation1Inverse).to.equal(undefined); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(3); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.model1Prop1).toBe('test'); + expect(parent1.model1Relation1Inverse).toBeUndefined(); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows.find((it) => it.id === inserted.id).model1Id).to.equal(parent1.id); - expect(rows.find((it) => it.id === inserted.id).model1Prop1).to.equal('test'); + expect(rows).toHaveLength(3); + expect(rows.find((it) => it.id === inserted.id).model1Id).toBe(parent1.id); + expect(rows.find((it) => it.id === inserted.id).model1Prop1).toBe('test'); }); }); @@ -780,7 +823,7 @@ module.exports = (session) => { return parent1 .$relatedQuery('model1Relation1Inverse') .then((model) => { - expect(model).to.eql(undefined); + expect(model).toBeUndefined(); return parent1 .$relatedQuery('model1Relation1Inverse') @@ -788,18 +831,18 @@ module.exports = (session) => { }) .then(($inserted) => { inserted = $inserted; - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.equal(3); - expect(inserted).to.be.a(Model1); - expect(inserted.model1Prop1).to.equal('test'); - expect(parent1.model1Relation1Inverse).to.equal(undefined); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(3); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.model1Prop1).toBe('test'); + expect(parent1.model1Relation1Inverse).toBeUndefined(); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows.find((it) => it.id === inserted.id).model1Id).to.equal(parent1.id); - expect(rows.find((it) => it.id === inserted.id).model1Prop1).to.equal('test'); + expect(rows).toHaveLength(3); + expect(rows.find((it) => it.id === inserted.id).model1Id).toBe(parent1.id); + expect(rows.find((it) => it.id === inserted.id).model1Prop1).toBe('test'); }); }); }); @@ -848,7 +891,7 @@ module.exports = (session) => { return parent1 .$relatedQuery('model1Relation2') .then((models) => { - expect(models).to.have.length(1); + expect(models).toHaveLength(1); return parent1 .$relatedQuery('model1Relation2') @@ -856,21 +899,19 @@ module.exports = (session) => { }) .then(($inserted) => { inserted = $inserted; - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.idCol).to.equal(3); - expect(inserted).to.be.a(Model2); - expect(inserted.model2Prop1).to.equal('test'); - expect(inserted.model1Id).to.equal(parent1.id); - expect(parent1.model1Relation2).to.eql(undefined); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.idCol).toBe(3); + expect(inserted).toBeInstanceOf(Model2); + expect(inserted.model2Prop1).toBe('test'); + expect(inserted.model1Id).toBe(parent1.id); + expect(parent1.model1Relation2).toBeUndefined(); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows.find((it) => it.id_col === inserted.idCol).model1_id).to.equal( - parent1.id, - ); - expect(rows.find((it) => it.id_col === inserted.idCol).model2_prop1).to.equal('test'); + expect(rows).toHaveLength(3); + expect(rows.find((it) => it.id_col === inserted.idCol).model1_id).toBe(parent1.id); + expect(rows.find((it) => it.id_col === inserted.idCol).model2_prop1).toBe('test'); }); }); @@ -880,38 +921,37 @@ module.exports = (session) => { return parent1 .$relatedQuery('model1Relation2') .then((models) => { - expect(models).to.have.length(1); + expect(models).toHaveLength(1); return parent1.$relatedQuery('model1Relation2').insert({ model2Prop1: 'test' }); }) .then(($inserted) => { inserted = $inserted; - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.idCol).to.equal(3); - expect(inserted).to.be.a(Model2); - expect(inserted.model2Prop1).to.equal('test'); - expect(inserted.model1Id).to.equal(parent1.id); - expect(parent1.model1Relation2).to.eql(undefined); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.idCol).toBe(3); + expect(inserted).toBeInstanceOf(Model2); + expect(inserted.model2Prop1).toBe('test'); + expect(inserted.model1Id).toBe(parent1.id); + expect(parent1.model1Relation2).toBeUndefined(); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows.find((it) => it.id_col === inserted.idCol).model1_id).to.equal( - parent1.id, - ); - expect(rows.find((it) => it.id_col === inserted.idCol).model2_prop1).to.equal('test'); + expect(rows).toHaveLength(3); + expect(rows.find((it) => it.id_col === inserted.idCol).model1_id).toBe(parent1.id); + expect(rows.find((it) => it.id_col === inserted.idCol).model2_prop1).toBe('test'); }); }); - if (session.isPostgres()) { + // MySQL doesn't return the ids of several inserted rows. + if (!session.isMySql()) { it('should accept an array', () => { let inserted = null; return parent1 .$relatedQuery('model1Relation2') .then((models) => { - expect(models).to.have.length(1); + expect(models).toHaveLength(1); return parent1 .$relatedQuery('model1Relation2') @@ -922,29 +962,29 @@ module.exports = (session) => { }) .then(($inserted) => { inserted = $inserted; - expect(inserted[0].idCol).to.equal(3); - expect(inserted[1].idCol).to.equal(4); - expect(inserted[0]).to.be.a(Model2); - expect(inserted[1]).to.be.a(Model2); - expect(inserted[0].model2Prop1).to.equal('test 1'); - expect(inserted[1].model2Prop1).to.equal('test 2'); - expect(inserted[0].model1Id).to.equal(parent1.id); - expect(inserted[1].model1Id).to.equal(parent1.id); - expect(parent1.model1Relation2).to.eql(undefined); + expect(inserted[0].idCol).toBe(3); + expect(inserted[1].idCol).toBe(4); + expect(inserted[0]).toBeInstanceOf(Model2); + expect(inserted[1]).toBeInstanceOf(Model2); + expect(inserted[0].model2Prop1).toBe('test 1'); + expect(inserted[1].model2Prop1).toBe('test 2'); + expect(inserted[0].model1Id).toBe(parent1.id); + expect(inserted[1].model1Id).toBe(parent1.id); + expect(parent1.model1Relation2).toBeUndefined(); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(4); - expect(rows.find((it) => it.id_col === inserted[0].idCol).model1_id).to.equal( + expect(rows).toHaveLength(4); + expect(rows.find((it) => it.id_col === inserted[0].idCol).model1_id).toBe( parent1.id, ); - expect(rows.find((it) => it.id_col === inserted[0].idCol).model2_prop1).to.equal( + expect(rows.find((it) => it.id_col === inserted[0].idCol).model2_prop1).toBe( 'test 1', ); - expect(rows.find((it) => it.id_col === inserted[1].idCol).model1_id).to.equal( + expect(rows.find((it) => it.id_col === inserted[1].idCol).model1_id).toBe( parent1.id, ); - expect(rows.find((it) => it.id_col === inserted[1].idCol).model2_prop1).to.equal( + expect(rows.find((it) => it.id_col === inserted[1].idCol).model2_prop1).toBe( 'test 2', ); }); @@ -956,7 +996,7 @@ module.exports = (session) => { return parent1 .$relatedQuery('model1Relation2') .then((models) => { - expect(models).to.have.length(1); + expect(models).toHaveLength(1); return parent1 .$relatedQuery('model1Relation2') @@ -964,33 +1004,33 @@ module.exports = (session) => { }) .then(($inserted) => { inserted = $inserted; - expect(inserted[0].$beforeInsertCalled).to.equal(1); - expect(inserted[0].$afterInsertCalled).to.equal(1); - expect(inserted[1].$beforeInsertCalled).to.equal(1); - expect(inserted[1].$afterInsertCalled).to.equal(1); - expect(inserted[0].idCol).to.equal(3); - expect(inserted[1].idCol).to.equal(4); - expect(inserted[0]).to.be.a(Model2); - expect(inserted[1]).to.be.a(Model2); - expect(inserted[0].model2Prop1).to.equal('test 1'); - expect(inserted[1].model2Prop1).to.equal('test 2'); - expect(inserted[0].model1Id).to.equal(parent1.id); - expect(inserted[1].model1Id).to.equal(parent1.id); - expect(parent1.model1Relation2).to.eql(undefined); + expect(inserted[0].$beforeInsertCalled).toBe(1); + expect(inserted[0].$afterInsertCalled).toBe(1); + expect(inserted[1].$beforeInsertCalled).toBe(1); + expect(inserted[1].$afterInsertCalled).toBe(1); + expect(inserted[0].idCol).toBe(3); + expect(inserted[1].idCol).toBe(4); + expect(inserted[0]).toBeInstanceOf(Model2); + expect(inserted[1]).toBeInstanceOf(Model2); + expect(inserted[0].model2Prop1).toBe('test 1'); + expect(inserted[1].model2Prop1).toBe('test 2'); + expect(inserted[0].model1Id).toBe(parent1.id); + expect(inserted[1].model1Id).toBe(parent1.id); + expect(parent1.model1Relation2).toBeUndefined(); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(4); - expect(rows.find((it) => it.id_col === inserted[0].idCol).model1_id).to.equal( + expect(rows).toHaveLength(4); + expect(rows.find((it) => it.id_col === inserted[0].idCol).model1_id).toBe( parent1.id, ); - expect(rows.find((it) => it.id_col === inserted[0].idCol).model2_prop1).to.equal( + expect(rows.find((it) => it.id_col === inserted[0].idCol).model2_prop1).toBe( 'test 1', ); - expect(rows.find((it) => it.id_col === inserted[1].idCol).model1_id).to.equal( + expect(rows.find((it) => it.id_col === inserted[1].idCol).model1_id).toBe( parent1.id, ); - expect(rows.find((it) => it.id_col === inserted[1].idCol).model2_prop1).to.equal( + expect(rows.find((it) => it.id_col === inserted[1].idCol).model2_prop1).toBe( 'test 2', ); }); @@ -1054,31 +1094,31 @@ module.exports = (session) => { return parent1 .$relatedQuery('model2Relation1') .then((models) => { - expect(models).to.have.length(1); + expect(models).toHaveLength(1); return parent1 .$relatedQuery('model2Relation1') .insert(Model1.fromJson({ model1Prop1: 'test' })); }) .then(($inserted) => { inserted = $inserted; - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.equal(5); - expect(inserted).to.be.a(Model1); - expect(inserted.model1Prop1).to.equal('test'); - expect(parent1.model2Relation1).to.eql(undefined); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(5); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.model1Prop1).toBe('test'); + expect(parent1.model2Relation1).toBeUndefined(); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(5); - expect(rows.find((it) => it.id === inserted.id).model1Prop1).to.equal('test'); + expect(rows).toHaveLength(5); + expect(rows.find((it) => it.id === inserted.id).model1Prop1).toBe('test'); return session.knex('Model1Model2'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expect( rows.filter((it) => it.model1Id === inserted.id && it.model2Id === parent1.idCol), - ).to.have.length(1); + ).toHaveLength(1); }); }); @@ -1090,12 +1130,12 @@ module.exports = (session) => { .onConflict('id') .ignore(); - expect(inserted.id).to.equal(4); - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); + expect(inserted.id).toBe(4); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); const joinTableRows = await session.knex('Model1Model2'); - chai.expect(joinTableRows).to.containSubset([{ model1Id: 4, model2Id: parent1.idCol }]); + expect(joinTableRows).toContainSubset([{ model1Id: 4, model2Id: parent1.idCol }]); }); } @@ -1105,41 +1145,42 @@ module.exports = (session) => { return parent1 .$relatedQuery('model2Relation1') .then((models) => { - expect(models).to.have.length(1); + expect(models).toHaveLength(1); return parent1.$relatedQuery('model2Relation1').insert({ model1Prop1: 'test' }); }) .then(($inserted) => { inserted = $inserted; - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.equal(5); - expect(inserted).to.be.a(Model1); - expect(inserted.model1Prop1).to.equal('test'); - expect(parent1.model2Relation1).to.eql(undefined); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(5); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.model1Prop1).toBe('test'); + expect(parent1.model2Relation1).toBeUndefined(); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(5); - expect(rows.find((it) => it.id === inserted.id).model1Prop1).to.equal('test'); + expect(rows).toHaveLength(5); + expect(rows.find((it) => it.id === inserted.id).model1Prop1).toBe('test'); return session.knex('Model1Model2'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expect( rows.filter((it) => it.model1Id === inserted.id && it.model2Id === parent1.idCol), - ).to.have.length(1); + ).toHaveLength(1); }); }); - if (session.isPostgres()) { + // MySQL doesn't return the ids of several inserted rows. + if (!session.isMySql()) { it('should accept an array', () => { let inserted = null; return parent1 .$relatedQuery('model2Relation1') .then((models) => { - expect(models).to.have.length(1); + expect(models).toHaveLength(1); return parent1 .$relatedQuery('model2Relation1') @@ -1150,33 +1191,33 @@ module.exports = (session) => { }) .then(($inserted) => { inserted = $inserted; - expect(inserted[0].id).to.equal(5); - expect(inserted[1].id).to.equal(6); - expect(inserted[0]).to.be.a(Model1); - expect(inserted[1]).to.be.a(Model1); - expect(inserted[0].model1Prop1).to.equal('test 1'); - expect(inserted[1].model1Prop1).to.equal('test 2'); - expect(parent1.model2Relation1).to.eql(undefined); + expect(inserted[0].id).toBe(5); + expect(inserted[1].id).toBe(6); + expect(inserted[0]).toBeInstanceOf(Model1); + expect(inserted[1]).toBeInstanceOf(Model1); + expect(inserted[0].model1Prop1).toBe('test 1'); + expect(inserted[1].model1Prop1).toBe('test 2'); + expect(parent1.model2Relation1).toBeUndefined(); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(6); - expect(rows.find((it) => it.id === inserted[0].id).model1Prop1).to.equal('test 1'); - expect(rows.find((it) => it.id === inserted[1].id).model1Prop1).to.equal('test 2'); + expect(rows).toHaveLength(6); + expect(rows.find((it) => it.id === inserted[0].id).model1Prop1).toBe('test 1'); + expect(rows.find((it) => it.id === inserted[1].id).model1Prop1).toBe('test 2'); return session.knex('Model1Model2'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); expect( rows.filter( (it) => it.model1Id === inserted[0].id && it.model2Id === parent1.idCol, ), - ).to.have.length(1); + ).toHaveLength(1); expect( rows.filter( (it) => it.model1Id === inserted[1].id && it.model2Id === parent1.idCol, ), - ).to.have.length(1); + ).toHaveLength(1); }); }); @@ -1186,7 +1227,7 @@ module.exports = (session) => { return parent1 .$relatedQuery('model2Relation1') .then((models) => { - expect(models).to.have.length(1); + expect(models).toHaveLength(1); return parent1 .$relatedQuery('model2Relation1') @@ -1194,37 +1235,37 @@ module.exports = (session) => { }) .then(($inserted) => { inserted = $inserted; - expect(inserted[0].$beforeInsertCalled).to.equal(1); - expect(inserted[0].$afterInsertCalled).to.equal(1); - expect(inserted[1].$beforeInsertCalled).to.equal(1); - expect(inserted[1].$afterInsertCalled).to.equal(1); - expect(inserted[0].id).to.equal(5); - expect(inserted[1].id).to.equal(6); - expect(inserted[0]).to.be.a(Model1); - expect(inserted[1]).to.be.a(Model1); - expect(inserted[0].model1Prop1).to.equal('test 1'); - expect(inserted[1].model1Prop1).to.equal('test 2'); - expect(parent1.model2Relation1).to.eql(undefined); + expect(inserted[0].$beforeInsertCalled).toBe(1); + expect(inserted[0].$afterInsertCalled).toBe(1); + expect(inserted[1].$beforeInsertCalled).toBe(1); + expect(inserted[1].$afterInsertCalled).toBe(1); + expect(inserted[0].id).toBe(5); + expect(inserted[1].id).toBe(6); + expect(inserted[0]).toBeInstanceOf(Model1); + expect(inserted[1]).toBeInstanceOf(Model1); + expect(inserted[0].model1Prop1).toBe('test 1'); + expect(inserted[1].model1Prop1).toBe('test 2'); + expect(parent1.model2Relation1).toBeUndefined(); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(6); - expect(rows.find((it) => it.id === inserted[0].id).model1Prop1).to.equal('test 1'); - expect(rows.find((it) => it.id === inserted[1].id).model1Prop1).to.equal('test 2'); + expect(rows).toHaveLength(6); + expect(rows.find((it) => it.id === inserted[0].id).model1Prop1).toBe('test 1'); + expect(rows.find((it) => it.id === inserted[1].id).model1Prop1).toBe('test 2'); return session.knex('Model1Model2'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); expect( rows.filter( (it) => it.model1Id === inserted[0].id && it.model2Id === parent1.idCol, ), - ).to.have.length(1); + ).toHaveLength(1); expect( rows.filter( (it) => it.model1Id === inserted[1].id && it.model2Id === parent1.idCol, ), - ).to.have.length(1); + ).toHaveLength(1); }); }); } @@ -1235,26 +1276,26 @@ module.exports = (session) => { return parent1 .$relatedQuery('model2Relation1') .then((models) => { - expect(models).to.have.length(1); + expect(models).toHaveLength(1); return parent1 .$relatedQuery('model2Relation1') .insert(Model1.fromJson({ model1Prop1: 'test', aliasedExtra: 'foo' })); }) .then(($inserted) => { inserted = $inserted; - expect(inserted.id).to.equal(5); - expect(inserted.model1Prop1).to.equal('test'); - expect(inserted.aliasedExtra).to.equal('foo'); - expect(parent1.model2Relation1).to.eql(undefined); + expect(inserted.id).toBe(5); + expect(inserted.model1Prop1).toBe('test'); + expect(inserted.aliasedExtra).toBe('foo'); + expect(parent1.model2Relation1).toBeUndefined(); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(5); - expect(rows.find((it) => it.id === inserted.id).model1Prop1).to.equal('test'); + expect(rows).toHaveLength(5); + expect(rows.find((it) => it.id === inserted.id).model1Prop1).toBe('test'); return session.knex('Model1Model2'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expect( rows.filter( (it) => @@ -1262,7 +1303,7 @@ module.exports = (session) => { it.model2Id === parent1.idCol && it.extra3 === inserted.aliasedExtra, ), - ).to.have.length(1); + ).toHaveLength(1); }); }); }); @@ -1321,31 +1362,31 @@ module.exports = (session) => { return parent .$relatedQuery('model2Relation2') .then((models) => { - expect(models).to.equal(undefined); + expect(models).toBeUndefined(); return parent.$relatedQuery('model2Relation2').insert({ model1Prop1: 'test' }); }) .then(($inserted) => { inserted = $inserted; - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.equal(5); - expect(inserted).to.be.a(Model1); - expect(inserted.model1Prop1).to.equal('test'); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(5); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.model1Prop1).toBe('test'); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(5); - expect(rows.find((it) => it.id === inserted.id).model1Prop1).to.equal('test'); + expect(rows).toHaveLength(5); + expect(rows.find((it) => it.id === inserted.id).model1Prop1).toBe('test'); return session.knex('Model1Model2One'); }) .then((rows) => { - expect(rows).to.have.length(1); + expect(rows).toHaveLength(1); expect( rows.filter((it) => it.model1Id === inserted.id && it.model2Id === parent.idCol), - ).to.have.length(1); + ).toHaveLength(1); }); }); }); @@ -1374,7 +1415,7 @@ module.exports = (session) => { .for(1) .first() .then((model) => { - expect(model).to.eql(undefined); + expect(model).toBeUndefined(); return Model1.relatedQuery('model1Relation1') .for(1) @@ -1383,16 +1424,16 @@ module.exports = (session) => { }) .then(($inserted) => { inserted = $inserted; - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.equal(3); - expect(inserted).to.be.a(Model1); - expect(inserted.model1Prop1).to.equal('inserted'); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(3); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.model1Prop1).toBe('inserted'); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(3); + expect(rows).toContainSubset([ { id: 1, model1Prop1: 'hello 1', @@ -1417,12 +1458,12 @@ module.exports = (session) => { .for([1, 2]) .insert({ model1Prop1: 'inserted' }) .then((inserted) => { - expect(inserted.id).to.not.be(undefined); + expect(inserted.id).not.toBeUndefined(); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(3); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(3); + expect(rows).toContainSubset([ { id: 1, model1Prop1: 'hello 1', @@ -1447,12 +1488,12 @@ module.exports = (session) => { .for(Model1.query().findByIds([1, 2])) .insert({ model1Prop1: 'inserted' }) .then((inserted) => { - expect(inserted.id).to.not.be(undefined); + expect(inserted.id).not.toBeUndefined(); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(3); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(3); + expect(rows).toContainSubset([ { id: 1, model1Prop1: 'hello 1', @@ -1506,17 +1547,17 @@ module.exports = (session) => { .for(1) .insert({ model2Prop1: 'inserted' }) .then((inserted) => { - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.idCol).to.equal(3); - expect(inserted).to.be.a(Model2); - expect(inserted.model2Prop1).to.equal('inserted'); - expect(inserted.model1Id).to.equal(1); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.idCol).toBe(3); + expect(inserted).toBeInstanceOf(Model2); + expect(inserted.model2Prop1).toBe('inserted'); + expect(inserted.model1Id).toBe(1); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(3); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(3); + expect(rows).toContainSubset([ { id_col: 1, model2_prop1: 'text 1', model2_prop2: 6, model1_id: 1 }, { id_col: 2, model2_prop1: 'text 2', model2_prop2: 3, model1_id: 2 }, { id_col: 3, model2_prop1: 'inserted', model2_prop2: null, model1_id: 1 }, @@ -1532,8 +1573,8 @@ module.exports = (session) => { return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(3); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(3); + expect(rows).toContainSubset([ { id_col: 1, model2_prop1: 'text 1', model2_prop2: 6, model1_id: 1 }, { id_col: 2, model2_prop1: 'text 2', model2_prop2: 3, model1_id: 2 }, { id_col: 3, model2_prop1: 'inserted', model2_prop2: null, model1_id: 1 }, @@ -1541,45 +1582,44 @@ module.exports = (session) => { }); }); - it('should fail if multiple parents are given', (done) => { - Model1.relatedQuery('model1Relation2') + it('should fail if multiple parents are given', () => { + return Model1.relatedQuery('model1Relation2') .for([1, 2]) .insert({ model2Prop1: 'inserted' }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( "Can only insert items for one parent at a time in case of HasManyRelation. Otherwise multiple insert queries would need to be created. If you need to insert items for multiple parents, simply loop through them. That's the most performant way.", ); - done(); - }) - .catch(done); + }); }); - if (session.isPostgres()) { + // MySQL doesn't return the ids of several inserted rows. + if (!session.isMySql()) { it('should accept an array', () => { return Model1.relatedQuery('model1Relation2') .for(1) .insert([{ model2Prop1: 'inserted 1' }, { model2Prop1: 'inserted 2' }]) .then((inserted) => { - expect(inserted[0].$beforeInsertCalled).to.equal(1); - expect(inserted[0].$afterInsertCalled).to.equal(1); - expect(inserted[1].$beforeInsertCalled).to.equal(1); - expect(inserted[1].$afterInsertCalled).to.equal(1); - expect(inserted[0].idCol).to.equal(3); - expect(inserted[1].idCol).to.equal(4); - expect(inserted[0]).to.be.a(Model2); - expect(inserted[1]).to.be.a(Model2); - expect(inserted[0].model2Prop1).to.equal('inserted 1'); - expect(inserted[1].model2Prop1).to.equal('inserted 2'); - expect(inserted[0].model1Id).to.equal(1); - expect(inserted[1].model1Id).to.equal(1); + expect(inserted[0].$beforeInsertCalled).toBe(1); + expect(inserted[0].$afterInsertCalled).toBe(1); + expect(inserted[1].$beforeInsertCalled).toBe(1); + expect(inserted[1].$afterInsertCalled).toBe(1); + expect(inserted[0].idCol).toBe(3); + expect(inserted[1].idCol).toBe(4); + expect(inserted[0]).toBeInstanceOf(Model2); + expect(inserted[1]).toBeInstanceOf(Model2); + expect(inserted[0].model2Prop1).toBe('inserted 1'); + expect(inserted[1].model2Prop1).toBe('inserted 2'); + expect(inserted[0].model1Id).toBe(1); + expect(inserted[1].model1Id).toBe(1); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(4); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(4); + expect(rows).toContainSubset([ { id_col: 1, model2_prop1: 'text 1', model2_prop2: 6, model1_id: 1 }, { id_col: 2, model2_prop1: 'text 2', model2_prop2: 3, model1_id: 2 }, { id_col: 3, model2_prop1: 'inserted 1', model2_prop2: null, model1_id: 1 }, @@ -1635,21 +1675,21 @@ module.exports = (session) => { .for(1) .insert({ model1Prop1: 'test' }) .then((inserted) => { - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.equal(5); - expect(inserted).to.be.a(Model1); - expect(inserted.model1Prop1).to.equal('test'); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(5); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.model1Prop1).toBe('test'); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(5); - expect(rows.find((it) => it.id === 5).model1Prop1).to.equal('test'); + expect(rows).toHaveLength(5); + expect(rows.find((it) => it.id === 5).model1Prop1).toBe('test'); return session.knex('Model1Model2'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows.filter((it) => it.model1Id === 5 && it.model2Id === 1)).to.have.length(1); + expect(rows).toHaveLength(3); + expect(rows.filter((it) => it.model1Id === 5 && it.model2Id === 1)).toHaveLength(1); }); }); @@ -1658,19 +1698,19 @@ module.exports = (session) => { .for(Model2.query().findById(1)) .insert({ model1Prop1: 'test' }) .then((inserted) => { - expect(inserted.id).to.equal(5); - expect(inserted).to.be.a(Model1); - expect(inserted.model1Prop1).to.equal('test'); + expect(inserted.id).toBe(5); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.model1Prop1).toBe('test'); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(5); - expect(rows.find((it) => it.id === 5).model1Prop1).to.equal('test'); + expect(rows).toHaveLength(5); + expect(rows.find((it) => it.id === 5).model1Prop1).toBe('test'); return session.knex('Model1Model2'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows.filter((it) => it.model1Id === 5 && it.model2Id === 1)).to.have.length(1); + expect(rows).toHaveLength(3); + expect(rows.filter((it) => it.model1Id === 5 && it.model2Id === 1)).toHaveLength(1); }); }); @@ -1680,26 +1720,22 @@ module.exports = (session) => { .for([1, 2]) .insert({ model1Prop1: 'test' }) .then((inserted) => { - expect(inserted.$beforeInsertCalled).to.equal(1); - expect(inserted.$afterInsertCalled).to.equal(1); - expect(inserted.id).to.equal(5); - expect(inserted).to.be.a(Model1); - expect(inserted.model1Prop1).to.equal('test'); + expect(inserted.$beforeInsertCalled).toBe(1); + expect(inserted.$afterInsertCalled).toBe(1); + expect(inserted.id).toBe(5); + expect(inserted).toBeInstanceOf(Model1); + expect(inserted.model1Prop1).toBe('test'); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(5); - expect(rows.find((it) => it.id === 5).model1Prop1).to.equal('test'); + expect(rows).toHaveLength(5); + expect(rows.find((it) => it.id === 5).model1Prop1).toBe('test'); return session.knex('Model1Model2'); }) .then((rows) => { - expect(rows).to.have.length(4); - expect(rows.filter((it) => it.model1Id === 5 && it.model2Id === 1)).to.have.length( - 1, - ); - expect(rows.filter((it) => it.model1Id === 5 && it.model2Id === 2)).to.have.length( - 1, - ); + expect(rows).toHaveLength(4); + expect(rows.filter((it) => it.model1Id === 5 && it.model2Id === 1)).toHaveLength(1); + expect(rows.filter((it) => it.model1Id === 5 && it.model2Id === 2)).toHaveLength(1); }); }); @@ -1708,28 +1744,24 @@ module.exports = (session) => { .for(1) .insert([{ model1Prop1: 'test 1' }, { model1Prop1: 'test 2' }]) .then((inserted) => { - expect(inserted[0].id).to.equal(5); - expect(inserted[1].id).to.equal(6); - expect(inserted[0]).to.be.a(Model1); - expect(inserted[1]).to.be.a(Model1); - expect(inserted[0].model1Prop1).to.equal('test 1'); - expect(inserted[1].model1Prop1).to.equal('test 2'); + expect(inserted[0].id).toBe(5); + expect(inserted[1].id).toBe(6); + expect(inserted[0]).toBeInstanceOf(Model1); + expect(inserted[1]).toBeInstanceOf(Model1); + expect(inserted[0].model1Prop1).toBe('test 1'); + expect(inserted[1].model1Prop1).toBe('test 2'); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(6); - expect(rows.find((it) => it.id === 5).model1Prop1).to.equal('test 1'); - expect(rows.find((it) => it.id === 6).model1Prop1).to.equal('test 2'); + expect(rows).toHaveLength(6); + expect(rows.find((it) => it.id === 5).model1Prop1).toBe('test 1'); + expect(rows.find((it) => it.id === 6).model1Prop1).toBe('test 2'); return session.knex('Model1Model2'); }) .then((rows) => { - expect(rows).to.have.length(4); - expect(rows.filter((it) => it.model1Id === 5 && it.model2Id === 1)).to.have.length( - 1, - ); - expect(rows.filter((it) => it.model1Id === 6 && it.model2Id === 1)).to.have.length( - 1, - ); + expect(rows).toHaveLength(4); + expect(rows.filter((it) => it.model1Id === 5 && it.model2Id === 1)).toHaveLength(1); + expect(rows.filter((it) => it.model1Id === 6 && it.model2Id === 1)).toHaveLength(1); }); }); } @@ -1739,32 +1771,29 @@ module.exports = (session) => { .for(1) .insert(Model1.fromJson({ model1Prop1: 'test', aliasedExtra: 'foo' })) .then((inserted) => { - expect(inserted.id).to.equal(5); - expect(inserted.model1Prop1).to.equal('test'); - expect(inserted.aliasedExtra).to.equal('foo'); + expect(inserted.id).toBe(5); + expect(inserted.model1Prop1).toBe('test'); + expect(inserted.aliasedExtra).toBe('foo'); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(5); - expect(rows.find((it) => it.id === 5).model1Prop1).to.equal('test'); + expect(rows).toHaveLength(5); + expect(rows.find((it) => it.id === 5).model1Prop1).toBe('test'); return session.knex('Model1Model2'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expect( rows.filter((it) => it.model1Id === 5 && it.model2Id === 1 && it.extra3 === 'foo'), - ).to.have.length(1); + ).toHaveLength(1); }); }); }); }); - describe('.query().insert().onConflict()', () => { - before(function () { + describe.skipIf(session.isMsSql())('.query().insert().onConflict()', () => { + beforeAll(() => { // knex doesn't support `onConflict()` on SQL Server. - if (session.isMsSql()) { - this.skip(); - } }); beforeEach(() => { @@ -1775,12 +1804,12 @@ module.exports = (session) => { it('should silently ignore insert if id already exists', async () => { await Model1.query().insert({ id: 1 }); const result = await Model1.query().insert({ id: 1 }).onConflict('id').ignore(); - expect(result instanceof Model1).to.equal(true); + expect(result instanceof Model1).toBe(true); const rows = await Model1.query(); - expect(rows).to.have.length(1); - expect(rows[0].id).to.equal(1); - expect(result.id).to.equal(rows[0].id); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(1); + expect(result.id).toBe(rows[0].id); }); }); @@ -1791,14 +1820,14 @@ module.exports = (session) => { .insert({ idCol: 1, model2Prop1: 'updated' }) .onConflict('id_col') .merge(); - expect(result instanceof Model2).to.equal(true); + expect(result instanceof Model2).toBe(true); const rows = await Model2.query(); - expect(rows).to.have.length(1); - expect(rows[0].idCol).to.equal(1); - expect(rows[0].model2Prop1).to.equal('updated'); - expect(result.id).to.equal(rows[0].id); - expect(result.model2Prop1).to.equal(rows[0].model2Prop1); + expect(rows).toHaveLength(1); + expect(rows[0].idCol).toBe(1); + expect(rows[0].model2Prop1).toBe('updated'); + expect(result.id).toBe(rows[0].id); + expect(result.model2Prop1).toBe(rows[0].model2Prop1); }); it('should update some columns of the row if id already exists', async () => { @@ -1807,15 +1836,15 @@ module.exports = (session) => { .insert({ idCol: 1, model2Prop1: 'updated', model2Prop2: 123456 }) .onConflict('id_col') .merge(['model2_prop1']); - expect(result instanceof Model2).to.equal(true); + expect(result instanceof Model2).toBe(true); const rows = await Model2.query(); - expect(rows).to.have.length(1); - expect(rows[0].idCol).to.equal(1); - expect(rows[0].model2Prop1).to.equal('updated'); - expect(rows[0].model2Prop2).to.equal(null); - expect(result.id).to.equal(rows[0].id); - expect(result.model2Prop1).to.equal(rows[0].model2Prop1); + expect(rows).toHaveLength(1); + expect(rows[0].idCol).toBe(1); + expect(rows[0].model2Prop1).toBe('updated'); + expect(rows[0].model2Prop2).toBeNull(); + expect(result.id).toBe(rows[0].id); + expect(result.model2Prop1).toBe(rows[0].model2Prop1); }); if (session.isPostgres()) { @@ -1826,19 +1855,137 @@ module.exports = (session) => { .onConflict('id_col') .merge({ model2Prop1: 'override updated' }) .returning('id_col', 'model2_prop1'); - expect(result instanceof Model2).to.equal(true); + expect(result instanceof Model2).toBe(true); const rows = await Model2.query(); - expect(rows).to.have.length(1); - expect(rows[0].idCol).to.equal(1); - expect(rows[0].model2Prop1).to.equal('override updated'); - expect(result.id).to.equal(rows[0].id); - expect(result.model2Prop1).to.equal(rows[0].model2Prop1); + expect(rows).toHaveLength(1); + expect(rows[0].idCol).toBe(1); + expect(rows[0].model2Prop1).toBe('override updated'); + expect(result.id).toBe(rows[0].id); + expect(result.model2Prop1).toBe(rows[0].model2Prop1); }); } }); }); + // MySQL doesn't return the ids of all rows inserted by one query. + describe.skipIf(session.isMySql())('.query().insert() of many models', () => { + class Item extends Model { + static get tableName() { + return 'insert_batch_item'; + } + } + + beforeAll(async () => { + await session.knex.schema.dropTableIfExists('insert_batch_item'); + await session.knex.schema.createTable('insert_batch_item', (table) => { + table.increments('id').primary(); + table.string('name'); + table.integer('stars').notNullable().defaultTo(5); + table.integer('level').notNullable().defaultTo(1); + }); + Item.knex(session.knex); + }); + + beforeEach(() => Item.query().delete()); + + afterAll(() => session.knex.schema.dropTableIfExists('insert_batch_item')); + + it('should write the defaults of columns that some models leave out', async () => { + const inserted = await Item.query().insert([ + { name: 'a', stars: 1 }, + { name: 'b', level: 3 }, + { name: 'c' }, + { name: 'd', stars: 2 }, + ]); + + expect(inserted.map((it) => it.name)).toEqual(['a', 'b', 'c', 'd']); + expect(new Set(inserted.map((it) => it.id)).size).toBe(4); + + const rows = await Item.query().orderBy('name'); + expect(rows.map((it) => [it.name, it.stars, it.level])).toEqual([ + ['a', 1, 1], + ['b', 5, 3], + ['c', 5, 1], + ['d', 2, 1], + ]); + + // The ids belong to the models, whatever the order of the queries: + for (const model of inserted) { + expect((await Item.query().findById(model.id)).name).toBe(model.name); + } + }); + + it('should insert more rows than fit into one query', async () => { + const names = Array.from({ length: 1200 }, (_, index) => `item ${index}`); + const inserted = await Item.query().insert(names.map((name) => ({ name }))); + + expect(inserted.map((it) => it.name)).toEqual(names); + expect(new Set(inserted.map((it) => it.id)).size).toBe(1200); + expect(await Item.query().resultSize()).toBe(1200); + + for (const model of [inserted[0], inserted[600], inserted[1199]]) { + expect((await Item.query().findById(model.id)).name).toBe(model.name); + } + }); + + it('should insert many columns of many rows', async () => { + const inserted = await Item.query().insert( + Array.from({ length: 700 }, (_, index) => ({ + name: `item ${index}`, + stars: 1, + level: 2, + })), + ); + + expect(inserted).toHaveLength(700); + expect(await Item.query().where({ stars: 1, level: 2 }).resultSize()).toBe(700); + }); + + async function countInsertQueries(insert) { + let count = 0; + const listener = (query) => /^insert/i.test(query.sql) && count++; + session.knex.on('query', listener); + try { + await insert(); + } finally { + session.knex.removeListener('query', listener); + } + return count; + } + + it('should insert models that all have no columns one by one', async () => { + let inserted; + const count = await countInsertQueries(async () => { + inserted = await Item.query().insert([{}, {}, {}]); + }); + + expect(count).toBe(3); + expect(new Set(inserted.map((it) => it.id)).size).toBe(3); + expect(await Item.query().resultSize()).toBe(3); + }); + + // SQLite inserts the models without columns separately, as it groups the + // models by their columns. + it.skipIf(session.isSqlite())( + 'should insert models without columns with the others in one query', + async () => { + let inserted; + const count = await countInsertQueries(async () => { + inserted = await Item.query().insert([{}, { name: 'a' }, {}]); + }); + + expect(count).toBe(1); + expect(inserted.map((it) => it.id)).toEqual( + [...inserted.map((it) => it.id)].sort((a, b) => a - b), + ); + const rows = await Item.query().orderBy('id'); + expect(rows.map((it) => it.name)).toEqual([null, 'a', null]); + expect(rows.map((it) => it.id)).toEqual(inserted.map((it) => it.id)); + }, + ); + }); + function subClassWithSchema(Model, schema) { let SubModel = inheritModel(Model); SubModel.jsonSchema = schema; diff --git a/tests/integration/insertGraph.js b/tests/integration/insertGraph.js index 66fafd683..d02e75b47 100644 --- a/tests/integration/insertGraph.js +++ b/tests/integration/insertGraph.js @@ -1,11 +1,9 @@ -const chai = require('chai'); -const utils = require('../../lib/utils/knexUtils'); -const expect = require('expect.js'); -const { transaction, ValidationError, Model } = require('../../'); -const { resetWarnings } = require('../../lib/utils/warnings'); -const { cloneDeep, sortBy } = require('../../testUtils/testUtils'); - -module.exports = (session) => { +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import * as utils from '../../lib/utils/knexUtils.js'; +import { transaction, ValidationError, Model } from 'objection'; +import { cloneDeep, delay, range, sortBy } from '../../testUtils/testUtils.js'; + +export default (session) => { let Model1 = session.models.Model1; let Model2 = session.models.Model2; @@ -84,8 +82,8 @@ module.exports = (session) => { return Model1.query().insertGraph([]); }); - it('should throw if #ref is used without the `allowRefs` option', (done) => { - Model1.query() + it('should throw if #ref is used without the `allowRefs` option', () => { + return Model1.query() .insertGraph({ '#id': 'id1', @@ -96,18 +94,17 @@ module.exports = (session) => { ], }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( '#ref references are not allowed in a graph by default. see the allowRefs insert/upsert graph option', ); - done(); }); }); - it('should throw if #ref{} is used without the `allowRefs` option', (done) => { - Model1.query() + it('should throw if #ref{} is used without the `allowRefs` option', () => { + return Model1.query() .insertGraph({ '#id': 'id1', @@ -118,13 +115,12 @@ module.exports = (session) => { ], }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( '#ref references are not allowed in a graph by default. see the allowRefs insert/upsert graph option', ); - done(); }); }); @@ -135,7 +131,7 @@ module.exports = (session) => { return check(inserted, true).then(() => inserted); }) .then((inserted) => { - expect(inserted).to.not.have.property('model1Prop2'); + expect(inserted).not.toHaveProperty('model1Prop2'); return Model1.query().withGraphFetched(eagerExpr).where('id', inserted.id).first(); }) .then((model) => { @@ -147,10 +143,10 @@ module.exports = (session) => { const inserted = await Model1.query().insertGraph(insertion, { allowRefs: true }); const [ref, , dbRef] = inserted.model1Relation1.model1Relation3; - expect(ref).to.not.have.property('#ref'); - expect(ref.model2Prop1).to.equal('child1'); - expect(dbRef).to.not.have.property('#dbRef'); - expect(dbRef.idCol).to.equal(1); + expect(ref).not.toHaveProperty('#ref'); + expect(ref.model2Prop1).toBe('child1'); + expect(dbRef).not.toHaveProperty('#dbRef'); + expect(dbRef.idCol).toBe(1); }); it('should not return the reference markers of skipped models (#221)', async () => { @@ -162,7 +158,7 @@ module.exports = (session) => { { allowRefs: true, noInsert: ['model1Relation1'] }, ); - expect(inserted.model1Relation1.model1Relation3[0]).to.not.have.property('#dbRef'); + expect(inserted.model1Relation1.model1Relation3[0]).not.toHaveProperty('#dbRef'); }); it('should not return the reference markers below related models (#221)', async () => { @@ -174,14 +170,14 @@ module.exports = (session) => { { allowRefs: true }, ); - expect(inserted.model1Relation1).to.not.have.property('#dbRef'); - expect(inserted.model1Relation1.model1Relation3[0]).to.not.have.property('#dbRef'); + expect(inserted.model1Relation1).not.toHaveProperty('#dbRef'); + expect(inserted.model1Relation1.model1Relation3[0]).not.toHaveProperty('#dbRef'); }); describe('jsonSchema: additionalProperties = false', () => { let origSchema; - before(() => { + beforeAll(() => { origSchema = Model1.jsonSchema; Model1.jsonSchema = { @@ -197,15 +193,15 @@ module.exports = (session) => { // Clear the memoized schema. delete Model1.$$jsonSchema; - expect(Model1.getJsonSchema()).to.equal(Model1.jsonSchema); + expect(Model1.getJsonSchema()).toBe(Model1.jsonSchema); }); - after(() => { + afterAll(() => { Model1.jsonSchema = origSchema; // Clear the memoized schema. delete Model1.$$jsonSchema; - expect(Model1.getJsonSchema()).to.equal(origSchema); + expect(Model1.getJsonSchema()).toBe(origSchema); }); it('should insert a model with relations', () => { @@ -215,7 +211,7 @@ module.exports = (session) => { return check(inserted, true).then(() => inserted); }) .then((inserted) => { - expect(inserted).to.not.have.property('model1Prop2'); + expect(inserted).not.toHaveProperty('model1Prop2'); return Model1.query().withGraphFetched(eagerExpr).where('id', inserted.id).first(); }) .then((model) => { @@ -254,7 +250,7 @@ module.exports = (session) => { .then((inserted) => { inserted.model1Relation2 = sortBy(inserted.model1Relation2, 'idCol'); - expect(inserted.toJSON()).to.eql({ + expect(inserted.toJSON()).toEqual({ id: 4, model1Relation2: [ { model1Id: 4, idCol: 100 }, @@ -267,7 +263,7 @@ module.exports = (session) => { .then((inserted) => { inserted[0].model1Relation2 = sortBy(inserted[0].model1Relation2, 'idCol'); - expect(inserted[0]).to.eql({ + expect(inserted[0]).toEqual({ id: 4, model1Id: null, model1Prop1: '42', @@ -294,11 +290,12 @@ module.exports = (session) => { }); const testValidation = (modifyGraph, expectedProperty) => { - return (done) => { + return () => { + // `modifyGraph()` can return other input that contains the graph: const graph = cloneDeep(insertion); - modifyGraph(graph); + const input = modifyGraph(graph) ?? graph; - transaction(Model1, Model2, (Model1, Model2) => { + return transaction(Model1, Model2, (Model1, Model2) => { // We can modify Model1 and Model2 here since it is a subclass of the actual // models shared between tests. Model1.jsonSchema = { @@ -324,26 +321,24 @@ module.exports = (session) => { delete Model1.$$jsonSchema; delete Model2.$$jsonSchema; - expect(Model1.getJsonSchema()).to.equal(Model1.jsonSchema); - expect(Model2.getJsonSchema()).to.equal(Model2.jsonSchema); + expect(Model1.getJsonSchema()).toBe(Model1.jsonSchema); + expect(Model2.getJsonSchema()).toBe(Model2.jsonSchema); - return Model1.query().insertGraph(graph, { allowRefs: true }); + return Model1.query().insertGraph(input, { allowRefs: true }); }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(ValidationError); - expect(err.data).to.have.property(expectedProperty); + expect(err).toBeInstanceOf(ValidationError); + expect(err.data).toHaveProperty([expectedProperty]); return Promise.all([session.knex('Model1'), session.knex('model2')]); }) .then(([rows1, rows2]) => { - expect(rows1).to.have.length(1); - expect(rows2).to.have.length(1); - done(); - }) - .catch(done); + expect(rows1).toHaveLength(1); + expect(rows2).toHaveLength(1); + }); }; }; @@ -368,6 +363,50 @@ module.exports = (session) => { }, 'model1Relation1.model1Relation3[1].model2Prop1'), ); + it( + 'should return validation paths with the index of array input', + testValidation((graph) => { + graph.model1Relation2[0].model2Prop1 = 666; + return [{ model1Prop1: 'first' }, graph]; + }, '[1].model1Relation2[0].model2Prop1'), + ); + + it('should roll back after the inserts that already started when a model fails validation', async () => { + let numStarted = 0; + let numInserted = 0; + + class ValidatedModel1 extends Model1 { + static jsonSchema = { + type: 'object', + properties: { + model1Prop1: { type: 'string' }, + }, + }; + + // Keep the valid inserts running when the invalid model fails: + async $beforeInsert() { + numStarted++; + await delay(10); + } + + $afterInsert() { + numInserted++; + } + } + + const graph = [...range(7).map((i) => ({ model1Prop1: `${i}` })), { model1Prop1: 666 }]; + + await expect( + transaction(ValidatedModel1, (ValidatedModel1) => + ValidatedModel1.query().insertGraph(graph), + ), + ).rejects.toThrow(ValidationError); + + // Inserts that run after the rollback can commit their rows on SQLite: + expect(numInserted).toBe(numStarted); + expect(await session.knex('Model1')).toHaveLength(1); + }); + it('should validate models upon insertion: references in integer columns should be accepted', () => { return transaction(Model1, Model2, (Model1, Model2) => { // We can modify Model1 and Model2 here since it is a subclass of the actual @@ -396,8 +435,8 @@ module.exports = (session) => { delete Model1.$$jsonSchema; delete Model2.$$jsonSchema; - expect(Model1.getJsonSchema()).to.equal(Model1.jsonSchema); - expect(Model2.getJsonSchema()).to.equal(Model2.jsonSchema); + expect(Model1.getJsonSchema()).toBe(Model1.jsonSchema); + expect(Model2.getJsonSchema()).toBe(Model2.jsonSchema); return Model1.query() .insertGraph(insertion, { allowRefs: true }) @@ -405,7 +444,7 @@ module.exports = (session) => { return check(inserted, true).then(() => inserted); }) .then((inserted) => { - expect(inserted).to.not.have.property('model1Prop2'); + expect(inserted).not.toHaveProperty('model1Prop2'); return Model1.query().withGraphFetched(eagerExpr).where('id', inserted.id).first(); }) .then((model) => { @@ -414,6 +453,27 @@ module.exports = (session) => { }); }); + it('should resolve #ref{} in a foreign key that the relation also sets (Vincit#1378)', async () => { + const inserted = await Model1.query().insertGraph( + { + '#id': 'root', + model1Prop1: 'root', + model1Relation2: [{ model2Prop1: 'child', model1Id: '#ref{root.id}' }], + }, + { allowRefs: true }, + ); + + const child = await Model2.query().findOne('model2_prop1', 'child'); + expect(child.model1Id).toBe(inserted.id); + }); + + it('should insert several models without properties (Vincit#1694)', async () => { + const inserted = await Model1.query().insertGraph([{}, {}]); + + expect(inserted.map((model) => typeof model.id)).toEqual(['number', 'number']); + expect(await Model1.query().findByIds(inserted.map((model) => model.id))).toHaveLength(2); + }); + it('relate: true option should cause models with id to be related instead of inserted', () => { return Model2.query() .insertGraph( @@ -436,7 +496,7 @@ module.exports = (session) => { .then((model) => { delete model.idCol; - expect(model).to.eql({ + expect(model).toEqual({ model1Id: null, model2Prop1: 'foo', model2Prop2: null, @@ -456,42 +516,70 @@ module.exports = (session) => { }); }); + it('noInsert option should leave out the models of the given relations', async () => { + const model = await Model1.query().insertGraph( + { + model1Prop1: 'root', + model1Relation1: { model1Prop1: 'parent' }, + model1Relation2: [{ model2Prop1: 'child' }], + }, + { noInsert: ['model1Relation2'] }, + ); + + const fetched = await Model1.query() + .findById(model.id) + .withGraphFetched('[model1Relation1, model1Relation2]'); + expect(fetched.model1Relation1.model1Prop1).toBe('parent'); + expect(fetched.model1Relation2).toEqual([]); + expect(await session.knex('model2').where('model2_prop1', 'child')).toEqual([]); + }); + + it('noRelate option should leave out the models that would be related', async () => { + const model = await Model2.query().insertGraph( + { + model2Prop1: 'foo', + model2Relation1: [{ id: population.id }, { model1Prop1: 'new' }], + }, + { relate: true, noRelate: ['model2Relation1'] }, + ); + + const fetched = await Model2.query() + .findById(model.idCol) + .withGraphFetched('model2Relation1'); + expect(fetched.model2Relation1.map((it) => it.model1Prop1)).toEqual(['new']); + }); + for (const prop of ['#unrelate', '#delete']) { - for (const allowMarkers of [false, true]) { - it(`should throw if ${prop} is used, with allowMarkers: ${allowMarkers}`, async () => { - const err = await Model1.query() - .insertGraph( - { - model1Prop1: 'root', - model1Relation2: [{ model2Prop1: 'marked', [prop]: true }], - }, - { allowMarkers }, - ) - .catch((err) => err); - - expect(err).to.be.a(ValidationError); - expect(err.type).to.equal('InvalidGraph'); - expect(err.message).to.equal( - '#unrelate and #delete can only be used in upsertGraph, not in insertGraph', - ); + it(`should throw if ${prop} is used`, async () => { + const err = await Model1.query() + .insertGraph({ + model1Prop1: 'root', + model1Relation2: [{ model2Prop1: 'marked', [prop]: true }], + }) + .catch((err) => err); - expect(await session.knex('Model1').where('model1Prop1', 'root')).to.eql([]); - }); + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('InvalidGraph'); + expect(err.message).toBe( + '#unrelate and #delete can only be used in upsertGraph, not in insertGraph', + ); - it(`should throw if ${prop} is used for a root model, with allowMarkers: ${allowMarkers}`, async () => { - const err = await Model1.query() - .insertGraph({ model1Prop1: 'root', [prop]: true }, { allowMarkers }) - .catch((err) => err); + expect(await session.knex('Model1').where('model1Prop1', 'root')).toEqual([]); + }); - expect(err).to.be.a(ValidationError); - expect(err.type).to.equal('InvalidGraph'); - expect(err.message).to.equal( - '#unrelate and #delete can only be used for related models, not for root models', - ); + it(`should throw if ${prop} is used for a root model`, async () => { + const err = await Model1.query() + .insertGraph({ model1Prop1: 'root', [prop]: true }) + .catch((err) => err); - expect(await session.knex('Model1').where('model1Prop1', 'root')).to.eql([]); - }); - } + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('InvalidGraph'); + expect(err.message).toBe( + '#unrelate and #delete can only be used for related models, not for root models', + ); + + expect(await session.knex('Model1').where('model1Prop1', 'root')).toEqual([]); + }); } it('should not insert the descendants of models skipped by noInsert (#215)', async () => { @@ -521,8 +609,8 @@ module.exports = (session) => { session.knex('model2'), ]); - chai.expect(model1Rows.map((row) => row.model1Prop1)).to.eql(['20', 'root']); - chai.expect(model2Rows.map((row) => row.id_col)).to.eql([1]); + expect(model1Rows.map((row) => row.model1Prop1)).toEqual(['20', 'root']); + expect(model2Rows.map((row) => row.id_col)).toEqual([1]); }); it('should insert the models below models skipped by noInsert that #ref points to (#215)', async () => { @@ -549,11 +637,11 @@ module.exports = (session) => { const result = await Model1.query().findById(model.id).withGraphFetched('model1Relation3'); - expect(result.model1Id).to.equal(null); - expect(result.model1Relation3.map((it) => it.model2Prop1)).to.eql(['referenced']); + expect(result.model1Id).toBe(null); + expect(result.model1Relation3.map((it) => it.model2Prop1)).toEqual(['referenced']); expect( await session.knex('model2').where('model2_prop1', 'hasMany of nested skipped'), - ).to.eql([]); + ).toEqual([]); }); it('should insert the models below models skipped by noInsert that #ref{} points to (#215)', async () => { @@ -573,12 +661,63 @@ module.exports = (session) => { const [row] = await session.knex('model2').where('model2_prop1', 'referenced'); - expect(row.id_col).to.be.a('number'); - expect(model.model1Prop2).to.equal(row.id_col); + expect(row.id_col).toBeTypeOf('number'); + expect(model.model1Prop2).toBe(row.id_col); + }); + + describe('branches below models skipped by noInsert (#369)', () => { + const insertSkipped = (skippedChildren, root = {}) => + Model1.query().insertGraph( + { + model1Prop1: 'root', + ...root, + // Not inserted because of `noInsert`: + model1Relation2: [{ model2Prop1: 'skipped', model2Relation1: skippedChildren }], + }, + { allowRefs: true, noInsert: ['model1Relation2'] }, + ); + + const selectInserted = () => + session.knex('Model1').whereIn('model1Prop1', ['a', 'b']).orderBy('model1Prop1'); + + it('should insert a referenced model without the join row of its skipped parent', async () => { + const model = await insertSkipped([{ '#id': 'a', model1Prop1: 'a' }], { + model1Relation1: { '#ref': 'a' }, + }); + + const [row] = await selectInserted(); + expect(row).toMatchObject({ model1Prop1: 'a' }); + expect(model.model1Id).toBe(row.id); + expect(await session.knex('Model1Model2').where('model1Id', row.id)).toEqual([]); + }); + + it('should not insert the siblings of a referenced model', async () => { + await insertSkipped([{ '#id': 'a', model1Prop1: 'a' }, { model1Prop1: 'b' }], { + model1Relation1: { '#ref': 'a' }, + }); + + expect((await selectInserted()).map((row) => row.model1Prop1)).toEqual(['a']); + }); + + it('should not insert models that only pruned siblings reference', async () => { + for (const skippedChildren of [ + [ + { '#id': 'a', model1Prop1: 'a' }, + { model1Prop1: 'b', model1Relation1: { '#ref': 'a' } }, + ], + [ + { '#id': 'a', model1Prop1: 'a', model1Relation1: { '#ref': 'b' } }, + { '#id': 'b', model1Prop1: 'b', model1Relation1: { '#ref': 'a' } }, + ], + ]) { + await insertSkipped(skippedChildren); + expect(await selectInserted()).toEqual([]); + } + }); }); - it('trying to relate a HasManyRelation should throw', (done) => { - Model1.query() + it('trying to relate a HasManyRelation should throw', () => { + return Model1.query() .insertGraph( { model1Prop1: 'foo', @@ -593,14 +732,14 @@ module.exports = (session) => { relate: true, }, ) - .then(() => done(new Error('should not get here'))) + .then(() => { + throw new Error('should not get here'); + }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( 'You cannot relate HasManyRelation or HasOneRelation using insertGraph, because those require update operations. Consider using upsertGraph instead.', ); - done(); - }) - .catch(done); + }); }); it(`relate: ['relation.path'] option should cause models with id to be related instead of inserted`, () => { @@ -653,7 +792,7 @@ module.exports = (session) => { delete model.model1Relation2[0].idCol; delete model.model1Relation2[0].model1Id; - expect(model).to.eql({ + expect(model).toEqual({ model1Id: 500, model1Prop1: 'hello', model1Prop2: null, @@ -726,10 +865,10 @@ module.exports = (session) => { .withGraphFetched('model1Relation1.model1Relation1'); }) .then((model) => { - expect(model.model1Prop1).to.equal('hello'); - expect(model.model1Relation1.model1Prop1).to.equal('parent'); - expect(model.model1Relation1.model1Relation1.id).to.equal(population.id); - expect(model.model1Relation1.model1Relation1.model1Prop1).to.equal( + expect(model.model1Prop1).toBe('hello'); + expect(model.model1Relation1.model1Prop1).toBe('parent'); + expect(model.model1Relation1.model1Relation1.id).toBe(population.id); + expect(model.model1Relation1.model1Relation1.model1Prop1).toBe( population.model1Prop1, ); }); @@ -745,7 +884,7 @@ module.exports = (session) => { return check(inserted, true).then(() => inserted); }) .then((inserted) => { - expect(inserted).to.have.property('model1Prop2'); + expect(inserted).toHaveProperty('model1Prop2'); return Model1.query().withGraphFetched(eagerExpr).where('id', inserted.id).first(); }) .then((model) => { @@ -775,8 +914,8 @@ module.exports = (session) => { .withGraphFetched(eagerExpr) .findById(inserted.id) .then((fetched) => { - chai.expect(inserted.$toJson()).to.containSubset(fetched.$toJson()); - chai.expect(fetched.$toJson()).to.containSubset(inserted.$toJson()); + expect(inserted.$toJson()).toContainSubset(fetched.$toJson()); + expect(fetched.$toJson()).toContainSubset(inserted.$toJson()); }); }); }); @@ -803,7 +942,7 @@ module.exports = (session) => { .then((model) => { delete model.idCol; - expect(model).to.eql({ + expect(model).toEqual({ model1Id: null, model2Prop1: 'foo', model2Prop2: null, @@ -825,22 +964,10 @@ module.exports = (session) => { }); describe('.query().insertGraph().onConflict() (#2156)', () => { - let warnings; - let originalWarn; - beforeEach(() => { - resetWarnings(); - warnings = []; - originalWarn = console.warn; - console.warn = (message) => warnings.push(message); return session.populate(population); }); - afterEach(() => { - console.warn = originalWarn; - resetWarnings(); - }); - const graph = () => ({ model1Prop1: 'new root', model1Relation2: [{ model2Prop1: 'new child' }], @@ -858,69 +985,74 @@ module.exports = (session) => { }; for (const [title, createQuery] of Object.entries(cases)) { - it(`${title} should warn and insert the graph as without onConflict()`, async () => { + it(`${title} should throw and insert nothing`, async () => { const method = title.includes('upsertGraph') ? 'upsertGraph' : 'insertGraph'; - const inserted = await createQuery(); - - expect(warnings).to.eql([ - `[objection] onConflict(), ignore() and merge() are not supported by ${method}(). ` + - 'Insert the conflicting rows with a separate insert() query instead. ' + - 'This will throw in objection 4.0.', - ]); - const fetched = await Model1.query() - .findById(inserted.id) - .withGraphFetched('model1Relation2'); + await expect(createQuery()).rejects.toThrow( + `onConflict(), ignore() and merge() are not supported by ${method}(). ` + + 'Insert the conflicting rows with a separate insert() query instead.', + ); - expect(fetched.model1Prop1).to.equal('new root'); - expect(fetched.model1Relation2.map((it) => it.model2Prop1)).to.eql(['new child']); + const rows = await Model1.query().where('model1Prop1', 'new root'); + expect(rows).toEqual([]); }); } }); describe('.query().insertGraph() with new models below related models (#217)', () => { - let warnings; - let originalWarn; - beforeEach(() => { - resetWarnings(); - warnings = []; - originalWarn = console.warn; - console.warn = (message) => warnings.push(message); return session.populate(population); }); - afterEach(() => { - console.warn = originalWarn; - resetWarnings(); + it('should throw and insert nothing', async () => { + const err = await Model1.query() + .insertGraph( + { + model1Prop1: 'root', + + model1Relation1: { + id: population.id, + model1Relation2: [{ model2Prop1: 'below related' }], + }, + }, + { relate: true }, + ) + .catch((err) => err); + + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('InvalidGraph'); + expect(err.message).toBe( + "insertGraph cannot insert the new model at 'model1Relation1.model1Relation2[0]' " + + 'below a related model. Insert it with a separate query or use upsertGraph instead', + ); + + expect(await Model1.query().where('model1Prop1', 'root')).toEqual([]); + expect(await session.knex('model2').where('model2_prop1', 'below related')).toEqual([]); }); - it('should warn and ignore the new models', async () => { - const model = await Model1.query().insertGraph( + it('should ignore existing models below related models', async () => { + const model = await Model2.query().insertGraph( { - model1Prop1: 'root', + model2Prop1: 'root', - model1Relation1: { - id: population.id, - model1Relation2: [{ model2Prop1: 'below related' }], - }, + model2Relation1: [ + { + id: population.id, + model1Relation3: [{ idCol: population.model1Relation3[0].idCol }], + }, + ], }, { relate: true }, ); - expect(warnings).to.eql([ - '[objection] insertGraph() ignores new models below related models. ' + - 'Insert them with a separate query or use upsertGraph() instead. ' + - 'This will throw in objection 4.0.', - ]); - - const fetched = await Model1.query().findById(model.id); + const fetched = await Model2.query() + .findById(model.idCol) + .withGraphFetched('model2Relation1'); - expect(fetched.model1Id).to.equal(population.id); - expect(await session.knex('model2').where('model2_prop1', 'below related')).to.eql([]); + expect(fetched.model2Relation1.map((it) => it.id)).toEqual([population.id]); }); - it('should not warn about existing models below related models', async () => { + it('should ignore models with ids below related models that are not related', async () => { const model = await Model2.query().insertGraph( { model2Prop1: 'root', @@ -928,20 +1060,19 @@ module.exports = (session) => { model2Relation1: [ { id: population.id, - model1Relation3: [{ idCol: population.model1Relation3[0].idCol }], + model1Relation3: [{ idCol: 1000, model2Prop1: 'with id' }], }, ], }, - { relate: true }, + { relate: ['model2Relation1'] }, ); - expect(warnings).to.eql([]); - const fetched = await Model2.query() .findById(model.idCol) .withGraphFetched('model2Relation1'); - expect(fetched.model2Relation1.map((it) => it.id)).to.eql([population.id]); + expect(fetched.model2Relation1.map((it) => it.id)).toEqual([population.id]); + expect(await Model2.query().findById(1000)).toBeUndefined(); }); }); @@ -959,20 +1090,18 @@ module.exports = (session) => { }); }); - it('should not allow insert when the allowed relation expression is not a superset', (done) => { - Model1.query() + it('should not allow insert when the allowed relation expression is not a superset', () => { + return Model1.query() .insertGraph(insertion) .allowGraph('[model1Relation1.model1Relation3, model1Relation2]') .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err instanceof ValidationError).to.equal(true); - expect(err.type).to.equal('UnallowedRelation'); - expect(err.message).to.eql('trying to upsert an unallowed relation'); - done(); - }) - .catch(done); + expect(err instanceof ValidationError).toBe(true); + expect(err.type).toBe('UnallowedRelation'); + expect(err.message).toBe('trying to upsert an unallowed relation'); + }); }); }); @@ -1011,11 +1140,11 @@ module.exports = (session) => { .$relatedQuery('model1Relation1') .insertGraph(insertion, { allowRefs: true }); - expect(parent.model1Id).to.equal(inserted.id); + expect(parent.model1Id).toBe(inserted.id); await check(inserted, true); const row = await session.knex('Model1').where('id', 1).first(); - expect(row.model1Id).to.equal(inserted.id); + expect(row.model1Id).toBe(inserted.id); }); }); @@ -1053,7 +1182,7 @@ module.exports = (session) => { return parent.$relatedQuery('model1Relation2').first(); }) .then((insertion) => { - expect(insertion.model2Prop1).to.equal('howdy'); + expect(insertion.model2Prop1).toBe('howdy'); return insertion.$relatedQuery('model2Relation1').withGraphFetched(eagerExpr).first(); }) .then((model) => { @@ -1116,7 +1245,7 @@ module.exports = (session) => { .whereIn('model2_prop1', ['multi1', 'multi2']) .orderBy('model2_prop1'); - expect(models.map(({ model2Prop1, extra1 }) => ({ model2Prop1, extra1 }))).to.eql([ + expect(models.map(({ model2Prop1, extra1 }) => ({ model2Prop1, extra1 }))).toEqual([ { model2Prop1: 'multi1', extra1: 'extra1' }, { model2Prop1: 'multi2', extra1: 'extra2' }, ]); @@ -1128,9 +1257,9 @@ module.exports = (session) => { model = model.$clone(); let knex = model.constructor.knex(); - expect(model).to.have.property('model1Relation1'); - expect(model.model1Relation1).to.have.property('model1Relation3'); - expect(model).to.have.property('model1Relation2'); + expect(model).toHaveProperty('model1Relation1'); + expect(model.model1Relation1).toHaveProperty('model1Relation3'); + expect(model).toHaveProperty('model1Relation2'); model.model1Relation1.model1Relation3 = sortBy( model.model1Relation1.model1Relation3, @@ -1138,43 +1267,43 @@ module.exports = (session) => { ); model.model1Relation2 = sortBy(model.model1Relation2, 'model2Prop1'); - expect(model.model1Prop1).to.equal('root'); + expect(model.model1Prop1).toBe('root'); shouldCheckHooks && checkHooks(model); - expect(model.model1Relation1.model1Prop1).to.equal('parent'); + expect(model.model1Relation1.model1Prop1).toBe('parent'); shouldCheckHooks && checkHooks(model.model1Relation1); - expect(model.model1Relation1Inverse.model1Prop1).to.equal('rootParent'); + expect(model.model1Relation1Inverse.model1Prop1).toBe('rootParent'); shouldCheckHooks && checkHooks(model.model1Relation1Inverse); - expect(model.model1Relation1.model1Relation3[0].model2Prop1).to.equal('child1'); + expect(model.model1Relation1.model1Relation3[0].model2Prop1).toBe('child1'); shouldCheckHooks && checkHooks(model.model1Relation1.model1Relation3[0]); - expect(model.model1Relation1.model1Relation3[1].model2Prop1).to.equal('cibling2'); - expect(model.model1Relation1.model1Relation3[1].extra1).to.equal('extraVal1'); - expect(model.model1Relation1.model1Relation3[1].extra2).to.equal('extraVal2'); - expect(model.model1Relation1.model1Relation3[2].idCol).to.equal(1); - expect(model.model1Relation1.model1Relation3[2].extra1).to.equal('foo'); + expect(model.model1Relation1.model1Relation3[1].model2Prop1).toBe('cibling2'); + expect(model.model1Relation1.model1Relation3[1].extra1).toBe('extraVal1'); + expect(model.model1Relation1.model1Relation3[1].extra2).toBe('extraVal2'); + expect(model.model1Relation1.model1Relation3[2].idCol).toBe(1); + expect(model.model1Relation1.model1Relation3[2].extra1).toBe('foo'); shouldCheckHooks && checkHooks(model.model1Relation1.model1Relation3[1]); - expect(model.model1Relation2[0].model2Prop1).to.equal('child1'); + expect(model.model1Relation2[0].model2Prop1).toBe('child1'); shouldCheckHooks && checkHooks(model.model1Relation2[0]); - expect(model.model1Relation2[1].model2Prop1).to.equal('child2'); + expect(model.model1Relation2[1].model2Prop1).toBe('child2'); shouldCheckHooks && checkHooks(model.model1Relation2[1]); - expect(model.model1Relation2[1].model2Relation2.model1Prop1).to.equal('child3'); + expect(model.model1Relation2[1].model2Relation2.model1Prop1).toBe('child3'); shouldCheckHooks && checkHooks(model.model1Relation2[1].model2Relation2); return knex(Model2.getTableName()).then((rows) => { // Check that the reference model was only inserted once. - expect(rows.filter((it) => it.model2_prop1 === 'child1')).to.have.length(1); + expect(rows.filter((it) => it.model2_prop1 === 'child1')).toHaveLength(1); }); } function checkHooks(model) { - expect(model.$beforeInsertCalled).to.equal(1); - expect(model.$afterInsertCalled).to.equal(1); + expect(model.$beforeInsertCalled).toBe(1); + expect(model.$afterInsertCalled).toBe(1); } }); }; diff --git a/tests/integration/jsonQueries.js b/tests/integration/jsonQueries.js index 7715f0029..5236848b7 100644 --- a/tests/integration/jsonQueries.js +++ b/tests/integration/jsonQueries.js @@ -1,16 +1,16 @@ -const expect = require('expect.js'); -const { Model, ref, val, raw } = require('../../'); -const { cloneDeep } = require('../../testUtils/testUtils'); +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { Model, ref, val, raw } from 'objection'; +import { cloneDeep } from '../../testUtils/testUtils.js'; function expectIdsEqual(resultArray, expectedIds) { expectArraysEqual(resultArray.map((it) => it.id).sort(), expectedIds); } function expectArraysEqual(arr1, arr2) { - expect({ arr: arr1 }).to.eql({ arr: arr2 }); + expect({ arr: arr1 }).toEqual({ arr: arr2 }); } -module.exports = (session) => { +export default (session) => { describe('JSON queries', () => { class ModelJson extends Model { static get tableName() { @@ -32,7 +32,14 @@ module.exports = (session) => { let BoundModel = ModelJson.bindKnex(session.knex); - before(() => { + // The mysql driver returns json values as text. + const jsonValue = (value) => (session.isMySql() ? JSON.stringify(value) : value); + // SQLite has no json containment for whereJsonSupersetOf() and whereJsonSubsetOf(). + const hasJsonContainment = !session.isSqlite(); + // The tests that pass the `@>` and `<@` operators of Postgres to where() only + // run there. + + beforeAll(() => { return session.knex.schema .dropTableIfExists('ModelJson') .createTable('ModelJson', (table) => { @@ -45,11 +52,11 @@ module.exports = (session) => { describe('QueryBuilder using ref() in normal query builder methods', () => { describe('Querying rows', () => { - before(() => { + beforeAll(() => { return BoundModel.query() .delete() .then(() => { - return BoundModel.query().insert([ + return BoundModel.query().insertGraph([ { id: 1, name: 'test1', jsonObject: {}, jsonArray: [1] }, { id: 2, name: 'test2', jsonObject: { attr: 2 }, jsonArray: [2] }, { id: 3, name: 'test3', jsonObject: { attr: 3 }, jsonArray: [3] }, @@ -63,8 +70,8 @@ module.exports = (session) => { .select(ref('jsonArray:[0]').as('foo')) .orderBy('foo', 'desc') .then((result) => { - expect(result).to.have.length(4); - expect(result[0]).eql({ foo: 4 }); + expect(result).toHaveLength(4); + expect(result[0]).toEqual({ foo: jsonValue(4) }); }); }); @@ -73,8 +80,8 @@ module.exports = (session) => { .select([ref('jsonObject:attr').castBigInt().as('bar'), ref('jsonArray:[0]').as('foo')]) .orderBy('foo') .then((result) => { - expect(result).to.have.length(4); - expect(result[0]).eql({ foo: 1, bar: null }); + expect(result).toHaveLength(4); + expect(result[0]).toEqual({ foo: jsonValue(1), bar: null }); }); }); @@ -93,9 +100,9 @@ module.exports = (session) => { ]) .orderBy('firstArrayItem', 'desc') .then((result) => { - expect(result).to.have.length(4); + expect(result).toHaveLength(4); // foo is always name of the last row of the table (quite a nonsense query) - expect(result[0]).eql({ foo: 'test4', firstArrayItem: 4 }); + expect(result[0]).toEqual({ foo: 'test4', firstArrayItem: jsonValue(4) }); }); }); @@ -103,7 +110,7 @@ module.exports = (session) => { return BoundModel.query() .where(ref('jsonArray:[0]').castBigInt(), ref('jsonObject:attr').castBigInt()) .then((result) => { - expect(result).to.have.length(3); + expect(result).toHaveLength(3); }); }); @@ -113,7 +120,7 @@ module.exports = (session) => { builder.where(ref('jsonArray:[0]').castBigInt(), ref('jsonObject:attr').castBigInt()); }) .then((result) => { - expect(result).to.have.length(3); + expect(result).toHaveLength(3); }); }); @@ -123,7 +130,7 @@ module.exports = (session) => { .join('ModelJson as t2', ref('ModelJson.jsonArray:[0]'), '=', ref('t2.jsonObject:attr')) .select('t2.*') .then((result) => { - expect(result).to.have.length(3); + expect(result).toHaveLength(3); }); }); @@ -140,7 +147,7 @@ module.exports = (session) => { }) .select('t2.*') .then((result) => { - expect(result).to.have.length(3); + expect(result).toHaveLength(3); }); }); @@ -148,52 +155,67 @@ module.exports = (session) => { return BoundModel.query() .orderBy(ref('jsonObject:attr'), 'desc') .then((result) => { - expect(result).to.have.length(4); - // null is first - expect(result[0].name).to.equal('test1'); - }); - }); - - it('should be able to use ref with groupBy and having (last argument of having is ref)', () => { - return BoundModel.query() - .select(['id', ref('jsonObject:attr').as('foo')]) - .groupBy([ref('jsonObject:attr'), 'id']) - .having('id', '>=', ref('jsonObject:attr').castInt()) - .orderBy('foo') - .then((result) => { - expect(result).to.have.length(3); - expect(result[0]).to.eql({ id: 2, foo: 2 }); - }); - }); - - it('should be able to use ref with groupBy and having (also first arg is ref)', () => { - return BoundModel.query() - .select('id', ref('jsonObject:attr').as('foo')) - .groupBy([ref('jsonObject:attr'), 'id']) - .having(ref('id').castInt(), '>=', ref('jsonObject:attr').castInt()) - .orderBy('foo') - .then((result) => { - expect(result).to.have.length(3); - expect(result[0]).to.eql({ id: 2, foo: 2 }); - }); - }); - - it('should be able to use ref with groupBy and nested having', () => { - return BoundModel.query() - .select(['id', ref('jsonObject:attr').as('foo')]) - .groupBy([ref('jsonObject:attr'), 'id']) - .having('id', '>=', ref('jsonObject:attr').castInt()) - .having((builder) => { - builder.having('id', '=', ref('id')).having((nestedBuilder) => { - nestedBuilder.having('id', '=', ref('id')); + // Postgres sorts nulls first in descending order, MySQL and SQLite last. + expect(result.map((it) => it.name)).toEqual( + session.isPostgres() + ? ['test1', 'test4', 'test3', 'test2'] + : ['test4', 'test3', 'test2', 'test1'], + ); + }); + }); + + // MySQL only allows grouped columns in having, not the json column. + it.skipIf(session.isMySql())( + 'should be able to use ref with groupBy and having (last argument of having is ref)', + () => { + return BoundModel.query() + .select(['id', ref('jsonObject:attr').as('foo')]) + .groupBy([ref('jsonObject:attr'), 'id']) + .having('id', '>=', ref('jsonObject:attr').castInt()) + .orderBy('foo') + .then((result) => { + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ id: 2, foo: 2 }); }); - }) - .orderBy('foo') - .then((result) => { - expect(result).to.have.length(3); - expect(result[0]).to.eql({ id: 2, foo: 2 }); - }); - }); + }, + ); + + // MySQL only allows grouped columns in having, not the json column. + it.skipIf(session.isMySql())( + 'should be able to use ref with groupBy and having (also first arg is ref)', + () => { + return BoundModel.query() + .select('id', ref('jsonObject:attr').as('foo')) + .groupBy([ref('jsonObject:attr'), 'id']) + .having(ref('id').castInt(), '>=', ref('jsonObject:attr').castInt()) + .orderBy('foo') + .then((result) => { + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ id: 2, foo: 2 }); + }); + }, + ); + + // MySQL only allows grouped columns in having, not the json column. + it.skipIf(session.isMySql())( + 'should be able to use ref with groupBy and nested having', + () => { + return BoundModel.query() + .select(['id', ref('jsonObject:attr').as('foo')]) + .groupBy([ref('jsonObject:attr'), 'id']) + .having('id', '>=', ref('jsonObject:attr').castInt()) + .having((builder) => { + builder.having('id', '=', ref('id')).having((nestedBuilder) => { + nestedBuilder.having('id', '=', ref('id')); + }); + }) + .orderBy('foo') + .then((result) => { + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ id: 2, foo: 2 }); + }); + }, + ); }); describe('.insert()', () => { @@ -215,7 +237,7 @@ module.exports = (session) => { return BoundModel.query() .truncate() .then(() => { - return BoundModel.query().insert([ + return BoundModel.query().insertGraph([ { id: 1, name: 'test1', jsonObject: {}, jsonArray: [1] }, { id: 2, name: 'test2', jsonObject: { attr: 2 }, jsonArray: [2] }, { id: 3, name: 'test3', jsonObject: { attr: 3 }, jsonArray: [3] }, @@ -224,64 +246,37 @@ module.exports = (session) => { }); }); - it('should be able to use knex.raw to jsonb column in update', () => { - return BoundModel.query() - .update({ - jsonArray: BoundModel.knex().raw('to_jsonb(??)', ['name']), - }) - .then((result) => { - expect(result).to.be(4); - }); - }); - - it('should be able to update internal field of json column and allow ref() syntax', () => { - // should do something like: - // update 'ModelJson' set - // 'jsonArray' = jsonb_set('[]', '{0}', to_jsonb('name'), true), - // 'jsonObject' = jsonb_set('jsonObject', '{attr}', to_jsonb('name'), true), - // 'name' = 'jsonArray'#>>'{0}' where 'id' = 1 returning *; - return BoundModel.query() - .update({ - name: ref('jsonArray:[0]').castText(), - 'jsonObject:attr': ref('name'), - // each attribute which is updated with ref must be updated separately - // e.g. SET 'jsonArray' = '[ ref(...), ref(...) ]' just isn't valid SQL - // (though it could be kind of parsed to multiple jsonb_set calls which would be insanely cool) - jsonArray: ref('name').castJson(), - }) - .where('id', 1) - .returning('*') - .then((result) => { - expect(result).to.eql([ - { - id: 1, - name: '1', - jsonObject: { attr: 'test1' }, - jsonArray: 'test1', - }, - ]); - }); - }); + it.skipIf(!session.isPostgres())( + 'should be able to use knex.raw to jsonb column in update', + () => { + return BoundModel.query() + .update({ + jsonArray: BoundModel.knex().raw('to_jsonb(??)', ['name']), + }) + .then((result) => { + expect(result).toBe(4); + }); + }, + ); it('should be able to patch internal field of json column and allow ref() syntax', () => { - // same stuff that with patch but different api method return BoundModel.query() .patch({ name: ref('jsonArray:[0]').castText(), 'jsonObject:attr': ref('name'), - jsonArray: ref('name').castJson(), + jsonArray: ref('name').castText().castJson(), }) .where('id', 1) - .returning('*') + .then(() => BoundModel.query().findById(1)) .then((result) => { - expect(result).to.eql([ - { - id: 1, - name: '1', - jsonObject: { attr: 'test1' }, - jsonArray: 'test1', - }, - ]); + // MySQL assigns from left to right and reads the updated name. + const name = session.isMySql() ? '1' : 'test1'; + expect(result).toEqual({ + id: 1, + name: '1', + jsonObject: { attr: name }, + jsonArray: name, + }); }); }); @@ -293,7 +288,7 @@ module.exports = (session) => { .findById(1) .then(() => BoundModel.query().findById(1).select('jsonObject')) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ jsonObject: { attr: [1, 2, 5, 7] }, }); }); @@ -307,7 +302,7 @@ module.exports = (session) => { .where('id', 1) .then(() => BoundModel.query().findById(1).select('jsonObject')) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ jsonObject: { attr: { foo: 'bar' } }, }); }); @@ -321,7 +316,7 @@ module.exports = (session) => { .where('id', 2) .then(() => BoundModel.query().findById(2).select('jsonObject')) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ jsonObject: { attr: 'baz' }, }); }); @@ -335,12 +330,20 @@ module.exports = (session) => { .where('id', 2) .then(() => BoundModel.query().findById(2).select('jsonObject')) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ jsonObject: { attr: 'baz' }, }); }); }); + it('should be able to patch internal field of json column using another json column', async () => { + // SQLite can't tell json columns from text columns, which ref() would write as strings. + const value = session.isSqlite() ? raw('json(??)', ['jsonArray']) : ref('jsonArray'); + await BoundModel.query().patch({ 'jsonObject:copy': value }).where('id', 2); + const result = await BoundModel.query().findById(2).select('jsonObject'); + expect(result).toEqual({ jsonObject: { attr: 2, copy: [2] } }); + }); + it('should be able to patch multiple fields inside the same json object', () => { return BoundModel.query() .patch({ @@ -350,7 +353,7 @@ module.exports = (session) => { .where('id', 2) .then(() => BoundModel.query().findById(2).select('jsonObject')) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ jsonObject: { attr: 2, attr1: 'foo', @@ -373,7 +376,7 @@ module.exports = (session) => { return BoundModel.query().findById(1).select('name', 'jsonObject'); }) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ name: 'updated name', jsonObject: { attr: 'bar', @@ -382,16 +385,20 @@ module.exports = (session) => { }); }); - it('should not have the json reference property in the result object', async () => { - const item = await BoundModel.query().findById(1); - await item.$query().patch({ 'jsonObject:attr': 'bar' }).returning('*'); - expect(item).to.eql({ - id: 1, - name: 'test1', - jsonObject: { attr: 'bar' }, - jsonArray: [1], - }); - }); + // MySQL doesn't support returning(). + it.skipIf(session.isMySql())( + 'should not have the json reference property in the result object', + async () => { + const item = await BoundModel.query().findById(1); + await item.$query().patch({ 'jsonObject:attr': 'bar' }).returning('*'); + expect(item).toEqual({ + id: 1, + name: 'test1', + jsonObject: { attr: 'bar' }, + jsonArray: [1], + }); + }, + ); }); describe('empty and special json keys', () => { @@ -399,7 +406,7 @@ module.exports = (session) => { return BoundModel.query() .truncate() .then(() => { - return BoundModel.query().insert([ + return BoundModel.query().insertGraph([ { id: 1, name: 'test1', jsonObject: { '': { a: 1 }, 'x, y': 1 }, jsonArray: [] }, { id: 2, name: 'test2', jsonObject: { '': { a: 2 }, 'x, y': 2 }, jsonArray: [] }, ]); @@ -410,18 +417,23 @@ module.exports = (session) => { const result = await BoundModel.query() .select('id', ref('jsonObject:[""].a').as('a'), ref("jsonObject:['x, y']").as('xy')) .where(ref("jsonObject:[''].a").castInt(), 2); - expect(result).to.eql([{ id: 2, a: 2, xy: 2 }]); + expect(result).toEqual([{ id: 2, a: jsonValue(2), xy: jsonValue(2) }]); }); - it('should be able to use empty keys in json where methods', async () => { - const result = await BoundModel.query().whereJsonSupersetOf('jsonObject:[""]', { a: 1 }); - expectIdsEqual(result, [1]); - }); + it.skipIf(!hasJsonContainment)( + 'should be able to use empty keys in json where methods', + async () => { + const result = await BoundModel.query().whereJsonSupersetOf('jsonObject:[""]', { + a: 1, + }); + expectIdsEqual(result, [1]); + }, + ); it('should be able to patch empty keys', async () => { await BoundModel.query().findById(1).patch({ 'jsonObject:[""].a': 3 }); const item = await BoundModel.query().findById(1); - expect(item.jsonObject).to.eql({ '': { a: 3 }, 'x, y': 1 }); + expect(item.jsonObject).toEqual({ '': { a: 3 }, 'x, y': 1 }); }); }); @@ -442,7 +454,7 @@ module.exports = (session) => { beforeEach(async () => { await BoundModel.query().truncate(); - await BoundModel.query().insert([ + await BoundModel.query().insertGraph([ { id: 1, name: 'test1', @@ -459,11 +471,16 @@ module.exports = (session) => { .select('id', ref(`${expr(key)}.a`).as('a')) .where(ref(`${expr(key)}.a`).castInt(), 1) .orWhere(ref(`${expr(key)}.a`), 2); - expect(result).to.eql([{ id: 1, a: 1 }]); + expect(result).toEqual([{ id: 1, a: jsonValue(1) }]); }); it(`should use a key with ${name} in json where methods`, async () => { - expectIdsEqual(await BoundModel.query().whereJsonSupersetOf(expr(key), { a: 1 }), [1]); + if (hasJsonContainment) { + expectIdsEqual( + await BoundModel.query().whereJsonSupersetOf(expr(key), { a: 1 }), + [1], + ); + } expectIdsEqual(await BoundModel.query().whereJsonIsObject(expr(key)), [1]); expectIdsEqual(await BoundModel.query().whereJsonNotObject(expr(key)), [2]); expectIdsEqual(await BoundModel.query().whereJsonHasAny(expr(key), ['a', key]), [1]); @@ -475,17 +492,17 @@ module.exports = (session) => { .findById(1) .patch({ [`${expr(key)}.a`]: 3 }); const items = await BoundModel.query().orderBy('id'); - expect(items[0].jsonObject[key]).to.eql({ a: 3 }); + expect(items[0].jsonObject[key]).toEqual({ a: 3 }); expect( Object.fromEntries(Object.entries(items[0].jsonObject).filter(([k]) => k !== key)), - ).to.eql( + ).toEqual( Object.fromEntries( Object.entries(keys) .filter(([n]) => n !== name) .map(([, k]) => [k, { a: 1 }]), ), ); - expect(items[1].jsonObject).to.eql({ x: { a: 2 } }); + expect(items[1].jsonObject).toEqual({ x: { a: 2 } }); }); } }); @@ -524,7 +541,7 @@ module.exports = (session) => { it('should be able to patch fields of json columns', async () => { await StrictModel.query().findById(1).patch({ 'jsonObject:other': 'foo' }); const item = await StrictModel.query().findById(1); - expect(item.jsonObject).to.eql({ attr: 1, other: 'foo' }); + expect(item.jsonObject).toEqual({ attr: 1, other: 'foo' }); }); it('should validate patched fields against the json column schema', async () => { @@ -536,8 +553,8 @@ module.exports = (session) => { error = err; } - expect(error).to.be.a(StrictModel.ValidationError); - expect(error.data).to.have.key('jsonObject.unknown'); + expect(error).toBeInstanceOf(StrictModel.ValidationError); + expect(Object.keys(error.data)).toContain('jsonObject.unknown'); error = null; @@ -547,11 +564,11 @@ module.exports = (session) => { error = err; } - expect(error).to.be.a(StrictModel.ValidationError); - expect(error.data).to.have.key('jsonObject.other'); + expect(error).toBeInstanceOf(StrictModel.ValidationError); + expect(Object.keys(error.data)).toContain('jsonObject.other'); const item = await StrictModel.query().findById(1); - expect(item.jsonObject).to.eql({ attr: 1 }); + expect(item.jsonObject).toEqual({ attr: 1 }); }); }); }); @@ -559,7 +576,7 @@ module.exports = (session) => { describe('QueryBuilder JSON queries', () => { let complexJsonObj; - before(() => { + beforeAll(() => { complexJsonObj = { id: 1, name: 'complex line', @@ -642,17 +659,19 @@ module.exports = (session) => { it('should have test data', () => { return BoundModel.query().then((all) => { - expect(all.find((it) => it.name === 'complex line').jsonObject.stringField).to.be( + expect(all.find((it) => it.name === 'complex line').jsonObject.stringField).toBe( complexJsonObj.jsonObject.stringField, ); }); }); - describe('private function parseFieldExpression(expression, extractAsText)', () => { + describe('column references in field expressions', () => { + const column = () => session.knex.raw('??', ['ModelJson.jsonArray']).toString(); + it('should quote ModelJson.jsonArray column reference properly', () => { expect( BoundModel.query().whereJsonIsArray('ModelJson.jsonArray').toKnexQuery().toString(), - ).to.contain('"ModelJson"."jsonArray"'); + ).toContain(column()); }); it('should quote ModelJson.jsonArray:[10] column reference properly', () => { @@ -661,7 +680,7 @@ module.exports = (session) => { .whereJsonIsArray('ModelJson.jsonArray:[50]') .toKnexQuery() .toString(), - ).to.contain('"ModelJson"."jsonArray"'); + ).toContain(column()); }); }); @@ -694,7 +713,7 @@ module.exports = (session) => { return BoundModel.query() .where('jsonArray', val({})) .then((results) => { - expect(results).to.have.length(0); + expect(results).toHaveLength(0); }); }); @@ -702,7 +721,7 @@ module.exports = (session) => { return BoundModel.query() .where('jsonObject', val([])) .then((results) => { - expect(results).to.have.length(0); + expect(results).toHaveLength(0); }); }); @@ -734,7 +753,7 @@ module.exports = (session) => { return BoundModel.query() .where('jsonObject', val({ a: '1' })) .then((results) => { - expect(results).to.have.length(0); + expect(results).toHaveLength(0); }); }); @@ -750,7 +769,7 @@ module.exports = (session) => { return BoundModel.query() .where(ref('jsonArray:[0].arrayField[0]'), val({ noMoreLevels: false })) .then((results) => { - expect(results).to.have.length(0); + expect(results).toHaveLength(0); }); }); @@ -782,7 +801,7 @@ module.exports = (session) => { return BoundModel.query() .where('jsonArray', val([2, 1])) .then((results) => { - expect(results).to.have.length(0); + expect(results).toHaveLength(0); }); }); @@ -838,502 +857,568 @@ module.exports = (session) => { }); }); - describe('.whereJsonSupersetOf(fieldExpr, )', () => { - it('should find all empty arrays with jsonArray @> []', () => { - return BoundModel.query() - .whereJsonSupersetOf('jsonArray', []) - .then((results) => { - expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); - }); - }); - - it('should find all empty arrays with jsonArray @> []', () => { - return BoundModel.query() - .where('jsonArray', '@>', val([])) - .then((results) => { - expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); - }); - }); + describe.skipIf(!hasJsonContainment)( + '.whereJsonSupersetOf(fieldExpr, )', + () => { + it('should find all empty arrays with jsonArray @> []', () => { + return BoundModel.query() + .whereJsonSupersetOf('jsonArray', []) + .then((results) => { + expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); + }); + }); - it('should find results jsonArray @> [1,2] (set is its own superset)', () => { - return BoundModel.query() - .whereJsonSupersetOf('jsonArray', [1, 2]) - .then((results) => { - expectIdsEqual(results, [4]); - }); - }); + it.skipIf(!session.isPostgres())( + 'should find all empty arrays with jsonArray @> []', + () => { + return BoundModel.query() + .where('jsonArray', '@>', val([])) + .then((results) => { + expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); + }); + }, + ); - it('should find results jsonArray @> [1,2] (set is its own superset)', () => { - return BoundModel.query() - .where('jsonArray', '@>', val([1, 2])) - .then((results) => { - expectIdsEqual(results, [4]); - }); - }); + it('should find results jsonArray @> [1,2] (set is its own superset)', () => { + return BoundModel.query() + .whereJsonSupersetOf('jsonArray', [1, 2]) + .then((results) => { + expectIdsEqual(results, [4]); + }); + }); - it('should not find results jsonArray @> {}', () => { - return BoundModel.query() - .whereJsonSupersetOf('jsonArray', {}) - .then((results) => { - expect(results).to.have.length(0); - }); - }); + it.skipIf(!session.isPostgres())( + 'should find results jsonArray @> [1,2] (set is its own superset)', + () => { + return BoundModel.query() + .where('jsonArray', '@>', val([1, 2])) + .then((results) => { + expectIdsEqual(results, [4]); + }); + }, + ); - it('should not find results jsonArray @> {}', () => { - return BoundModel.query() - .where('jsonArray', '@>', val({})) - .then((results) => { - expect(results).to.have.length(0); - }); - }); + it('should not find results jsonArray @> {}', () => { + return BoundModel.query() + .whereJsonSupersetOf('jsonArray', {}) + .then((results) => { + // MySQL's JSON_CONTAINS() finds objects in the elements of arrays. + expectIdsEqual(results, session.isMySql() ? [1] : []); + }); + }); - it('should not find results jsonObject @> []', () => { - return BoundModel.query() - .whereJsonSupersetOf('jsonObject', []) - .then((results) => { - expect(results).to.have.length(0); - }); - }); + it.skipIf(!session.isPostgres())('should not find results jsonArray @> {}', () => { + return BoundModel.query() + .where('jsonArray', '@>', val({})) + .then((results) => { + expect(results).toHaveLength(0); + }); + }); - it('should not find results jsonObject @> []', () => { - return BoundModel.query() - .where('jsonObject', '@>', val([])) - .then((results) => { - expect(results).to.have.length(0); - }); - }); + it('should not find results jsonObject @> []', () => { + return BoundModel.query() + .whereJsonSupersetOf('jsonObject', []) + .then((results) => { + expect(results).toHaveLength(0); + }); + }); - it('should find subset where both sides are references', () => { - return BoundModel.query() - .whereJsonSupersetOf('jsonObject:a', 'jsonObject:b') - .then((results) => { - expectIdsEqual(results, [7]); - }); - }); + it.skipIf(!session.isPostgres())('should not find results jsonObject @> []', () => { + return BoundModel.query() + .where('jsonObject', '@>', val([])) + .then((results) => { + expect(results).toHaveLength(0); + }); + }); - it('should find subset where the right side is a ref()', async () => { - expectIdsEqual( - await BoundModel.query().whereJsonSupersetOf('jsonObject:a', ref('jsonObject:b')), - [7], - ); - expectIdsEqual( - await BoundModel.query().whereJsonSubsetOf( - 'jsonObject:b', - ref('ModelJson.jsonObject:a'), - ), - [7], - ); - expectIdsEqual( - await BoundModel.query().whereJsonNotSupersetOf('jsonObject', ref('jsonObject:a')), - [6, 7], - ); - }); + it('should find subset where both sides are references', () => { + return BoundModel.query() + .whereJsonSupersetOf('jsonObject:a', 'jsonObject:b') + .then((results) => { + expectIdsEqual(results, [7]); + }); + }); - it('should find subset where the right side is a val() or raw()', async () => { - expectIdsEqual( - await BoundModel.query().whereJsonSupersetOf( - 'jsonObject:objectField', - val({}).castJson(), - ), - [1], - ); - expectIdsEqual( - await BoundModel.query().orWhereJsonSupersetOf( - 'jsonObject:objectField', - raw('?::jsonb', JSON.stringify(complexJsonObj.jsonObject.objectField)), - ), - [1], - ); - }); + it('should find subset where the right side is a ref()', async () => { + expectIdsEqual( + await BoundModel.query().whereJsonSupersetOf('jsonObject:a', ref('jsonObject:b')), + [7], + ); + expectIdsEqual( + await BoundModel.query().whereJsonSubsetOf( + 'jsonObject:b', + ref('ModelJson.jsonObject:a'), + ), + [7], + ); + expectIdsEqual( + await BoundModel.query().whereJsonNotSupersetOf('jsonObject', ref('jsonObject:a')), + [6, 7], + ); + }); - it('should find subset where both sides are references', () => { - return BoundModel.query() - .where(ref('jsonObject:a').castJson(), '@>', ref('jsonObject:b').castJson()) - .then((results) => { - expectIdsEqual(results, [7]); - }); - }); + it('should find subset where the right side is a val() or raw()', async () => { + expectIdsEqual( + await BoundModel.query().whereJsonSupersetOf( + 'jsonObject:objectField', + val({}).castJson(), + ), + [1], + ); + expectIdsEqual( + await BoundModel.query().orWhereJsonSupersetOf( + 'jsonObject:objectField', + raw('?', JSON.stringify(complexJsonObj.jsonObject.objectField)), + ), + [1], + ); + }); - it('should find results jsonObject @> {}', () => { - return BoundModel.query() - .whereJsonSupersetOf('jsonObject', {}) - .then((results) => { - expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); - }); - }); + it.skipIf(!session.isPostgres())( + 'should find subset where both sides are references', + () => { + return BoundModel.query() + .where(ref('jsonObject:a').castJson(), '@>', ref('jsonObject:b').castJson()) + .then((results) => { + expectIdsEqual(results, [7]); + }); + }, + ); - it('should find results jsonObject @> {}', () => { - return BoundModel.query() - .where('jsonObject', '@>', val({})) - .then((results) => { - expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); - }); - }); + it('should find results jsonObject @> {}', () => { + return BoundModel.query() + .whereJsonSupersetOf('jsonObject', {}) + .then((results) => { + expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); + }); + }); - it('should find results jsonObject.objectField @> complexJsonObj.jsonObject.objectField', () => { - return BoundModel.query() - .whereJsonSupersetOf('jsonObject:objectField', complexJsonObj.jsonObject.objectField) - .then((results) => { - expectIdsEqual(results, [1]); - }); - }); + it.skipIf(!session.isPostgres())('should find results jsonObject @> {}', () => { + return BoundModel.query() + .where('jsonObject', '@>', val({})) + .then((results) => { + expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); + }); + }); - it('should find results jsonObject.objectField @> complexJsonObj.jsonObject.objectField', () => { - return BoundModel.query() - .where(ref('jsonObject:objectField'), '@>', val(complexJsonObj.jsonObject.objectField)) - .then((results) => { - expectIdsEqual(results, [1]); - }); - }); + it('should find results jsonObject.objectField @> complexJsonObj.jsonObject.objectField', () => { + return BoundModel.query() + .whereJsonSupersetOf('jsonObject:objectField', complexJsonObj.jsonObject.objectField) + .then((results) => { + expectIdsEqual(results, [1]); + }); + }); - it('should not find results jsonObject.objectField @> complexJsonObj.jsonObject.objectField that has additional key', () => { - let obj = cloneDeep(complexJsonObj.jsonObject.objectField); - obj.otherKey = 'Im here too!'; + it.skipIf(!session.isPostgres())( + 'should find results jsonObject.objectField @> complexJsonObj.jsonObject.objectField', + () => { + return BoundModel.query() + .where( + ref('jsonObject:objectField'), + '@>', + val(complexJsonObj.jsonObject.objectField), + ) + .then((results) => { + expectIdsEqual(results, [1]); + }); + }, + ); - return BoundModel.query() - .whereJsonSupersetOf('jsonObject:objectField', obj) - .then((results) => { - expect(results).to.have.length(0); - }); - }); + it('should not find results jsonObject.objectField @> complexJsonObj.jsonObject.objectField that has additional key', () => { + let obj = cloneDeep(complexJsonObj.jsonObject.objectField); + obj.otherKey = 'Im here too!'; - it('should not find results jsonObject.objectField @> complexJsonObj.jsonObject.objectField that has additional key', () => { - let obj = cloneDeep(complexJsonObj.jsonObject.objectField); - obj.otherKey = 'Im here too!'; + return BoundModel.query() + .whereJsonSupersetOf('jsonObject:objectField', obj) + .then((results) => { + expect(results).toHaveLength(0); + }); + }); - return BoundModel.query() - .where(ref('jsonObject:objectField'), '@>', val(obj)) - .then((results) => { - expect(results).to.have.length(0); - }); - }); + it.skipIf(!session.isPostgres())( + 'should not find results jsonObject.objectField @> complexJsonObj.jsonObject.objectField that has additional key', + () => { + let obj = cloneDeep(complexJsonObj.jsonObject.objectField); + obj.otherKey = 'Im here too!'; - it('should not find results jsonObject.objectField @> { object: "other string" }', () => { - return BoundModel.query() - .whereJsonSupersetOf('jsonObject:objectField', { - object: 'something else', - }) - .then((results) => { - expect(results).to.have.length(0); - }); - }); + return BoundModel.query() + .where(ref('jsonObject:objectField'), '@>', val(obj)) + .then((results) => { + expect(results).toHaveLength(0); + }); + }, + ); - it('should not find results jsonObject.objectField @> { object: "other string" }', () => { - return BoundModel.query() - .where( - ref('jsonObject:objectField'), - '@>', - val({ + it('should not find results jsonObject.objectField @> { object: "other string" }', () => { + return BoundModel.query() + .whereJsonSupersetOf('jsonObject:objectField', { object: 'something else', - }), - ) - .then((results) => { - expect(results).to.have.length(0); - }); - }); - - it('should find results jsonObject @> [] OR jsonObject @> {}', () => { - return BoundModel.query() - .whereJsonSupersetOf('jsonObject', []) - .orWhereJsonSupersetOf('jsonObject', {}) - .then((results) => { - expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); - }); - }); - - it('should find results jsonObject @> [] OR jsonObject @> {}', () => { - return BoundModel.query() - .where('jsonObject', '@>', val([])) - .orWhere('jsonObject', '@>', val({})) - .then((results) => { - expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); - }); - }); + }) + .then((results) => { + expect(results).toHaveLength(0); + }); + }); - it('should not find results NOT(jsonObject.objectField @> complexJsonObj.jsonObject.objectField)', () => { - return BoundModel.query() - .whereJsonNotSupersetOf('jsonObject:objectField', complexJsonObj.jsonObject.objectField) - .then((results) => { - expectIdsEqual(results, []); - }); - }); + it.skipIf(!session.isPostgres())( + 'should not find results jsonObject.objectField @> { object: "other string" }', + () => { + return BoundModel.query() + .where( + ref('jsonObject:objectField'), + '@>', + val({ + object: 'something else', + }), + ) + .then((results) => { + expect(results).toHaveLength(0); + }); + }, + ); - it('should not find results NOT(jsonObject.objectField @> complexJsonObj.jsonObject.objectField)', () => { - return BoundModel.query() - .whereNot( - ref('jsonObject:objectField'), - '@>', - val(complexJsonObj.jsonObject.objectField), - ) - .then((results) => { - expectIdsEqual(results, []); - }); - }); + it('should find results jsonObject @> [] OR jsonObject @> {}', () => { + return BoundModel.query() + .whereJsonSupersetOf('jsonObject', []) + .orWhereJsonSupersetOf('jsonObject', {}) + .then((results) => { + expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); + }); + }); - it('should not find results NOT(jsonObject.a @> jsonObject.b)', () => { - return BoundModel.query() - .whereJsonNotSupersetOf('jsonObject:a', 'jsonObject:b') - .then((results) => { - expectIdsEqual(results, []); - }); - }); + it.skipIf(!session.isPostgres())( + 'should find results jsonObject @> [] OR jsonObject @> {}', + () => { + return BoundModel.query() + .where('jsonObject', '@>', val([])) + .orWhere('jsonObject', '@>', val({})) + .then((results) => { + expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); + }); + }, + ); - it('should not find results NOT(jsonObject.a @> jsonObject.b)', () => { - return BoundModel.query() - .whereNot(ref('jsonObject:a').castJson(), '@>', ref('jsonObject:b').castJson()) - .then((results) => { - expectIdsEqual(results, []); - }); - }); + it('should not find results NOT(jsonObject.objectField @> complexJsonObj.jsonObject.objectField)', () => { + return BoundModel.query() + .whereJsonNotSupersetOf( + 'jsonObject:objectField', + complexJsonObj.jsonObject.objectField, + ) + .then((results) => { + expectIdsEqual(results, []); + }); + }); - it('should find results jsonObject.a @> jsonObject.b OR NOT(jsonObject.a @> jsonObject.b)', () => { - return BoundModel.query() - .whereJsonSupersetOf('jsonObject:a', 'jsonObject:b') - .orWhereJsonNotSupersetOf('jsonObject:a', 'jsonObject:b') - .then((results) => { - expectIdsEqual(results, [7]); - }); - }); + it.skipIf(!session.isPostgres())( + 'should not find results NOT(jsonObject.objectField @> complexJsonObj.jsonObject.objectField)', + () => { + return BoundModel.query() + .whereNot( + ref('jsonObject:objectField'), + '@>', + val(complexJsonObj.jsonObject.objectField), + ) + .then((results) => { + expectIdsEqual(results, []); + }); + }, + ); - it('should find results jsonObject.a @> jsonObject.b OR NOT(jsonObject.a @> jsonObject.b)', () => { - return BoundModel.query() - .where(ref('jsonObject:a').castJson(), '@>', ref('jsonObject:b').castJson()) - .orWhereNot(ref('jsonObject:a').castJson(), '@>', ref('jsonObject:b').castJson()) - .then((results) => { - expectIdsEqual(results, [7]); - }); - }); + it('should not find results NOT(jsonObject.a @> jsonObject.b)', () => { + return BoundModel.query() + .whereJsonNotSupersetOf('jsonObject:a', 'jsonObject:b') + .then((results) => { + expectIdsEqual(results, []); + }); + }); - it('should not find results NOT(jsonObject.x @> jsonObject.y)', () => { - return BoundModel.query() - .whereJsonNotSupersetOf('jsonObject:x', 'jsonObject:y') - .then((results) => { - expectIdsEqual(results, []); - }); - }); + it.skipIf(!session.isPostgres())( + 'should not find results NOT(jsonObject.a @> jsonObject.b)', + () => { + return BoundModel.query() + .whereNot(ref('jsonObject:a').castJson(), '@>', ref('jsonObject:b').castJson()) + .then((results) => { + expectIdsEqual(results, []); + }); + }, + ); - it('should not find results NOT(jsonObject.x @> jsonObject.y)', () => { - return BoundModel.query() - .whereNot(ref('jsonObject:x').castJson(), '@>', ref('jsonObject:y').castJson()) - .then((results) => { - expectIdsEqual(results, []); - }); - }); + it('should find results jsonObject.a @> jsonObject.b OR NOT(jsonObject.a @> jsonObject.b)', () => { + return BoundModel.query() + .whereJsonSupersetOf('jsonObject:a', 'jsonObject:b') + .orWhereJsonNotSupersetOf('jsonObject:a', 'jsonObject:b') + .then((results) => { + expectIdsEqual(results, [7]); + }); + }); - it('should find results jsonArray = {} or NOT(jsonObject @> jsonArray)', () => { - return BoundModel.query() - .where('jsonArray', '=', val({})) - .orWhereNot('jsonObject', '@>', ref('jsonArray')) - .then((results) => { - expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); - }); - }); + it.skipIf(!session.isPostgres())( + 'should find results jsonObject.a @> jsonObject.b OR NOT(jsonObject.a @> jsonObject.b)', + () => { + return BoundModel.query() + .where(ref('jsonObject:a').castJson(), '@>', ref('jsonObject:b').castJson()) + .orWhereNot(ref('jsonObject:a').castJson(), '@>', ref('jsonObject:b').castJson()) + .then((results) => { + expectIdsEqual(results, [7]); + }); + }, + ); - it('should get skipped if value is undefined and skipUndefined() is called', () => { - return BoundModel.query() - .skipUndefined() - .orWhereJsonSupersetOf('jsonObject', undefined) - .then((results) => { - expect(results.length).to.equal(7); - }); - }); - }); + it('should not find results NOT(jsonObject.x @> jsonObject.y)', () => { + return BoundModel.query() + .whereJsonNotSupersetOf('jsonObject:x', 'jsonObject:y') + .then((results) => { + expectIdsEqual(results, []); + }); + }); - describe('.whereJsonSubsetOf(fieldExpr, )', () => { - it('should find all empty arrays with jsonArray <@ []', () => { - return BoundModel.query() - .whereJsonSubsetOf('jsonArray', []) - .then((results) => { - expectIdsEqual(results, [2, 6, 7]); - }); - }); + it.skipIf(!session.isPostgres())( + 'should not find results NOT(jsonObject.x @> jsonObject.y)', + () => { + return BoundModel.query() + .whereNot(ref('jsonObject:x').castJson(), '@>', ref('jsonObject:y').castJson()) + .then((results) => { + expectIdsEqual(results, []); + }); + }, + ); - it('should find results jsonArray <@ [1,2] (set is its own subset)', () => { - return BoundModel.query() - .whereJsonSubsetOf('jsonArray', [1, 2]) - .then((results) => { - expectIdsEqual(results, [2, 4, 6, 7]); - }); - }); + it.skipIf(!session.isPostgres())( + 'should find results jsonArray = {} or NOT(jsonObject @> jsonArray)', + () => { + return BoundModel.query() + .where('jsonArray', '=', val({})) + .orWhereNot('jsonObject', '@>', ref('jsonArray')) + .then((results) => { + expectIdsEqual(results, [1, 2, 4, 5, 6, 7]); + }); + }, + ); + }, + ); + + describe.skipIf(!hasJsonContainment)( + '.whereJsonSubsetOf(fieldExpr, )', + () => { + it('should find all empty arrays with jsonArray <@ []', () => { + return BoundModel.query() + .whereJsonSubsetOf('jsonArray', []) + .then((results) => { + expectIdsEqual(results, [2, 6, 7]); + }); + }); - it('should not find results jsonArray <@ {}', () => { - return BoundModel.query() - .whereJsonSubsetOf('jsonArray', {}) - .then((results) => { - expect(results).to.have.length(0); - }); - }); + it('should find results jsonArray <@ [1,2] (set is its own subset)', () => { + return BoundModel.query() + .whereJsonSubsetOf('jsonArray', [1, 2]) + .then((results) => { + expectIdsEqual(results, [2, 4, 6, 7]); + }); + }); - it('should not find results jsonArray <@ {}', () => { - return BoundModel.query() - .where('jsonArray', '<@', val({})) - .then((results) => { - expect(results).to.have.length(0); - }); - }); + it('should not find results jsonArray <@ {}', () => { + return BoundModel.query() + .whereJsonSubsetOf('jsonArray', {}) + .then((results) => { + expect(results).toHaveLength(0); + }); + }); - it('should not find results jsonObject <@ []', () => { - return BoundModel.query() - .whereJsonSubsetOf('jsonObject', []) - .then((results) => { - expect(results).to.have.length(0); - }); - }); + it.skipIf(!session.isPostgres())('should not find results jsonArray <@ {}', () => { + return BoundModel.query() + .where('jsonArray', '<@', val({})) + .then((results) => { + expect(results).toHaveLength(0); + }); + }); - it('should not find results jsonObject <@ []', () => { - return BoundModel.query() - .where('jsonObject', '<@', val([])) - .then((results) => { - expect(results).to.have.length(0); - }); - }); + it('should not find results jsonObject <@ []', () => { + return BoundModel.query() + .whereJsonSubsetOf('jsonObject', []) + .then((results) => { + expect(results).toHaveLength(0); + }); + }); - it('should find subset where both sides are references', () => { - return BoundModel.query() - .whereJsonSubsetOf('jsonObject:a', 'jsonObject:b') - .then((results) => { - expectIdsEqual(results, [7]); - }); - }); + it.skipIf(!session.isPostgres())('should not find results jsonObject <@ []', () => { + return BoundModel.query() + .where('jsonObject', '<@', val([])) + .then((results) => { + expect(results).toHaveLength(0); + }); + }); - it('should find subset where both sides are references', () => { - return BoundModel.query() - .where(ref('jsonObject:a').castJson(), '<@', ref('jsonObject:b').castJson()) - .then((results) => { - expectIdsEqual(results, [7]); - }); - }); + it('should find subset where both sides are references', () => { + return BoundModel.query() + .whereJsonSubsetOf('jsonObject:a', 'jsonObject:b') + .then((results) => { + expectIdsEqual(results, [7]); + }); + }); - it('should find results jsonObject <@ {}', () => { - return BoundModel.query() - .whereJsonSubsetOf('jsonObject', {}) - .then((results) => { - expectIdsEqual(results, [2, 4, 5]); - }); - }); + it.skipIf(!session.isPostgres())( + 'should find subset where both sides are references', + () => { + return BoundModel.query() + .where(ref('jsonObject:a').castJson(), '<@', ref('jsonObject:b').castJson()) + .then((results) => { + expectIdsEqual(results, [7]); + }); + }, + ); - it('should find results jsonObject <@ {}', () => { - return BoundModel.query() - .where('jsonObject', '<@', val({})) - .then((results) => { - expectIdsEqual(results, [2, 4, 5]); - }); - }); + it('should find results jsonObject <@ {}', () => { + return BoundModel.query() + .whereJsonSubsetOf('jsonObject', {}) + .then((results) => { + expectIdsEqual(results, [2, 4, 5]); + }); + }); - it('should find results jsonObject.objectField <@ complexJsonObj.jsonObject.objectField', () => { - return BoundModel.query() - .whereJsonSubsetOf('jsonObject:objectField', complexJsonObj.jsonObject.objectField) - .then((results) => { - expectIdsEqual(results, [1]); - }); - }); + it.skipIf(!session.isPostgres())('should find results jsonObject <@ {}', () => { + return BoundModel.query() + .where('jsonObject', '<@', val({})) + .then((results) => { + expectIdsEqual(results, [2, 4, 5]); + }); + }); - it('should find results jsonObject.objectField <@ complexJsonObj.jsonObject.objectField', () => { - return BoundModel.query() - .where(ref('jsonObject:objectField'), '<@', val(complexJsonObj.jsonObject.objectField)) - .then((results) => { - expectIdsEqual(results, [1]); - }); - }); + it('should find results jsonObject.objectField <@ complexJsonObj.jsonObject.objectField', () => { + return BoundModel.query() + .whereJsonSubsetOf('jsonObject:objectField', complexJsonObj.jsonObject.objectField) + .then((results) => { + expectIdsEqual(results, [1]); + }); + }); - it('should find results jsonObject.objectField <@ complexJsonObj.jsonObject.objectField that has additional key', () => { - let obj = cloneDeep(complexJsonObj.jsonObject.objectField); - obj.otherKey = 'Im here too!'; + it.skipIf(!session.isPostgres())( + 'should find results jsonObject.objectField <@ complexJsonObj.jsonObject.objectField', + () => { + return BoundModel.query() + .where( + ref('jsonObject:objectField'), + '<@', + val(complexJsonObj.jsonObject.objectField), + ) + .then((results) => { + expectIdsEqual(results, [1]); + }); + }, + ); - return BoundModel.query() - .whereJsonSubsetOf('jsonObject:objectField', obj) - .then((results) => { - expectIdsEqual(results, [1]); - }); - }); + it('should find results jsonObject.objectField <@ complexJsonObj.jsonObject.objectField that has additional key', () => { + let obj = cloneDeep(complexJsonObj.jsonObject.objectField); + obj.otherKey = 'Im here too!'; - it('should find results jsonObject.objectField <@ complexJsonObj.jsonObject.objectField that has additional key', () => { - let obj = cloneDeep(complexJsonObj.jsonObject.objectField); - obj.otherKey = 'Im here too!'; + return BoundModel.query() + .whereJsonSubsetOf('jsonObject:objectField', obj) + .then((results) => { + expectIdsEqual(results, [1]); + }); + }); - return BoundModel.query() - .where(ref('jsonObject:objectField'), '<@', val(obj)) - .then((results) => { - expectIdsEqual(results, [1]); - }); - }); + it.skipIf(!session.isPostgres())( + 'should find results jsonObject.objectField <@ complexJsonObj.jsonObject.objectField that has additional key', + () => { + let obj = cloneDeep(complexJsonObj.jsonObject.objectField); + obj.otherKey = 'Im here too!'; - it('should not find results jsonObject.objectField <@ { object: "other string" }', () => { - return BoundModel.query() - .whereJsonSubsetOf('jsonObject:objectField', { - object: 'something else', - }) - .then((results) => { - expect(results).to.have.length(0); - }); - }); + return BoundModel.query() + .where(ref('jsonObject:objectField'), '<@', val(obj)) + .then((results) => { + expectIdsEqual(results, [1]); + }); + }, + ); - it('should not find results jsonObject.objectField <@ { object: "other string" }', () => { - return BoundModel.query() - .where( - ref('jsonObject:objectField'), - '<@', - val({ + it('should not find results jsonObject.objectField <@ { object: "other string" }', () => { + return BoundModel.query() + .whereJsonSubsetOf('jsonObject:objectField', { object: 'something else', - }), - ) - .then((results) => { - expect(results).to.have.length(0); - }); - }); + }) + .then((results) => { + expect(results).toHaveLength(0); + }); + }); - it('should find results jsonObject <@ {} OR jsonArray <@ []', () => { - return BoundModel.query() - .whereJsonSubsetOf('jsonObject', {}) - .orWhereJsonSubsetOf('jsonArray', []) - .then((results) => { - expectIdsEqual(results, [2, 4, 5, 6, 7]); - }); - }); + it.skipIf(!session.isPostgres())( + 'should not find results jsonObject.objectField <@ { object: "other string" }', + () => { + return BoundModel.query() + .where( + ref('jsonObject:objectField'), + '<@', + val({ + object: 'something else', + }), + ) + .then((results) => { + expect(results).toHaveLength(0); + }); + }, + ); - it('should find results jsonObject <@ {} OR jsonArray <@ []', () => { - return BoundModel.query() - .where('jsonObject', '<@', val({})) - .orWhere('jsonArray', '<@', val([])) - .then((results) => { - expectIdsEqual(results, [2, 4, 5, 6, 7]); - }); - }); + it('should find results jsonObject <@ {} OR jsonArray <@ []', () => { + return BoundModel.query() + .whereJsonSubsetOf('jsonObject', {}) + .orWhereJsonSubsetOf('jsonArray', []) + .then((results) => { + expectIdsEqual(results, [2, 4, 5, 6, 7]); + }); + }); - it('should find results NOT(jsonObject.a <@ jsonObject.b)', () => { - return BoundModel.query() - .whereJsonNotSubsetOf('jsonObject:a', 'jsonObject:b') - .then((results) => { - expectIdsEqual(results, []); - }); - }); + it.skipIf(!session.isPostgres())( + 'should find results jsonObject <@ {} OR jsonArray <@ []', + () => { + return BoundModel.query() + .where('jsonObject', '<@', val({})) + .orWhere('jsonArray', '<@', val([])) + .then((results) => { + expectIdsEqual(results, [2, 4, 5, 6, 7]); + }); + }, + ); - it('should find results NOT(jsonObject.a <@ jsonObject.b)', () => { - return BoundModel.query() - .whereNot(ref('jsonObject:a').castJson(), '<@', ref('jsonObject:b').castJson()) - .then((results) => { - expectIdsEqual(results, []); - }); - }); + it('should find results NOT(jsonObject.a <@ jsonObject.b)', () => { + return BoundModel.query() + .whereJsonNotSubsetOf('jsonObject:a', 'jsonObject:b') + .then((results) => { + expectIdsEqual(results, []); + }); + }); - it('should find results jsonObject.a <@ jsonObject.b OR NOT(jsonObject.a <@ jsonObject.b)', () => { - return BoundModel.query() - .whereJsonSubsetOf('jsonObject:a', 'jsonObject:b') - .orWhereJsonNotSubsetOf('jsonObject:a', 'jsonObject:b') - .then((results) => { - expectIdsEqual(results, [7]); - }); - }); + it.skipIf(!session.isPostgres())( + 'should find results NOT(jsonObject.a <@ jsonObject.b)', + () => { + return BoundModel.query() + .whereNot(ref('jsonObject:a').castJson(), '<@', ref('jsonObject:b').castJson()) + .then((results) => { + expectIdsEqual(results, []); + }); + }, + ); - it('should find results jsonObject.a <@ jsonObject.b OR NOT(jsonObject.a <@ jsonObject.b)', () => { - return BoundModel.query() - .where(ref('jsonObject:a').castJson(), '<@', ref('jsonObject:b').castJson()) - .orWhereNot(ref('jsonObject:a').castJson(), '<@', ref('jsonObject:b').castJson()) - .then((results) => { - expectIdsEqual(results, [7]); - }); - }); - }); + it('should find results jsonObject.a <@ jsonObject.b OR NOT(jsonObject.a <@ jsonObject.b)', () => { + return BoundModel.query() + .whereJsonSubsetOf('jsonObject:a', 'jsonObject:b') + .orWhereJsonNotSubsetOf('jsonObject:a', 'jsonObject:b') + .then((results) => { + expectIdsEqual(results, [7]); + }); + }); + + it.skipIf(!session.isPostgres())( + 'should find results jsonObject.a <@ jsonObject.b OR NOT(jsonObject.a <@ jsonObject.b)', + () => { + return BoundModel.query() + .where(ref('jsonObject:a').castJson(), '<@', ref('jsonObject:b').castJson()) + .orWhereNot(ref('jsonObject:a').castJson(), '<@', ref('jsonObject:b').castJson()) + .then((results) => { + expectIdsEqual(results, [7]); + }); + }, + ); + }, + ); describe('.whereJsonIsArray(fieldExpr)', () => { it('should find all arrays that has an array in index 5', () => { @@ -1348,7 +1433,7 @@ module.exports = (session) => { return BoundModel.query() .whereJsonIsArray('jsonObject:objectField') .then((results) => { - expect(results).to.have.length(0); + expect(results).toHaveLength(0); }); }); @@ -1403,7 +1488,7 @@ module.exports = (session) => { return BoundModel.query() .whereJsonIsObject('jsonObject:arrayField') .then((results) => { - expect(results).to.have.length(0); + expect(results).toHaveLength(0); }); }); @@ -1411,7 +1496,7 @@ module.exports = (session) => { return BoundModel.query() .whereJsonIsObject('jsonObject:arrayField.imNot') .then((results) => { - expect(results).to.have.length(0); + expect(results).toHaveLength(0); }); }); @@ -1454,40 +1539,25 @@ module.exports = (session) => { }); describe('.whereJsonHasAny(fieldExpr, keys) and .whereJsonHasAll(fieldExpr, keys)', () => { - it('should throw error if null in input array', (done) => { - BoundModel.query() - .whereJsonHasAny('jsonObject', [null]) - .then(() => { - done(new Error('should not get here')); - }) - .catch((err) => { - done(); - }) - .catch(done); + it('should throw error if no keys are given', async () => { + await expect(BoundModel.query().whereJsonHasAny('jsonObject', [])).rejects.toThrow( + 'At least one key to find is required.', + ); + await expect(BoundModel.query().whereJsonHasAll('jsonObject', [])).rejects.toThrow( + 'At least one key to find is required.', + ); }); - it('should throw error if number in input array', (done) => { - BoundModel.query() - .whereJsonHasAny('jsonObject', 1) - .then(() => { - done(new Error('should not get here')); - }) - .catch((err) => { - done(); - }) - .catch(done); + it('should throw error if null in input array', () => { + return expect(BoundModel.query().whereJsonHasAny('jsonObject', [null])).rejects.toThrow(); }); - it('should throw error if boolean in input array', (done) => { - BoundModel.query() - .whereJsonHasAny('jsonObject', false) - .then(() => { - done(new Error('should not get here')); - }) - .catch((err) => { - done(); - }) - .catch(done); + it('should throw error if number in input array', () => { + return expect(BoundModel.query().whereJsonHasAny('jsonObject', 1)).rejects.toThrow(); + }); + + it('should throw error if boolean in input array', () => { + return expect(BoundModel.query().whereJsonHasAny('jsonObject', false)).rejects.toThrow(); }); it('should find results for a', () => { @@ -1498,7 +1568,7 @@ module.exports = (session) => { }); }); - it('should find results for a', () => { + it.skipIf(!session.isPostgres())('should find results for a', () => { // TODO knex doesn't support ?| operator. return BoundModel.query() .where(raw('?? \\?| ?', ['jsonObject', val('a').asArray()])) @@ -1540,7 +1610,7 @@ module.exports = (session) => { }); }); - it('should find results for a and b', () => { + it.skipIf(!session.isPostgres())('should find results for a and b', () => { // TODO knex doesn't support ?& operator. return BoundModel.query() .where(raw('?? \\?& ?', ['jsonObject', val(['a', 'b']).asArray()])) @@ -1571,14 +1641,43 @@ module.exports = (session) => { .range(0, 1) .whereJsonHasAny('jsonObject:b', '2') .then((result) => { - expect(result.results).to.have.length(1); - expect(result.total).to.equal(1); - expect(result.results[0].id).to.equal(7); + expect(result.results).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.results[0].id).toBe(7); }); }); }); describe('.where(fieldExpr, operator, value)', () => { + it('should cast json null to NULL', async () => { + const query = () => BoundModel.query().where('id', 1); + expectIdsEqual(await query().whereNull(ref('jsonObject:nullField').castText()), [1]); + expectIdsEqual(await query().whereNull(ref('jsonObject:nullField').castInt()), [1]); + expectIdsEqual(await query().whereNotNull(ref('jsonObject:numberField').castText()), [1]); + }); + + it('should cast json values to the types of the database', async () => { + const query = () => BoundModel.query().whereNotNull('jsonObject'); + expectIdsEqual( + await query().where(ref('jsonObject:numberField').castDecimal(), 1.5), + [1], + ); + expectIdsEqual(await query().where(ref('jsonObject:numberField').castReal(), 1.5), [1]); + expectIdsEqual(await query().where(ref('jsonObject:numberField').castText(), '1.5'), [1]); + expectIdsEqual( + await query().where(ref('jsonObject:booleanField').castBool(), false), + [1], + ); + expectIdsEqual( + await query().where(ref('jsonObject:numberField').castJson(), val(1.5).castJson()), + [1], + ); + expectIdsEqual( + await query().where(ref('name').castText().castJson(), val('complex line').castJson()), + [1], + ); + }); + it('should be able to find numbers with >', () => { return BoundModel.query() .where(ref('jsonObject:numberField'), '>', 1.4) diff --git a/tests/integration/jsonQueriesMySql.js b/tests/integration/jsonQueriesMySql.js deleted file mode 100644 index 656b8753c..000000000 --- a/tests/integration/jsonQueriesMySql.js +++ /dev/null @@ -1,61 +0,0 @@ -const expect = require('expect.js'); -const { Model } = require('../../'); - -module.exports = (session) => { - describe('JSON queries on MySQL (ditojs#113)', () => { - class ModelJson extends Model { - static get tableName() { - return 'ModelJson'; - } - } - - let BoundModel; - - before(async () => { - BoundModel = ModelJson.bindKnex(session.knex); - - await session.knex.schema.dropTableIfExists('ModelJson'); - await session.knex.schema.createTable('ModelJson', (table) => { - table.integer('id').primary(); - table.json('data'); - }); - - await session.knex('ModelJson').insert([ - { id: 1, data: JSON.stringify({ a: 1, b: [1, 2] }) }, - { id: 2, data: JSON.stringify({ a: 2, b: [2, 3] }) }, - { id: 3, data: JSON.stringify({ a: 1 }) }, - ]); - }); - - after(() => { - return session.knex.schema.dropTableIfExists('ModelJson'); - }); - - const ids = (query) => query.orderBy('id').then((rows) => rows.map((it) => it.id)); - - it('whereJsonSupersetOf() should use knex', async () => { - expect(await ids(BoundModel.query().whereJsonSupersetOf('data', { a: 1 }))).to.eql([1, 3]); - expect(await ids(BoundModel.query().whereJsonNotSupersetOf('data', { a: 1 }))).to.eql([2]); - expect( - await ids( - BoundModel.query() - .whereJsonSupersetOf('data', { a: 2 }) - .orWhereJsonSupersetOf('ModelJson.data', { b: [1] }), - ), - ).to.eql([1, 2]); - }); - - it('whereJsonSubsetOf() should use knex', async () => { - expect( - await ids(BoundModel.query().whereJsonSubsetOf('data', { a: 1, b: [1, 2], c: 3 })), - ).to.eql([1, 3]); - expect( - await ids( - BoundModel.query() - .where('id', 3) - .orWhereJsonNotSubsetOf('data', { a: 1, b: [1, 2] }), - ), - ).to.eql([2, 3]); - }); - }); -}; diff --git a/tests/integration/jsonRelations.js b/tests/integration/jsonRelations.js index d4ca1eba0..3768140bd 100644 --- a/tests/integration/jsonRelations.js +++ b/tests/integration/jsonRelations.js @@ -1,10 +1,15 @@ -const { Model, ref } = require('../../'); -const expect = require('expect.js'); -const { sortBy } = require('../../testUtils/testUtils'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model, ref } from 'objection'; +import { sortBy } from '../../testUtils/testUtils.js'; -module.exports = (session) => { +export default (session) => { describe('JSON relations', () => { class BaseModel extends Model { + // MySQL and SQLite return json columns as text. + static get jsonAttributes() { + return ['json']; + } + static get modifiers() { return ['name', 'id', 'json'].reduce((obj, prop) => { obj[prop] = (qb) => qb.select(prop); @@ -79,13 +84,13 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { Person.knex(session.knex); Animal.knex(session.knex); Movie.knex(session.knex); }); - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('PersonMovie') .dropTableIfExists('Movie') @@ -111,7 +116,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema .dropTableIfExists('PersonMovie') .dropTableIfExists('Movie') @@ -180,7 +185,7 @@ module.exports = (session) => { ) .then(sortRelations) .then((person) => { - expect(person).to.eql({ + expect(person).toEqual({ name: 'Arnold', favoritePet: { @@ -224,7 +229,7 @@ module.exports = (session) => { ) .then(sortRelations) .then((person) => { - expect(person).to.eql({ + expect(person).toEqual({ name: 'Arnold', favoritePet: { @@ -272,7 +277,7 @@ module.exports = (session) => { name: animal.name, people: animal.peopleWhoseFavoriteIAm.map((person) => person.name), })), - ).to.eql([ + ).toEqual([ { name: 'Cato', people: ['Brad'] }, { name: 'Fluffy', people: ['Arnold'] }, ]); @@ -287,7 +292,7 @@ module.exports = (session) => { .then((it) => it.$relatedQuery('favoritePet').insert({ name: 'Doggo' })) .then(() => Person.query().findOne({ name: 'Arnold' }).withGraphFetched('favoritePet')) .then((person) => { - expect(person.json.stuff.favoritePetId).to.equal(person.favoritePet.id); + expect(person.json.stuff.favoritePetId).toBe(person.favoritePet.id); }); }); @@ -296,7 +301,7 @@ module.exports = (session) => { .findOne({ name: 'Arnold' }) .then((it) => it.$relatedQuery('favoritePet').select('name')) .then((pet) => { - expect(pet).to.eql({ + expect(pet).toEqual({ name: 'Fluffy', }); }); @@ -308,7 +313,7 @@ module.exports = (session) => { .then((it) => it.$relatedQuery('favoritePet').patch({ json: { updated: true } })) .then(() => Animal.query().select('json', 'name').orderBy('name')) .then((pets) => { - expect(pets).to.eql([ + expect(pets).toEqual([ { json: null, name: 'Cato', @@ -332,8 +337,8 @@ module.exports = (session) => { const brad = people.find((it) => it.name === 'Brad'); const ardnold = people.find((it) => it.name === 'Arnold'); - expect(ardnold.json.stuff.favoritePetId).to.equal(123); - expect(brad.json.stuff.favoritePetId).to.equal(brad.favoritePet.id); + expect(ardnold.json.stuff.favoritePetId).toBe(123); + expect(brad.json.stuff.favoritePetId).toBe(brad.favoritePet.id); }); }); @@ -346,10 +351,24 @@ module.exports = (session) => { const brad = people.find((it) => it.name === 'Brad'); const ardnold = people.find((it) => it.name === 'Arnold'); - expect(ardnold.json.stuff.favoritePetId).to.equal(null); - expect(brad.json.stuff.favoritePetId).to.equal(brad.favoritePet.id); + expect(ardnold.json.stuff.favoritePetId).toBeNull(); + expect(brad.json.stuff.favoritePetId).toBe(brad.favoritePet.id); }); }); + + it('unrelate should only clear the keys of the owner models that it unrelates', async () => { + const [arnold, brad] = await Person.query().orderBy('name'); + const bradPetId = brad.json.stuff.favoritePetId; + + expect( + await Person.relatedQuery('favoritePet') + .for([arnold, brad]) + .unrelate() + .where('name', 'Fluffy'), + ).toBe(1); + expect(arnold.json.stuff.favoritePetId).toBeNull(); + expect(brad.json.stuff.favoritePetId).toBe(bradPetId); + }); }); describe('has many relation', () => { @@ -365,7 +384,7 @@ module.exports = (session) => { ) .then(sortRelations) .then((pet) => { - expect(pet).to.eql({ + expect(pet).toEqual({ name: 'Fluffy', peopleWhoseFavoriteIAm: [ @@ -385,7 +404,7 @@ module.exports = (session) => { .findOne({ name: 'Fluffy' }) .then((it) => it.$relatedQuery('peopleWhoseFavoriteIAm').select('name')) .then((pet) => { - expect(pet).to.eql([ + expect(pet).toEqual([ { name: 'Arnold', }, @@ -401,7 +420,7 @@ module.exports = (session) => { ) .then(() => Person.query().select('name').orderBy('name')) .then((pet) => { - expect(pet).to.eql([ + expect(pet).toEqual([ { name: 'Arnold the second', }, @@ -424,7 +443,7 @@ module.exports = (session) => { ) .then(sortRelations) .then((pet) => { - expect(pet.peopleWhoseFavoriteIAm).to.eql([ + expect(pet.peopleWhoseFavoriteIAm).toEqual([ { name: 'Arnold', }, @@ -455,7 +474,7 @@ module.exports = (session) => { ) .then(sortRelations) .then((pet) => { - expect(pet.peopleWhoseFavoriteIAm).to.eql([ + expect(pet.peopleWhoseFavoriteIAm).toEqual([ { name: 'Brad', }, @@ -464,7 +483,7 @@ module.exports = (session) => { return Person.query().findOne({ name: 'Arnold' }).select('json'); }) .then((arnold) => { - expect(arnold.json.stuff.favoritePetId).to.equal(null); + expect(arnold.json.stuff.favoritePetId).toBeNull(); }); }); }); @@ -482,7 +501,7 @@ module.exports = (session) => { ) .then(sortRelations) .then((pet) => { - expect(pet).to.eql({ + expect(pet).toEqual({ name: 'Brad', movies: [ @@ -502,7 +521,7 @@ module.exports = (session) => { .findOne({ name: 'Arnold' }) .then((it) => it.$relatedQuery('movies').select('name').orderBy('name')) .then((movies) => { - expect(movies).to.eql([ + expect(movies).toEqual([ { name: 'Terminator', }, @@ -519,7 +538,7 @@ module.exports = (session) => { .then((it) => it.$relatedQuery('movies').patch({ name: 'Some terminator' })) .then(() => Movie.query().select('name').orderBy('name')) .then((movies) => { - expect(movies).to.eql([ + expect(movies).toEqual([ { name: 'Inglorious bastards', }, @@ -546,7 +565,7 @@ module.exports = (session) => { ) .then(sortRelations) .then((person) => { - expect(person.movies).to.eql([ + expect(person.movies).toEqual([ { name: 'Inglorious bastards', }, @@ -579,7 +598,7 @@ module.exports = (session) => { ) .then(sortRelations) .then((person) => { - expect(person.movies).to.eql([ + expect(person.movies).toEqual([ { name: 'Inglorious bastards', }, diff --git a/tests/integration/knexIdentifierMapping.js b/tests/integration/knexIdentifierMapping.js index b45e03960..a6e115b79 100644 --- a/tests/integration/knexIdentifierMapping.js +++ b/tests/integration/knexIdentifierMapping.js @@ -1,8 +1,8 @@ -const Knex = require('knex'); -const { expect } = require('chai'); -const { Model, knexIdentifierMapping } = require('../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import Knex from 'knex'; +import { Model, knexIdentifierMapping } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('knexIdentifierMapping', () => { let knex; @@ -59,7 +59,7 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { // Create schema with the knex instance that doesn't // have identifier mapping configured. return session.knex.schema @@ -87,7 +87,7 @@ module.exports = (session) => { }); }); - before(() => { + beforeAll(() => { const config = Object.assign( {}, session.opt.knexConfig, @@ -110,7 +110,7 @@ module.exports = (session) => { describe('schema', () => { const table = 'snakeCaseTestTable'; - before(() => { + beforeAll(() => { return knex.schema.dropTableIfExists(table); }); @@ -118,7 +118,7 @@ module.exports = (session) => { return knex.schema.dropTableIfExists(table); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists(table); }); @@ -135,13 +135,13 @@ module.exports = (session) => { return knex(table); }) .then((rows) => { - expect(rows).to.eql([{ id: 1, fName: 'fooBar' }]); + expect(rows).toEqual([{ id: 1, fName: 'fooBar' }]); // Query with a knex without case mapping. return session.knex('snake_case_test_table'); }) .then((rows) => { - expect(rows).to.eql([{ id: 1, first_name: 'fooBar' }]); + expect(rows).toEqual([{ id: 1, first_name: 'fooBar' }]); }); }); @@ -160,13 +160,13 @@ module.exports = (session) => { }) .hasTable(table) .then((hasTable) => { - expect(!!hasTable).to.equal(true); + expect(!!hasTable).toBe(true); }); }); it('hasTable (false)', () => { return knex.schema.hasTable(table).then((hasTable) => { - expect(hasTable).to.equal(false); + expect(hasTable).toBe(false); }); }); }); @@ -217,7 +217,7 @@ module.exports = (session) => { return model.$relatedQuery('pets', knex).orderBy('animalName'); }) .then((pets) => { - expect(pets).to.containSubset([ + expect(pets).toContainSubset([ { animalName: 'Hurtta', }, @@ -237,8 +237,8 @@ module.exports = (session) => { [method]('[parent.parent, pets, movies]') .orderBy('person.fName') .then((people) => { - expect(people.length).to.equal(3); - expect(people).to.containSubset([ + expect(people.length).toBe(3); + expect(people).toContainSubset([ { rootFirstName: 'Seppo', @@ -284,7 +284,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema .dropTableIfExists('person_movie') .dropTableIfExists('animal') @@ -292,7 +292,7 @@ module.exports = (session) => { .dropTableIfExists('person'); }); - after(() => { + afterAll(() => { return knex.destroy(); }); }); diff --git a/tests/integration/knexSnakeCase.js b/tests/integration/knexSnakeCase.js index 74ca699e4..7164c11be 100644 --- a/tests/integration/knexSnakeCase.js +++ b/tests/integration/knexSnakeCase.js @@ -1,8 +1,8 @@ -const Knex = require('knex'); -const { Model, knexSnakeCaseMappers } = require('../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import Knex from 'knex'; +import { Model, knexSnakeCaseMappers } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('knexSnakeCaseMappers', () => { let knex; @@ -63,7 +63,7 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { // Create schema with the knex instance that doesn't // have identifier mapping configured. return session.knex.schema @@ -95,7 +95,7 @@ module.exports = (session) => { }); }); - before(() => { + beforeAll(() => { const config = Object.assign({}, session.opt.knexConfig, knexSnakeCaseMappers()); knex = Knex(config); }); @@ -103,7 +103,7 @@ module.exports = (session) => { describe('schema', () => { const table = 'snakeCaseTestTable'; - before(() => { + beforeAll(() => { return knex.schema.dropTableIfExists(table); }); @@ -111,7 +111,7 @@ module.exports = (session) => { return knex.schema.dropTableIfExists(table); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists(table); }); @@ -128,13 +128,13 @@ module.exports = (session) => { return knex(table); }) .then((rows) => { - expect(rows).to.eql([{ id: 1, firstName: 'fooBar' }]); + expect(rows).toEqual([{ id: 1, firstName: 'fooBar' }]); // Query with a knex without case mapping. return session.knex('snake_case_test_table'); }) .then((rows) => { - expect(rows).to.eql([{ id: 1, first_name: 'fooBar' }]); + expect(rows).toEqual([{ id: 1, first_name: 'fooBar' }]); }); }); @@ -168,13 +168,13 @@ module.exports = (session) => { }) .hasTable(table) .then((hasTable) => { - expect(!!hasTable).to.equal(true); + expect(!!hasTable).toBe(true); }); }); it('hasTable (false)', () => { return knex.schema.hasTable(table).then((hasTable) => { - expect(hasTable).to.equal(false); + expect(hasTable).toBe(false); }); }); @@ -252,7 +252,7 @@ module.exports = (session) => { .insert({ firstName: 'Arto' }) .returning('*') .then((res) => { - expect(res).to.containSubset({ firstName: 'Arto', parentId: null }); + expect(res).toMatchObject({ firstName: 'Arto', parentId: null }); }); }); } @@ -262,7 +262,7 @@ module.exports = (session) => { .joinRelated('parentPerson.parentPerson') .select('parentPerson:parentPerson.firstName as nestedRef') .then((result) => { - expect(result).to.containSubset([{ nestedRef: 'Matti' }]); + expect(result).toContainSubset([{ nestedRef: 'Matti' }]); }); }); @@ -275,7 +275,7 @@ module.exports = (session) => { }) .returning('*') .then((result) => { - expect(result).to.containSubset([ + expect(result).toContainSubset([ { firstName: 'Matti', parentId: null, @@ -298,24 +298,24 @@ module.exports = (session) => { expect( firstNames(await Person.query(knex).whereJsonSupersetOf(fieldExpression, coordinates)), - ).to.eql(['Matti']); + ).toEqual(['Matti']); expect( firstNames(await Person.query(knex).whereJsonSubsetOf('person.personAddress', {})), - ).to.eql([]); + ).toEqual([]); expect( firstNames( await Person.query(knex).whereJsonSupersetOf('personAddress', 'person.personAddress'), ), - ).to.eql(['Matti']); + ).toEqual(['Matti']); expect( firstNames( await Person.query(knex).whereJsonHasAny(fieldExpression, 'latitudeCoordinate'), ), - ).to.eql(['Matti']); - expect(firstNames(await Person.query(knex).whereJsonIsObject(fieldExpression))).to.eql([ + ).toEqual(['Matti']); + expect(firstNames(await Person.query(knex).whereJsonIsObject(fieldExpression))).toEqual([ 'Matti', ]); - expect(firstNames(await Person.query(knex).whereJsonNotObject(fieldExpression))).to.eql([ + expect(firstNames(await Person.query(knex).whereJsonNotObject(fieldExpression))).toEqual([ 'Seppo', 'Teppo', ]); @@ -329,7 +329,7 @@ module.exports = (session) => { return model.$relatedQuery('pets', knex).orderBy('animalName'); }) .then((pets) => { - expect(pets).to.containSubset([ + expect(pets).toContainSubset([ { animalName: 'Hurtta', }, @@ -351,8 +351,8 @@ module.exports = (session) => { [method]('[parentPerson.parentPerson, pets, movies]') .orderBy('person.firstName') .then((people) => { - expect(people.length).to.equal(3); - expect(people).to.containSubset([ + expect(people.length).toBe(3); + expect(people).toContainSubset([ { rootFirstName: 'Seppo', @@ -398,7 +398,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema .dropTableIfExists('person_movie') .dropTableIfExists('animal') @@ -406,7 +406,7 @@ module.exports = (session) => { .dropTableIfExists('person'); }); - after(() => { + afterAll(() => { return knex.destroy(); }); }); @@ -467,7 +467,7 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { // Create schema with the knex instance that doesn't // have identifier mapping configured. return session.knex.schema @@ -495,7 +495,7 @@ module.exports = (session) => { }); }); - before(() => { + beforeAll(() => { const config = Object.assign( {}, session.opt.knexConfig, @@ -507,7 +507,7 @@ module.exports = (session) => { describe('schema', () => { const table = 'snakeCaseTestTable'; - before(() => { + beforeAll(() => { return knex.schema.dropTableIfExists(table); }); @@ -515,7 +515,7 @@ module.exports = (session) => { return knex.schema.dropTableIfExists(table); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists(table); }); @@ -532,13 +532,13 @@ module.exports = (session) => { return knex(table); }) .then((rows) => { - expect(rows).to.eql([{ id: 1, firstName: 'fooBar' }]); + expect(rows).toEqual([{ id: 1, firstName: 'fooBar' }]); // Query with a knex without case mapping. return session.knex('SNAKE_CASE_TEST_TABLE'); }) .then((rows) => { - expect(rows).to.eql([{ ID: 1, FIRST_NAME: 'fooBar' }]); + expect(rows).toEqual([{ ID: 1, FIRST_NAME: 'fooBar' }]); }); }); @@ -572,13 +572,13 @@ module.exports = (session) => { }) .hasTable(table) .then((hasTable) => { - expect(!!hasTable).to.equal(true); + expect(!!hasTable).toBe(true); }); }); it('hasTable (false)', () => { return knex.schema.hasTable(table).then((hasTable) => { - expect(hasTable).to.equal(false); + expect(hasTable).toBe(false); }); }); }); @@ -629,7 +629,7 @@ module.exports = (session) => { return model.$relatedQuery('pets', knex).orderBy('animalName'); }) .then((pets) => { - expect(pets).to.containSubset([ + expect(pets).toContainSubset([ { animalName: 'Hurtta', }, @@ -651,8 +651,8 @@ module.exports = (session) => { [method]('[parentPerson.parentPerson, pets, movies]') .orderBy('person.firstName') .then((people) => { - expect(people.length).to.equal(3); - expect(people).to.containSubset([ + expect(people.length).toBe(3); + expect(people).toContainSubset([ { rootFirstName: 'Seppo', @@ -698,7 +698,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema .dropTableIfExists('person_movie') .dropTableIfExists('animal') @@ -706,7 +706,7 @@ module.exports = (session) => { .dropTableIfExists('person'); }); - after(() => { + afterAll(() => { return knex.destroy(); }); }); diff --git a/tests/integration/manyToManyModify.js b/tests/integration/manyToManyModify.js index 30ba582ea..8b298bebb 100644 --- a/tests/integration/manyToManyModify.js +++ b/tests/integration/manyToManyModify.js @@ -1,8 +1,7 @@ -const expect = require('expect.js'); -const { Model } = require('../../'); -const { resetWarnings } = require('../../lib/utils/warnings'); +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { const { Model1, Model2 } = session.models; describe('many to many relation modify queries', () => { @@ -76,9 +75,9 @@ module.exports = (session) => { const model = await owner(1); const numDeleted = await model.$relatedQuery('model1Relation3').unrelate(); - expect(numDeleted).to.equal(4); - expect(await joinRows()).to.eql(withoutRows(0, 1, 2, 3)); - expect(await model2Rows()).to.have.length(3); + expect(numDeleted).toBe(4); + expect(await joinRows()).toEqual(withoutRows(0, 1, 2, 3)); + expect(await model2Rows()).toHaveLength(3); }); it('should unrelate rows matching a filter on the related table', async () => { @@ -88,8 +87,8 @@ module.exports = (session) => { .unrelate() .where('model2.model2_prop1', 'b'); - expect(numDeleted).to.equal(1); - expect(await joinRows()).to.eql(withoutRows(2)); + expect(numDeleted).toBe(1); + expect(await joinRows()).toEqual(withoutRows(2)); }); it('should unrelate all join rows of a related row matching a filter', async () => { @@ -99,8 +98,8 @@ module.exports = (session) => { .unrelate() .where('model2.id_col', 1); - expect(numDeleted).to.equal(2); - expect(await joinRows()).to.eql(withoutRows(0, 1)); + expect(numDeleted).toBe(2); + expect(await joinRows()).toEqual(withoutRows(0, 1)); }); it('should unrelate rows using findByIds', async () => { @@ -110,8 +109,8 @@ module.exports = (session) => { .unrelate() .findByIds([1, 3]); - expect(numDeleted).to.equal(3); - expect(await joinRows()).to.eql(withoutRows(0, 1, 3)); + expect(numDeleted).toBe(3); + expect(await joinRows()).toEqual(withoutRows(0, 1, 3)); }); it('should return 0 if no rows match', async () => { @@ -121,8 +120,8 @@ module.exports = (session) => { .unrelate() .where('model2.model2_prop1', 'does not exist'); - expect(numDeleted).to.equal(0); - expect(await joinRows()).to.eql(allJoinRows); + expect(numDeleted).toBe(0); + expect(await joinRows()).toEqual(allJoinRows); }); it('should unrelate for multiple owners', async () => { @@ -131,8 +130,8 @@ module.exports = (session) => { .unrelate() .where('model2.id_col', 2); - expect(numDeleted).to.equal(2); - expect(await joinRows()).to.eql(withoutRows(2, 4)); + expect(numDeleted).toBe(2); + expect(await joinRows()).toEqual(withoutRows(2, 4)); }); describe('filters on the join table (#1853)', () => { @@ -144,11 +143,11 @@ module.exports = (session) => { .where('Model1Model2.extra1', 'x1'); if (byKeys) { - expect(numDeleted).to.equal(2); - expect(await joinRows()).to.eql(withoutRows(0, 1)); + expect(numDeleted).toBe(2); + expect(await joinRows()).toEqual(withoutRows(0, 1)); } else { - expect(numDeleted).to.equal(1); - expect(await joinRows()).to.eql(withoutRows(0)); + expect(numDeleted).toBe(1); + expect(await joinRows()).toEqual(withoutRows(0)); } }); @@ -159,11 +158,11 @@ module.exports = (session) => { .where('Model1Model2.extra1', 'x1'); if (byKeys) { - expect(numDeleted).to.equal(3); - expect(await joinRows()).to.eql(withoutRows(0, 1, 5)); + expect(numDeleted).toBe(3); + expect(await joinRows()).toEqual(withoutRows(0, 1, 5)); } else { - expect(numDeleted).to.equal(2); - expect(await joinRows()).to.eql(withoutRows(0, 5)); + expect(numDeleted).toBe(2); + expect(await joinRows()).toEqual(withoutRows(0, 5)); } }); @@ -176,11 +175,11 @@ module.exports = (session) => { .where('Model1Model2.extra1', 'dup'); if (byKeys) { - expect(numDeleted).to.equal(2); - expect(await joinRows()).to.eql(withoutRows(0, 1)); + expect(numDeleted).toBe(2); + expect(await joinRows()).toEqual(withoutRows(0, 1)); } else { - expect(numDeleted).to.equal(1); - expect(await joinRows()).to.eql(withoutRows(1)); + expect(numDeleted).toBe(1); + expect(await joinRows()).toEqual(withoutRows(1)); } }); @@ -193,11 +192,11 @@ module.exports = (session) => { .orWhere('model2.model2_prop1', 'c'); if (byKeys) { - expect(numDeleted).to.equal(3); - expect(await joinRows()).to.eql(withoutRows(0, 1, 3)); + expect(numDeleted).toBe(3); + expect(await joinRows()).toEqual(withoutRows(0, 1, 3)); } else { - expect(numDeleted).to.equal(2); - expect(await joinRows()).to.eql(withoutRows(1, 3)); + expect(numDeleted).toBe(2); + expect(await joinRows()).toEqual(withoutRows(1, 3)); } }); @@ -208,19 +207,11 @@ module.exports = (session) => { .where('Model1Model2.extra1', 'x2') .where('model2.id_col', 2); - expect(numDeleted).to.equal(2); - expect(await joinRows()).to.eql(withoutRows(2, 4)); + expect(numDeleted).toBe(2); + expect(await joinRows()).toEqual(withoutRows(2, 4)); }); - it('should apply a filter on an aliased join table', async function () { - // MySQL supports aliases in deletes since 8.0.16. - if (session.isMySql()) { - const [[{ version }]] = await session.knex.raw('select version() as version'); - if (version.startsWith('5.')) { - this.skip(); - } - } - + it('should apply a filter on an aliased join table', async () => { const model = await owner(1); const numDeleted = await model .$relatedQuery('model1Relation3') @@ -231,11 +222,11 @@ module.exports = (session) => { .where('m2.id_col', 1); if (byKeys) { - expect(numDeleted).to.equal(2); - expect(await joinRows()).to.eql(withoutRows(0, 1)); + expect(numDeleted).toBe(2); + expect(await joinRows()).toEqual(withoutRows(0, 1)); } else { - expect(numDeleted).to.equal(1); - expect(await joinRows()).to.eql(withoutRows(1)); + expect(numDeleted).toBe(1); + expect(await joinRows()).toEqual(withoutRows(1)); } }); }); @@ -264,8 +255,8 @@ module.exports = (session) => { const model = await owner(1); const numDeleted = await model.$relatedQuery('model1Relation3').modify(filter).unrelate(); - expect(numDeleted).to.equal(2); - expect(await joinRows()).to.eql(withoutRows(2, 3)); + expect(numDeleted).toBe(2); + expect(await joinRows()).toEqual(withoutRows(2, 3)); }); it(`should patch the join table extras with ${name}`, async () => { @@ -275,7 +266,7 @@ module.exports = (session) => { .modify(filter) .patch({ model2Prop2: 10, extra2: 'z' }); - expect(await model2Rows()).to.eql([ + expect(await model2Rows()).toEqual([ [1, 1], [2, 10], [3, 10], @@ -284,7 +275,7 @@ module.exports = (session) => { const expected = allJoinRows.slice(); expected[2] = [1, 2, 'x2', 'z']; expected[3] = [1, 3, 'x3', 'z']; - expect(await joinRows()).to.eql(expected); + expect(await joinRows()).toEqual(expected); }); } }); @@ -297,13 +288,13 @@ module.exports = (session) => { .patch({ model2Prop2: 10 }) .where('model2.id_col', 1); - expect(numUpdated).to.equal(1); - expect(await model2Rows()).to.eql([ + expect(numUpdated).toBe(1); + expect(await model2Rows()).toEqual([ [1, 10], [2, 2], [3, 3], ]); - expect(await joinRows()).to.eql(allJoinRows); + expect(await joinRows()).toEqual(allJoinRows); }); it('should patch related rows and join table extras matching a filter', async () => { @@ -313,8 +304,8 @@ module.exports = (session) => { .patch({ model2Prop2: 10, extra2: 'z' }) .where('model2.model2_prop1', 'b'); - expect(numUpdated).to.equal(1); - expect(await model2Rows()).to.eql([ + expect(numUpdated).toBe(1); + expect(await model2Rows()).toEqual([ [1, 1], [2, 10], [3, 3], @@ -322,7 +313,7 @@ module.exports = (session) => { const expected = allJoinRows.slice(); expected[2] = [1, 2, 'x2', 'z']; - expect(await joinRows()).to.eql(expected); + expect(await joinRows()).toEqual(expected); }); it('should patch nothing if no rows match', async () => { @@ -332,13 +323,13 @@ module.exports = (session) => { .patch({ model2Prop2: 10, extra2: 'z' }) .where('model2.model2_prop1', 'does not exist'); - expect(numUpdated).to.equal(0); - expect(await model2Rows()).to.eql([ + expect(numUpdated).toBe(0); + expect(await model2Rows()).toEqual([ [1, 1], [2, 2], [3, 3], ]); - expect(await joinRows()).to.eql(allJoinRows); + expect(await joinRows()).toEqual(allJoinRows); }); it('should patch nothing if the related rows to patch are fetched first and none match (#161)', async () => { @@ -348,13 +339,13 @@ module.exports = (session) => { .patch({ model2Prop2: 10, extra2: 'z' }) .where('model2.model2_prop2', 999); - expect(numUpdated).to.equal(0); - expect(await model2Rows()).to.eql([ + expect(numUpdated).toBe(0); + expect(await model2Rows()).toEqual([ [1, 1], [2, 2], [3, 3], ]); - expect(await joinRows()).to.eql(allJoinRows); + expect(await joinRows()).toEqual(allJoinRows); }); it('should patch nothing with none()', async () => { @@ -364,13 +355,13 @@ module.exports = (session) => { .patch({ model2Prop2: 10, extra2: 'z' }) .none(); - expect(numUpdated).to.equal(0); - expect(await model2Rows()).to.eql([ + expect(numUpdated).toBe(0); + expect(await model2Rows()).toEqual([ [1, 1], [2, 2], [3, 3], ]); - expect(await joinRows()).to.eql(allJoinRows); + expect(await joinRows()).toEqual(allJoinRows); }); it('should patch nothing if resolved in a hook', async () => { @@ -382,13 +373,13 @@ module.exports = (session) => { builder.resolve(0); }); - expect(numUpdated).to.equal(0); - expect(await model2Rows()).to.eql([ + expect(numUpdated).toBe(0); + expect(await model2Rows()).toEqual([ [1, 1], [2, 2], [3, 3], ]); - expect(await joinRows()).to.eql(allJoinRows); + expect(await joinRows()).toEqual(allJoinRows); }); it('should patch nothing if resolved in a hook with only join table extras', async () => { @@ -400,8 +391,8 @@ module.exports = (session) => { builder.resolve(0); }); - expect(numUpdated).to.equal(0); - expect(await joinRows()).to.eql(allJoinRows); + expect(numUpdated).toBe(0); + expect(await joinRows()).toEqual(allJoinRows); }); it('should count the related rows of the join rows that a patch with only join table extras changes', async () => { @@ -412,13 +403,13 @@ module.exports = (session) => { .patch({ extra2: 'z' }) .where('model2.id_col', '<', 3); - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); const expected = allJoinRows.slice(); expected[0] = [1, 1, 'x1', 'z']; expected[1] = [1, 1, 'dup', 'z']; expected[2] = [1, 2, 'x2', 'z']; - expect(await joinRows()).to.eql(expected); + expect(await joinRows()).toEqual(expected); }); it('should return the related row that patchAndFetchById() patches with only join table extras', async () => { @@ -427,13 +418,13 @@ module.exports = (session) => { .$relatedQuery('model1Relation3') .patchAndFetchById(1, { extra2: 'z' }); - expect(patched.idCol).to.equal(1); - expect(patched.extra2).to.equal('z'); + expect(patched.idCol).toBe(1); + expect(patched.extra2).toBe('z'); const expected = allJoinRows.slice(); expected[0] = [1, 1, 'x1', 'z']; expected[1] = [1, 1, 'dup', 'z']; - expect(await joinRows()).to.eql(expected); + expect(await joinRows()).toEqual(expected); }); it('should only patch the join table extras of join rows matching a filter on the join table (#1853)', async () => { @@ -443,7 +434,7 @@ module.exports = (session) => { .patch({ model2Prop2: 10, extra2: 'z' }) .where('Model1Model2.extra1', 'x1'); - expect(await model2Rows()).to.eql([ + expect(await model2Rows()).toEqual([ [1, 10], [2, 2], [3, 3], @@ -454,7 +445,7 @@ module.exports = (session) => { if (byKeys) { expected[1] = [1, 1, 'dup', 'z']; } - expect(await joinRows()).to.eql(expected); + expect(await joinRows()).toEqual(expected); }); it('should patch the join table extras of rows matching a filter on a related column that the patch changes (#161)', async () => { @@ -464,8 +455,8 @@ module.exports = (session) => { .patch({ model2Prop2: 10, extra2: 'z' }) .where('model2.model2_prop2', 2); - expect(numUpdated).to.equal(1); - expect(await model2Rows()).to.eql([ + expect(numUpdated).toBe(1); + expect(await model2Rows()).toEqual([ [1, 1], [2, 10], [3, 3], @@ -473,7 +464,7 @@ module.exports = (session) => { const expected = allJoinRows.slice(); expected[2] = [1, 2, 'x2', 'z']; - expect(await joinRows()).to.eql(expected); + expect(await joinRows()).toEqual(expected); }); it('should patch the related rows matching a filter on an extra that the patch changes (#161)', async () => { @@ -483,8 +474,8 @@ module.exports = (session) => { .patch({ model2Prop2: 10, extra1: 'changed' }) .where('Model1Model2.extra1', 'x2'); - expect(numUpdated).to.equal(1); - expect(await model2Rows()).to.eql([ + expect(numUpdated).toBe(1); + expect(await model2Rows()).toEqual([ [1, 1], [2, 10], [3, 3], @@ -492,7 +483,7 @@ module.exports = (session) => { const expected = allJoinRows.slice(); expected[2] = [1, 2, 'changed', 'y']; - expect(await joinRows()).to.eql(expected); + expect(await joinRows()).toEqual(expected); }); it('should patch the join table extras of join rows matching an orWhere on the join table and the related table (#2191)', async () => { @@ -509,64 +500,62 @@ module.exports = (session) => { if (byKeys) { expected[0] = [1, 1, 'x1', 'z']; } - expect(await joinRows()).to.eql(expected); + expect(await joinRows()).toEqual(expected); }); - it('should only patch the join rows of the related rows that limit() picks', async function () { - // Only MySQL and SQL Server support a limit in updates. - if (!session.isMySql() && !session.isMsSql()) { - this.skip(); - } - - const model = await owner(1); - const numUpdated = await model - .$relatedQuery('model1Relation3') - .patch({ model2Prop2: 10, extra2: 'z' }) - .whereIn('model2.model2_prop1', ['a', 'b', 'c']) - .orderBy('model2.model2_prop1', 'desc') - .limit(2); - - expect(numUpdated).to.equal(2); - expect(await model2Rows()).to.eql([ - [1, 1], - [2, 10], - [3, 10], - ]); + it.skipIf(!session.isMySql() && !session.isMsSql())( + 'should only patch the join rows of the related rows that limit() picks', + async () => { + // Only MySQL and SQL Server support a limit in updates. + const model = await owner(1); + const numUpdated = await model + .$relatedQuery('model1Relation3') + .patch({ model2Prop2: 10, extra2: 'z' }) + .whereIn('model2.model2_prop1', ['a', 'b', 'c']) + .orderBy('model2.model2_prop1', 'desc') + .limit(2); - const expected = allJoinRows.slice(); - expected[2] = [1, 2, 'x2', 'z']; - expected[3] = [1, 3, 'x3', 'z']; - expect(await joinRows()).to.eql(expected); - }); + expect(numUpdated).toBe(2); + expect(await model2Rows()).toEqual([ + [1, 1], + [2, 10], + [3, 10], + ]); - it('should count the related rows and not their join rows with limit()', async function () { - // Only MySQL and SQL Server support a limit in updates. - if (!session.isMySql() && !session.isMsSql()) { - this.skip(); - } + const expected = allJoinRows.slice(); + expected[2] = [1, 2, 'x2', 'z']; + expected[3] = [1, 3, 'x3', 'z']; + expect(await joinRows()).toEqual(expected); + }, + ); - // Model2 1 has two join rows, so it's picked once. - const model = await owner(1); - const numUpdated = await model - .$relatedQuery('model1Relation3') - .patch({ model2Prop2: 10, extra2: 'z' }) - .orderBy('model2.id_col') - .limit(2) - .offset(0); + it.skipIf(!session.isMySql() && !session.isMsSql())( + 'should count the related rows and not their join rows with limit()', + async () => { + // Only MySQL and SQL Server support a limit in updates. + // Model2 1 has two join rows, so it's picked once. + const model = await owner(1); + const numUpdated = await model + .$relatedQuery('model1Relation3') + .patch({ model2Prop2: 10, extra2: 'z' }) + .orderBy('model2.id_col') + .limit(2) + .offset(0); - expect(numUpdated).to.equal(2); - expect(await model2Rows()).to.eql([ - [1, 10], - [2, 10], - [3, 3], - ]); + expect(numUpdated).toBe(2); + expect(await model2Rows()).toEqual([ + [1, 10], + [2, 10], + [3, 3], + ]); - const expected = allJoinRows.slice(); - expected[0] = [1, 1, 'x1', 'z']; - expected[1] = [1, 1, 'dup', 'z']; - expected[2] = [1, 2, 'x2', 'z']; - expect(await joinRows()).to.eql(expected); - }); + const expected = allJoinRows.slice(); + expected[0] = [1, 1, 'x1', 'z']; + expected[1] = [1, 1, 'dup', 'z']; + expected[2] = [1, 2, 'x2', 'z']; + expect(await joinRows()).toEqual(expected); + }, + ); it('should patch the join table extras ordered by a join table column without limit()', async () => { const model = await owner(1); @@ -576,61 +565,100 @@ module.exports = (session) => { .orderBy('Model1Model2.extra1'); const expected = allJoinRows.map((row) => (row[0] === 1 ? [...row.slice(0, 3), 'z'] : row)); - expect(await joinRows()).to.eql(expected); + expect(await joinRows()).toEqual(expected); }); - it('should only patch the join table extras of the related rows that limit() and offset() pick', async function () { - // Only MySQL and SQL Server support a limit in updates. - if (!session.isMySql() && !session.isMsSql()) { - this.skip(); - } - - const model = await owner(1); - await model - .$relatedQuery('model1Relation3') - .patch({ extra2: 'z' }) - .orderBy('model2.id_col') - .limit(1) - .offset(1); - - const expected = allJoinRows.slice(); - expected[2] = [1, 2, 'x2', 'z']; - expect(await joinRows()).to.eql(expected); - }); + it.skipIf(!session.isMySql() && !session.isMsSql())( + 'should pick the related rows by a join table column with limit()', + async () => { + // Only MySQL and SQL Server support a limit in updates. + const model = await owner(1); + const numUpdated = await model + .$relatedQuery('model1Relation3') + .patch({ model2Prop2: 10, extra2: 'z' }) + .orderBy('Model1Model2.extra1', 'desc') + .limit(2); - it("should not warn about the offset that it doesn't leave out", async function () { - // Only MySQL and SQL Server support a limit in updates. - if (!session.isMySql() && !session.isMsSql()) { - this.skip(); - } + expect(numUpdated).toBe(2); + expect(await model2Rows()).toEqual([ + [1, 1], + [2, 10], + [3, 10], + ]); - // The warning is only observable through `console.warn()`. - const warnings = []; - const originalWarn = console.warn; - console.warn = (message) => warnings.push(message); - resetWarnings(); + const expected = allJoinRows.slice(); + expected[2] = [1, 2, 'x2', 'z']; + expected[3] = [1, 3, 'x3', 'z']; + expect(await joinRows()).toEqual(expected); + }, + ); + + it.skipIf(!session.isMySql() && !session.isMsSql())( + 'should order by a related column that the join table shares with limit()', + async () => { + // Only MySQL and SQL Server support a limit in updates. Both Model1 + // and the join table have `id` and `model1Id` columns. + const model = await Model2.query().findById(2); + const numUpdated = await model + .$relatedQuery('model2Relation1') + .patch({ model1Prop2: 10, aliasedExtra: 'z' }) + .orderBy('model1Id') + .orderBy('id', 'desc') + .limit(1); + + expect(numUpdated).toBe(1); + expect( + await session.knex('Model1').whereIn('id', [1, 2]).orderBy('id').pluck('model1Prop2'), + ).toEqual([null, 10]); + expect( + await session + .knex('Model1Model2') + .where('extra3', 'z') + .select('model1Id', 'model2Id') + .then((rows) => rows.map((row) => [row.model1Id, row.model2Id])), + ).toEqual([[2, 2]]); + }, + ); - try { + it.skipIf(!session.isMySql() && !session.isMsSql())( + 'should only patch the join table extras of the related rows that limit() and offset() pick', + async () => { + // Only MySQL and SQL Server support a limit in updates. const model = await owner(1); await model .$relatedQuery('model1Relation3') - .patch({ model2Prop2: 10, extra2: 'z' }) + .patch({ extra2: 'z' }) .orderBy('model2.id_col') .limit(1) .offset(1); - expect(warnings).to.have.length(0); - // Without join table extras, the offset is left out. - await model + const expected = allJoinRows.slice(); + expected[2] = [1, 2, 'x2', 'z']; + expect(await joinRows()).toEqual(expected); + }, + ); + + it.skipIf(!session.isMySql() && !session.isMsSql())( + 'should not throw for the offset that it honours with join table extras', + async () => { + // Only MySQL and SQL Server support a limit in updates. + const model = await owner(1); + const numUpdated = await model .$relatedQuery('model1Relation3') - .patch({ model2Prop2: 10 }) + .patch({ model2Prop2: 10, extra2: 'z' }) + .orderBy('model2.id_col') .limit(1) .offset(1); - expect(warnings).to.have.length(1); - } finally { - console.warn = originalWarn; - resetWarnings(); - } + expect(numUpdated).toBe(1); + }, + ); + + it('should throw for an offset without join table extras, as knex leaves it out', async () => { + const model = await owner(1); + await expect( + model.$relatedQuery('model1Relation3').patch({ model2Prop2: 10 }).limit(1).offset(1), + ).rejects.toThrow('offset() is not supported in update queries, as knex leaves it out.'); + expect(await joinRows()).toEqual(allJoinRows); }); describe('asFindQuery() in a static afterUpdate hook', () => { @@ -655,7 +683,7 @@ module.exports = (session) => { .patch({ extra2: 'z' }) .whereIn('model2.model2_prop1', ['b', 'c']); - expect(foundIds).to.eql([2, 3]); + expect(foundIds).toEqual([2, 3]); }); it('should find the related rows of the join rows with only join table extras that the patch changes', async () => { @@ -665,14 +693,14 @@ module.exports = (session) => { .patch({ extra1: 'changed' }) .where('Model1Model2.extra1', 'x2'); - expect(foundIds).to.eql([2]); + expect(foundIds).toEqual([2]); }); it('should find nothing with none()', async () => { const model = await owner(1); await model.$relatedQuery('model1Relation3').patch({ extra2: 'z' }).none(); - expect(foundIds).to.eql([]); + expect(foundIds).toEqual([]); }); for (const [title, patch] of [ @@ -687,7 +715,7 @@ module.exports = (session) => { .patchAndFetchById(2, patch) .where('model2.model2_prop1', 'b'); - expect(foundIds).to.eql([2]); + expect(foundIds).toEqual([2]); }); it(`should keep the id condition of patchAndFetchById() apart from an orWhere${title}`, async () => { @@ -698,8 +726,8 @@ module.exports = (session) => { .where('model2.model2_prop1', 'does not exist') .orWhere('model2.model2_prop1', 'b'); - expect(foundIds).to.eql([]); - expect(await model2Rows()).to.eql([ + expect(foundIds).toEqual([]); + expect(await model2Rows()).toEqual([ [1, 1], [2, 2], [3, 3], @@ -713,8 +741,8 @@ module.exports = (session) => { .patchAndFetchById(2, patch) .where('model2.model2_prop1', 'does not exist'); - expect(foundIds).to.eql([]); - expect(await model2Rows()).to.eql([ + expect(foundIds).toEqual([]); + expect(await model2Rows()).toEqual([ [1, 1], [2, 2], [3, 3], @@ -723,9 +751,8 @@ module.exports = (session) => { } }); - describe('with more related rows than one statement can filter by (#161)', () => { - // More ids than SQL Server accepts as bound values in one statement, - // patched in several batches: + describe('with more related rows than one statement binds values for (#161, #287)', () => { + // More ids than SQL Server accepts as bound values in one statement: const ids = Array.from({ length: 2101 }, (_, index) => 101 + index); async function insertRows(ids) { @@ -761,74 +788,69 @@ module.exports = (session) => { .patch({ model2Prop2: 10, extra2: 'z' }) .where('Model1Model2.extra1', 'many'); - expect(numUpdated).to.equal(ids.length); - expect(await patchedIds()).to.eql(ids); - expect(await patchedJoinRowIds()).to.eql(ids); + expect(numUpdated).toBe(ids.length); + expect(await patchedIds()).toEqual(ids); + expect(await patchedJoinRowIds()).toEqual(ids); }); - it('should patch all of them with the statistics of a related table that held one row', async function () { - if (!session.isPostgres()) { - this.skip(); - } - - // Vacuum the related table while it only holds its last row, as the - // autovacuum may do, so that Postgres plans for one related row. - const lastId = ids[ids.length - 1]; - await session.knex('model2').whereNot('id_col', lastId).delete(); - await session.knex.raw('vacuum analyze model2'); - await insertRows(ids.slice(0, -1)); + it.skipIf(!session.isPostgres())( + 'should patch all of them with the statistics of a related table that held one row', + async () => { + // Vacuum the related table while it only holds its last row, as the + // autovacuum may do, so that Postgres plans for one related row. + const lastId = ids[ids.length - 1]; + await session.knex('model2').whereNot('id_col', lastId).delete(); + await session.knex.raw('vacuum analyze model2'); + await insertRows(ids.slice(0, -1)); - const model = await owner(1); - const numUpdated = await model - .$relatedQuery('model1Relation3') - .patch({ model2Prop2: 10, extra2: 'z' }) - .where('Model1Model2.extra1', 'many'); - - expect(numUpdated).to.equal(ids.length); - expect(await patchedIds()).to.eql(ids); - expect(await patchedJoinRowIds()).to.eql(ids); - }); - - it('should patch the related rows that limit() picks and their join rows in batches', async function () { - // Only MySQL and SQL Server support a limit in updates. - if (!session.isMySql() && !session.isMsSql()) { - this.skip(); - } - - const someIds = ids.slice(0, 600); - const model = await owner(1); - const numUpdated = await model - .$relatedQuery('model1Relation3') - .patch({ model2Prop2: 10, extra2: 'z' }) - .where('Model1Model2.extra1', 'many') - .orderBy('model2.id_col') - .limit(someIds.length); + const model = await owner(1); + const numUpdated = await model + .$relatedQuery('model1Relation3') + .patch({ model2Prop2: 10, extra2: 'z' }) + .where('Model1Model2.extra1', 'many'); - expect(numUpdated).to.equal(someIds.length); - expect(await patchedIds()).to.eql(someIds); - expect(await patchedJoinRowIds()).to.eql(someIds); - }); + expect(numUpdated).toBe(ids.length); + expect(await patchedIds()).toEqual(ids); + expect(await patchedJoinRowIds()).toEqual(ids); + }, + ); - it('should return the patched rows of all batches with returning()', async function () { - if (!session.isPostgres() && !session.isMsSql()) { - this.skip(); - } + it.skipIf(!session.isMySql() && !session.isMsSql())( + 'should patch the related rows that limit() picks and their join rows', + async () => { + // Only MySQL and SQL Server support a limit in updates. + const someIds = ids.slice(0, -1); + const model = await owner(1); + const numUpdated = await model + .$relatedQuery('model1Relation3') + .patch({ model2Prop2: 10, extra2: 'z' }) + .where('Model1Model2.extra1', 'many') + .orderBy('model2.id_col') + .limit(someIds.length); + + expect(numUpdated).toBe(someIds.length); + expect(await patchedIds()).toEqual(someIds); + expect(await patchedJoinRowIds()).toEqual(someIds); + }, + ); - const model = await owner(1); - const models = await model - .$relatedQuery('model1Relation3') - .patch({ model2Prop2: 10, extra2: 'z' }) - .where('Model1Model2.extra1', 'many') - .returning('*'); + it.skipIf(!session.isPostgres() && !session.isMsSql())( + 'should return all patched rows with returning()', + async () => { + const model = await owner(1); + const models = await model + .$relatedQuery('model1Relation3') + .patch({ model2Prop2: 10, extra2: 'z' }) + .where('Model1Model2.extra1', 'many') + .returning('*'); - expect(models.every((model) => model instanceof Model2)).to.equal(true); - expect(models.map((model) => model.idCol).sort((a, b) => a - b)).to.eql(ids); - expect(models.every((model) => model.model2Prop2 === 10)).to.equal(true); - }); + expect(models.every((model) => model instanceof Model2)).toBe(true); + expect(models.map((model) => model.idCol).sort((a, b) => a - b)).toEqual(ids); + expect(models.every((model) => model.model2Prop2 === 10)).toBe(true); + }, + ); it('should find the patched rows with asFindQuery() in a static afterUpdate hook', async () => { - // Two batches, but few enough ids for the single find statement. - const someIds = ids.slice(0, 600); let foundIds = null; Model2.afterUpdate = async ({ asFindQuery }) => { @@ -840,12 +862,11 @@ module.exports = (session) => { const numUpdated = await model .$relatedQuery('model1Relation3') .patch({ model2Prop2: 10, extra2: 'z' }) - .where('Model1Model2.extra1', 'many') - .where('model2.id_col', '<=', someIds[someIds.length - 1]); + .where('Model1Model2.extra1', 'many'); - expect(numUpdated).to.equal(someIds.length); - expect(foundIds).to.eql(someIds); - expect(await patchedIds()).to.eql(someIds); + expect(numUpdated).toBe(ids.length); + expect(foundIds).toEqual(ids); + expect(await patchedIds()).toEqual(ids); }); }); }); @@ -894,8 +915,8 @@ module.exports = (session) => { .unrelate() .where('model2.id_col', 2); - expect(numDeleted).to.equal(1); - expect(await joinRows()).to.eql([...withoutRows(2), filteredJoinRow]); + expect(numDeleted).toBe(1); + expect(await joinRows()).toEqual([...withoutRows(2), filteredJoinRow]); }); it('should apply the filter with a model class bound to a knex instance', async () => { @@ -905,8 +926,8 @@ module.exports = (session) => { .unrelate() .where('model2.id_col', 2); - expect(numDeleted).to.equal(1); - expect(await joinRows()).to.eql([...withoutRows(2), filteredJoinRow]); + expect(numDeleted).toBe(1); + expect(await joinRows()).toEqual([...withoutRows(2), filteredJoinRow]); }); it('should unrelate rows matching a filter on the join table', async () => { @@ -915,8 +936,8 @@ module.exports = (session) => { .unrelate() .where('Model1Model2.extra1', 'x3'); - expect(numDeleted).to.equal(1); - expect(await joinRows()).to.eql([...withoutRows(3), filteredJoinRow]); + expect(numDeleted).toBe(1); + expect(await joinRows()).toEqual([...withoutRows(3), filteredJoinRow]); }); it('should not unrelate the join rows that the filter excludes', async () => { @@ -925,8 +946,8 @@ module.exports = (session) => { .unrelate() .where('Model1Model2.extra1', 'filtered'); - expect(numDeleted).to.equal(0); - expect(await joinRows()).to.eql([...allJoinRows, filteredJoinRow]); + expect(numDeleted).toBe(0); + expect(await joinRows()).toEqual([...allJoinRows, filteredJoinRow]); }); it('should only unrelate the join rows matching a filter on the join table', async () => { @@ -936,11 +957,11 @@ module.exports = (session) => { .where('Model1Model2.extra1', 'dup'); if (byKeys) { - expect(numDeleted).to.equal(2); - expect(await joinRows()).to.eql([...withoutRows(0, 1), filteredJoinRow]); + expect(numDeleted).toBe(2); + expect(await joinRows()).toEqual([...withoutRows(0, 1), filteredJoinRow]); } else { - expect(numDeleted).to.equal(1); - expect(await joinRows()).to.eql([...withoutRows(1), filteredJoinRow]); + expect(numDeleted).toBe(1); + expect(await joinRows()).toEqual([...withoutRows(1), filteredJoinRow]); } }); @@ -950,7 +971,7 @@ module.exports = (session) => { .patch({ model2Prop2: 10, extra1: 'z' }) .where('model2.id_col', 2); - expect(await model2Rows()).to.eql([ + expect(await model2Rows()).toEqual([ [1, 1], [2, 10], [3, 3], @@ -958,12 +979,62 @@ module.exports = (session) => { const expected = allJoinRows.slice(); expected[2] = [1, 2, 'z', 'y']; - expect(await joinRows()).to.eql([...expected, filteredJoinRow]); + expect(await joinRows()).toEqual([...expected, filteredJoinRow]); + }); + }); + + describe('with a relation through a related column other than the id', () => { + class Prop2Model1 extends Model1 { + static get relationMappings() { + return { + prop2Relation: { + relation: Model.ManyToManyRelation, + modelClass: Model2, + join: { + from: 'Model1.id', + through: { + from: 'Model1Model2.model1Id', + to: 'Model1Model2.model2Id', + extra: ['extra2'], + }, + to: 'model2.model2_prop2', + }, + }, + }; + } + } + + beforeEach(async () => { + // Swap the `model2Prop2` values of model2 1 and 3, so that the join + // rows of model2 1 relate model2 3 and the other way around. + await session.knex('model2').where('id_col', 1).update({ model2_prop2: 3 }); + await session.knex('model2').where('id_col', 3).update({ model2_prop2: 1 }); }); + + it.skipIf(!session.isMySql() && !session.isMsSql())( + 'should only patch the join rows of the related rows that limit() picks (#360)', + async () => { + // Only MySQL and SQL Server support a limit in updates. + const numUpdated = await Prop2Model1.fromJson({ id: 1 }) + .$relatedQuery('prop2Relation') + .patch({ model2Prop1: 'z', extra2: 'z' }) + .orderBy('model2.id_col') + .limit(1); + + expect(numUpdated).toBe(1); + expect(await session.knex('model2').where('model2_prop1', 'z').pluck('id_col')).toEqual([ + 1, + ]); + + const expected = allJoinRows.slice(); + expected[3] = [1, 3, 'x3', 'z']; + expect(await joinRows()).toEqual(expected); + }, + ); }); function describeItemTags(title, { joinTable, isSupported = () => true }) { - describe(title, () => { + describe.skipIf(!isSupported())(title, () => { const { knex } = session; class Tag extends Model { @@ -990,11 +1061,7 @@ module.exports = (session) => { }; } - before(async function () { - if (!isSupported()) { - this.skip(); - } - + beforeAll(async () => { await knex.schema .raw('DROP VIEW IF EXISTS m2m_modify_item_tag_view') .dropTableIfExists('m2m_modify_item_tag') @@ -1018,7 +1085,7 @@ module.exports = (session) => { .raw('CREATE VIEW m2m_modify_item_tag_view AS SELECT * FROM m2m_modify_item_tag'); }); - after(() => { + afterAll(() => { return knex.schema .raw('DROP VIEW IF EXISTS m2m_modify_item_tag_view') .dropTableIfExists('m2m_modify_item_tag') @@ -1060,8 +1127,8 @@ module.exports = (session) => { it('should unrelate', async () => { const numDeleted = await tags().unrelate().where('m2m_modify_tag.id', 2); - expect(numDeleted).to.equal(2); - expect(await itemTags()).to.eql([[1, null]]); + expect(numDeleted).toBe(2); + expect(await itemTags()).toEqual([[1, null]]); }); it('should only unrelate the join rows matching a filter on the join table', async () => { @@ -1071,11 +1138,11 @@ module.exports = (session) => { .where(`${joinTable}.note`, 'a'); if (byKeys) { - expect(numDeleted).to.equal(2); - expect(await itemTags()).to.eql([[1, null]]); + expect(numDeleted).toBe(2); + expect(await itemTags()).toEqual([[1, null]]); } else { - expect(numDeleted).to.equal(1); - expect(await itemTags()).to.eql([ + expect(numDeleted).toBe(1); + expect(await itemTags()).toEqual([ [1, null], [2, 'b'], ]); @@ -1085,7 +1152,7 @@ module.exports = (session) => { it('should patch the join table extras', async () => { await tags().patch({ note: 'x' }).where('m2m_modify_tag.id', 1); - expect(await itemTags()).to.eql([ + expect(await itemTags()).toEqual([ [1, 'x'], [2, 'a'], [2, 'b'], @@ -1095,7 +1162,7 @@ module.exports = (session) => { it('should only patch the join table extras of the join rows matching a filter on the join table', async () => { await tags().patch({ note: 'x' }).where(`${joinTable}.note`, 'b'); - expect(await itemTags()).to.eql([ + expect(await itemTags()).toEqual([ [1, null], ...(byKeys ? [ @@ -1121,7 +1188,7 @@ module.exports = (session) => { isSupported: () => !session.isSqlite(), }); - describe('with triggers on the join table (#2406)', () => { + describe.skipIf(!session.isMySql())('with triggers on the join table (#2406)', () => { const { knex } = session; class Tag extends Model { @@ -1160,11 +1227,7 @@ module.exports = (session) => { // The tables are only used here, so that their triggers are fetched // after they are created. - before(async function () { - if (!session.isMySql()) { - this.skip(); - } - + beforeAll(async () => { await dropTables(); await knex.schema .createTable('m2m_trigger_item', (table) => { @@ -1198,7 +1261,7 @@ module.exports = (session) => { `); }); - after(async () => { + afterAll(async () => { if (session.isMySql()) { await dropTables(); } @@ -1266,10 +1329,10 @@ module.exports = (session) => { model.$relatedQuery('model1Relation3').unrelate().where('model2.id_col', 2), ); - expect(queries).to.have.length(2); - expect(queries[0]).to.match(/^select .* exists \(select 1 from `model2`/); - expect(queries[1]).to.match(/^delete /); - expect(queries[1]).not.to.contain('exists'); + expect(queries).toHaveLength(2); + expect(queries[0]).toMatch(/^select .* exists \(select 1 from `model2`/); + expect(queries[1]).toMatch(/^delete /); + expect(queries[1]).not.toContain('exists'); }); it('should unrelate if a trigger is created after the first unrelate', async () => { @@ -1287,9 +1350,9 @@ module.exports = (session) => { .unrelate() .where('m2m_trigger_tag.id', 1); - expect(numDeleted).to.equal(1); - expect(await joinRows(joinTable)).to.eql([]); - expect(await tagCounts()).to.eql([1, 0]); + expect(numDeleted).toBe(1); + expect(await joinRows(joinTable)).toEqual([]); + expect(await tagCounts()).toEqual([1, 0]); } finally { await knex.raw('DROP TRIGGER m2m_trigger_item_tag_insert_delete'); } @@ -1303,26 +1366,26 @@ module.exports = (session) => { .where('m2m_trigger_item_tag.note', 'a'); // The keys can't tell apart the join rows of tag 2. - expect(numDeleted).to.equal(2); + expect(numDeleted).toBe(2); }); - expect(queries).to.have.length(2); - expect(queries[0]).to.match(/^select /); - expect(queries[1]).to.match(/^delete /); - expect(queries[1]).not.to.contain('exists'); - expect(await joinRows('m2m_trigger_item_tag')).to.eql([[1, null]]); - expect(await tagCounts()).to.eql([0, 2]); + expect(queries).toHaveLength(2); + expect(queries[0]).toMatch(/^select /); + expect(queries[1]).toMatch(/^delete /); + expect(queries[1]).not.toContain('exists'); + expect(await joinRows('m2m_trigger_item_tag')).toEqual([[1, null]]); + expect(await tagCounts()).toEqual([0, 2]); }); it('should patch the join table extras if the join table has an update trigger', async () => { await tags('tags').patch({ note: 'x' }).where('m2m_trigger_tag.id', 1); - expect(await joinRows('m2m_trigger_item_tag')).to.eql([ + expect(await joinRows('m2m_trigger_item_tag')).toEqual([ [1, 'x'], [2, 'a'], [2, 'b'], ]); - expect(await tagCounts()).to.eql([1, 0]); + expect(await tagCounts()).toEqual([1, 0]); }); }); @@ -1331,9 +1394,9 @@ module.exports = (session) => { const model = await owner(1); const numDeleted = await model.$relatedQuery('model1Relation3').delete(); - expect(numDeleted).to.equal(3); - expect(await model2Rows()).to.eql([]); - expect(await joinRows()).to.eql([]); + expect(numDeleted).toBe(3); + expect(await model2Rows()).toEqual([]); + expect(await joinRows()).toEqual([]); }); it('should delete related rows matching a filter on the related table', async () => { @@ -1343,12 +1406,12 @@ module.exports = (session) => { .delete() .where('model2.model2_prop1', 'a'); - expect(numDeleted).to.equal(1); - expect(await model2Rows()).to.eql([ + expect(numDeleted).toBe(1); + expect(await model2Rows()).toEqual([ [2, 2], [3, 3], ]); - expect(await joinRows()).to.eql(withoutRows(0, 1)); + expect(await joinRows()).toEqual(withoutRows(0, 1)); }); it('should delete related rows matching a filter on the join table', async () => { @@ -1358,24 +1421,24 @@ module.exports = (session) => { .delete() .where('Model1Model2.extra1', 'x2'); - expect(numDeleted).to.equal(1); - expect(await model2Rows()).to.eql([ + expect(numDeleted).toBe(1); + expect(await model2Rows()).toEqual([ [1, 1], [3, 3], ]); - expect(await joinRows()).to.eql(withoutRows(2, 4)); + expect(await joinRows()).toEqual(withoutRows(2, 4)); }); it('should delete related rows using findById', async () => { const model = await owner(1); const numDeleted = await model.$relatedQuery('model1Relation3').delete().findById(3); - expect(numDeleted).to.equal(1); - expect(await model2Rows()).to.eql([ + expect(numDeleted).toBe(1); + expect(await model2Rows()).toEqual([ [1, 1], [2, 2], ]); - expect(await joinRows()).to.eql(withoutRows(3, 5)); + expect(await joinRows()).toEqual(withoutRows(3, 5)); }); it('should return 0 if no rows match', async () => { @@ -1385,9 +1448,9 @@ module.exports = (session) => { .delete() .where('model2.model2_prop1', 'a'); - expect(numDeleted).to.equal(0); - expect(await model2Rows()).to.have.length(3); - expect(await joinRows()).to.eql(allJoinRows); + expect(numDeleted).toBe(0); + expect(await model2Rows()).toHaveLength(3); + expect(await joinRows()).toEqual(allJoinRows); }); }); }); diff --git a/tests/integration/misc/#2320.js b/tests/integration/misc/#2320.js deleted file mode 100644 index baee7e54a..000000000 --- a/tests/integration/misc/#2320.js +++ /dev/null @@ -1,121 +0,0 @@ -const { Model } = require('../../../'); -const { expect } = require('chai'); - -module.exports = (session) => { - describe('insert().onConflict().ignore() results #2320 #2597 #2661', () => { - const { knex } = session; - - before(function () { - // knex doesn't support `onConflict()` on SQL Server. - if (session.isMsSql()) { - this.skip(); - } - }); - - class Tag extends Model { - static get tableName() { - return 'onConflictTags'; - } - - static get jsonSchema() { - return { - type: 'object', - properties: { - id: { type: 'integer' }, - name: { type: 'string' }, - meta: { type: ['object', 'null'] }, - }, - }; - } - } - - before(() => { - return knex.schema.dropTableIfExists('onConflictTags').createTable('onConflictTags', (t) => { - t.increments('id').primary(); - t.string('name').unique(); - t.json('meta'); - }); - }); - - after(() => { - return knex.schema.dropTableIfExists('onConflictTags'); - }); - - beforeEach(async () => { - await knex('onConflictTags').delete(); - await Tag.query(knex).insert({ name: 'a', meta: { v: 'original a' } }); - await Tag.query(knex).insert({ name: 'b', meta: { v: 'original b' } }); - }); - - async function findTag(name) { - return Tag.query(knex).findOne({ name }); - } - - it('should not assign an id to an ignored model', async () => { - const tag = Tag.fromJson({ name: 'a' }); - const result = await Tag.query(knex).insert(tag).onConflict('name').ignore(); - - expect(result).to.equal(tag); - expect(tag.id).to.equal(undefined); - }); - - it('should assign the id to a model that is not ignored', async () => { - const result = await Tag.query(knex).insert({ name: 'c' }).onConflict('name').ignore(); - - expect(result.id).to.equal((await findTag('c')).id); - }); - - it('insertAndFetch() should not fetch an ignored model without an id (#2661)', async () => { - const result = await Tag.query(knex) - .insertAndFetch({ name: 'a', meta: { v: 'new a' } }) - .onConflict('name') - .ignore(); - - expect(result.id).to.equal(undefined); - expect(result.name).to.equal('a'); - expect(result.meta).to.eql({ v: 'new a' }); - }); - - if (session.isPostgres()) { - it('should merge the returned rows to the right models (#2320)', async () => { - const tags = ['a', 'c', 'b', 'd'].map((name) => Tag.fromJson({ name })); - const result = await Tag.query(knex).insert(tags).onConflict('name').ignore(); - - expect(result).to.have.length(4); - expect(result.every((it, i) => it === tags[i])).to.equal(true); - expect(tags.map((it) => it.name)).to.eql(['a', 'c', 'b', 'd']); - expect(tags[0].id).to.equal(undefined); - expect(tags[1].id).to.equal((await findTag('c')).id); - expect(tags[2].id).to.equal(undefined); - expect(tags[3].id).to.equal((await findTag('d')).id); - }); - - it('should not crash with object properties in the jsonSchema (#2597)', async () => { - const result = await Tag.query(knex) - .insert([ - { name: 'c', meta: { v: 'new c' } }, - { name: 'a', meta: { v: 'new a' } }, - ]) - .onConflict('name') - .ignore() - .returning('*'); - - expect(result.map((it) => it.id)).to.eql([(await findTag('c')).id, undefined]); - expect(result.map((it) => it.meta)).to.eql([{ v: 'new c' }, { v: 'new a' }]); - }); - - it('insertAndFetch() should only fetch the models that have an id (#2661)', async () => { - const result = await Tag.query(knex) - .insertAndFetch([ - { name: 'a', meta: { v: 'new a' } }, - { name: 'c', meta: { v: 'new c' } }, - ]) - .onConflict('name') - .ignore(); - - expect(result.map((it) => it.id)).to.eql([undefined, (await findTag('c')).id]); - expect(result.map((it) => it.meta)).to.eql([{ v: 'new a' }, { v: 'new c' }]); - }); - } - }); -}; diff --git a/tests/integration/misc/aggregateMethodsWithRelations.js b/tests/integration/misc/aggregateMethodsWithRelations.js index e7eb72f79..c1e10fd79 100644 --- a/tests/integration/misc/aggregateMethodsWithRelations.js +++ b/tests/integration/misc/aggregateMethodsWithRelations.js @@ -1,6 +1,6 @@ -const expect = require('expect.js'); +import { describe, it, expect, beforeEach } from 'vitest'; -module.exports = (session) => { +export default (session) => { describe('aggregate methods with relations', () => { beforeEach(() => { return session.populate([ @@ -27,8 +27,8 @@ module.exports = (session) => { .groupBy('Model1.id', 'Model1.model1Id', 'Model1.model1Prop1', 'Model1.model1Prop2') .orderBy('Model1.model1Prop1') .then((models) => { - expect(models[0].relCount).to.eql(3); - expect(models[1].relCount).to.eql(2); + expect(Number(models[0].relCount)).toBe(3); + expect(Number(models[1].relCount)).toBe(2); }); }); }); diff --git a/tests/integration/misc/asFindQueryRunAfter.js b/tests/integration/misc/asFindQueryRunAfter.js index 3057ba4d3..f2581c039 100644 --- a/tests/integration/misc/asFindQueryRunAfter.js +++ b/tests/integration/misc/asFindQueryRunAfter.js @@ -1,13 +1,13 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('asFindQuery() in static hooks ignores runAfter() of the original query #2093', () => { const { knex } = session; let Person; let hookResults; - before(async () => { + beforeAll(async () => { await knex.schema.dropTableIfExists('as_find_query_person'); await session.createTable('as_find_query_person', (table, { increments }) => { increments('id'); @@ -15,11 +15,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('as_find_query_person'); }); - before(() => { + beforeAll(() => { Person = class Person extends Model { static get tableName() { return 'as_find_query_person'; @@ -61,10 +61,10 @@ module.exports = (session) => { return result; }); - expect(result).to.equal(1); - expect(calls).to.equal(1); - expect(hookResults).to.have.length(1); - expect(hookResults[0].map((it) => it.id)).to.eql([1]); + expect(result).toBe(1); + expect(calls).toBe(1); + expect(hookResults).toHaveLength(1); + expect(hookResults[0].map((it) => it.id)).toEqual([1]); }); it('should not throw because of throwIfNotFound() on the original query', async () => { @@ -77,23 +77,19 @@ module.exports = (session) => { } // The delete itself throws, but the hook's find query just returns no rows. - expect(error).to.be.an.instanceOf(Person.NotFoundError); - expect(hookResults).to.eql([[]]); + expect(error).toBeInstanceOf(Person.NotFoundError); + expect(hookResults).toEqual([[]]); }); it('should return an empty array for a findById() query that finds nothing', async () => { const result = await Person.query().deleteById(1000); - expect(result).to.equal(0); - expect(hookResults).to.eql([[]]); + expect(result).toBe(0); + expect(hookResults).toEqual([[]]); }); - it('should not traverse the results of the find query', async function () { - if (session.isMySql()) { - // MySQL doesn't support `returning()`, so there are no models to traverse. - return this.skip(); - } - + // MySQL doesn't support `returning()`, so there are no models to traverse. + it.skipIf(session.isMySql())('should not traverse the results of the find query', async () => { const traversed = []; await Person.query() @@ -103,8 +99,8 @@ module.exports = (session) => { .traverse((model) => traversed.push(model.id)); // Only the patched rows are traversed, not the rows of the hook's find query. - expect(traversed).to.eql([2]); - expect(hookResults[0].map((it) => it.id)).to.eql([2]); + expect(traversed).toEqual([2]); + expect(hookResults[0].map((it) => it.id)).toEqual([2]); }); it('should still run runAfter() callbacks added to the find query itself', async () => { @@ -120,7 +116,7 @@ module.exports = (session) => { Person.beforeUpdate = beforeUpdate; } - expect(hookResults).to.eql([['Jennifer']]); + expect(hookResults).toEqual([['Jennifer']]); }); }); }; diff --git a/tests/integration/misc/concurrency.js b/tests/integration/misc/concurrency.js index d33b6b766..d06e8ab4f 100644 --- a/tests/integration/misc/concurrency.js +++ b/tests/integration/misc/concurrency.js @@ -1,8 +1,8 @@ -const expect = require('expect.js'); -const mockKnexFactory = require('../../../testUtils/mockKnex'); -const { Model, snakeCaseMappers } = require('../../../'); +import { describe, it, expect, beforeEach } from 'vitest'; +import mockKnexFactory from '../../../testUtils/mockKnex.js'; +import { Model, snakeCaseMappers } from 'objection'; -module.exports = (session) => { +export default (session) => { // TODO: Skipped for now for because a change in knex broke mockKnexFactory. describe.skip('Model.concurrency', () => { let knex; @@ -90,7 +90,7 @@ module.exports = (session) => { }; runningQueries.push(runningQuery); - expect(runningQueries).to.have.length(1); + expect(runningQueries).toHaveLength(1); return oldImpl.apply(this, args).then((res) => { runningQueries = runningQueries.filter((it) => it !== runningQuery); diff --git a/tests/integration/misc/cyclicGraphRefs.js b/tests/integration/misc/cyclicGraphRefs.js index f6ecd6c8d..ebae87c07 100644 --- a/tests/integration/misc/cyclicGraphRefs.js +++ b/tests/integration/misc/cyclicGraphRefs.js @@ -1,15 +1,15 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); -const { FetchStrategy } = require('../../../lib/queryBuilder/graph/GraphOptions'); +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { Model } from 'objection'; +import { FetchStrategy } from '../../../lib/queryBuilder/graph/GraphOptions.js'; -module.exports = (session) => { +export default (session) => { describe('insertGraph / upsertGraph with cyclic #ref dependencies #1482', () => { const { knex } = session; let Person; let Pet; let Toy; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('cyclic_toy') .dropTableIfExists('cyclic_pet') @@ -44,7 +44,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema .alterTable('cyclic_person', (table) => { table.dropForeign('favoritePetId'); @@ -64,7 +64,7 @@ module.exports = (session) => { }); }); - before(() => { + beforeAll(() => { Person = class Person extends Model { static get tableName() { return 'cyclic_person'; @@ -180,14 +180,14 @@ module.exports = (session) => { error = err; } - expect(error).to.be.an('error'); - expect(error.message).to.equal('the object graph contains cyclic references'); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('the object graph contains cyclic references'); } async function expectNoRows() { - expect(await Person.query().resultSize()).to.equal(0); - expect(await Pet.query().resultSize()).to.equal(0); - expect(await Toy.query().resultSize()).to.equal(0); + expect(await Person.query().resultSize()).toBe(0); + expect(await Pet.query().resultSize()).toBe(0); + expect(await Toy.query().resultSize()).toBe(0); } const variants = [ @@ -214,14 +214,14 @@ module.exports = (session) => { ); const felix = result.pets[1]; - expect(felix.id).to.be.a('number'); - expect(felix.ownerId).to.equal(result.id); - expect(result.favoritePetId).to.equal(felix.id); + expect(felix.id).toBeTypeOf('number'); + expect(felix.ownerId).toBe(result.id); + expect(result.favoritePetId).toBe(felix.id); const jennifer = await fetchPerson('Jennifer'); - expect(jennifer.pets.map((it) => it.name).sort()).to.eql(['Doggo', 'Felix']); - expect(jennifer.favoritePet.name).to.equal('Felix'); - expect(jennifer.favoritePet.id).to.equal(felix.id); + expect(jennifer.pets.map((it) => it.name).sort()).toEqual(['Doggo', 'Felix']); + expect(jennifer.favoritePet.name).toBe('Felix'); + expect(jennifer.favoritePet.id).toBe(felix.id); }); it('should work when the #ref target is the first item of the relation', async () => { @@ -235,8 +235,8 @@ module.exports = (session) => { ); const jennifer = await fetchPerson('Jennifer'); - expect(jennifer.pets.map((it) => it.name).sort()).to.eql(['Doggo', 'Felix']); - expect(jennifer.favoritePet.name).to.equal('Felix'); + expect(jennifer.pets.map((it) => it.name).sort()).toEqual(['Doggo', 'Felix']); + expect(jennifer.favoritePet.name).toBe('Felix'); }); it('should break cycles spanning more than two levels', async () => { @@ -254,11 +254,11 @@ module.exports = (session) => { graphOptions, ); - expect(result.favoriteToyId).to.equal(result.pets[0].toys[1].id); + expect(result.favoriteToyId).toBe(result.pets[0].toys[1].id); const jennifer = await fetchPerson('Jennifer'); - expect(jennifer.favoriteToy.name).to.equal('Ball'); - expect(jennifer.pets[0].toys.map((it) => it.name).sort()).to.eql(['Ball', 'Mouse']); + expect(jennifer.favoriteToy.name).toBe('Ball'); + expect(jennifer.pets[0].toys.map((it) => it.name).sort()).toEqual(['Ball', 'Mouse']); }); it('should break multiple cycles in one graph', async () => { @@ -280,12 +280,12 @@ module.exports = (session) => { ); const jennifer = await fetchPerson('Jennifer'); - expect(jennifer.favoritePet.name).to.equal('Felix'); - expect(jennifer.favoriteToy.name).to.equal('Ball'); + expect(jennifer.favoritePet.name).toBe('Felix'); + expect(jennifer.favoriteToy.name).toBe('Ball'); const brad = await fetchPerson('Brad'); - expect(brad.favoritePet.name).to.equal('Tom'); - expect(brad.pets.map((it) => it.name).sort()).to.eql(['Rex', 'Tom']); + expect(brad.favoritePet.name).toBe('Tom'); + expect(brad.pets.map((it) => it.name).sort()).toEqual(['Rex', 'Tom']); }); it('should handle a #ref into another root without a cycle', async () => { @@ -304,8 +304,8 @@ module.exports = (session) => { ); const jennifer = await fetchPerson('Jennifer'); - expect(jennifer.favoritePet.name).to.equal('Tom'); - expect(jennifer.pets).to.eql([]); + expect(jennifer.favoritePet.name).toBe('Tom'); + expect(jennifer.pets).toEqual([]); }); it('should resolve #ref{} properties of the owner that are not deferred', async () => { @@ -322,8 +322,8 @@ module.exports = (session) => { ); const jennifer = await fetchPerson('Jennifer'); - expect(jennifer.favoritePet.name).to.equal('Felix'); - expect(jennifer.pets[0].toys[0].name).to.equal('Toy of Jennifer'); + expect(jennifer.favoritePet.name).toBe('Felix'); + expect(jennifer.pets[0].toys[0].name).toBe('Toy of Jennifer'); }); it('should not defer a foreign key that is read by a #ref{} property reference', async () => { @@ -377,7 +377,7 @@ module.exports = (session) => { graphOptions, ); - expect(Person.hookCalls).to.eql(['beforeUpdate', 'afterUpdate']); + expect(Person.hookCalls).toEqual(['beforeUpdate', 'afterUpdate']); }); it('should roll back the transaction if the deferred patch fails', async () => { @@ -399,8 +399,8 @@ module.exports = (session) => { error = err; } - expect(error).to.be.an('error'); - expect(error.message).to.equal('update failed'); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('update failed'); await expectNoRows(); }); }); @@ -422,14 +422,14 @@ module.exports = (session) => { ); const felix = await Pet.query().findOne({ name: 'Felix' }).withGraphFetched('favoriteToy'); - expect(felix.ownerId).to.equal(jennifer.id); - expect(felix.favoriteToy.name).to.equal('Ball'); + expect(felix.ownerId).toBe(jennifer.id); + expect(felix.favoriteToy.name).toBe('Ball'); }); }); describe('upsertGraph() cycles that must not be broken', () => { it('should throw if the #ref target is not inserted because of noInsert', async () => { - await expectCyclicError( + await expect( Person.query().upsertGraph( { name: 'Jennifer', @@ -438,6 +438,8 @@ module.exports = (session) => { }, { allowRefs: true, noInsert: ['pets'] }, ), + ).rejects.toThrow( + "the reference at 'favoritePet' points to a model that the graph doesn't write before it", ); await expectNoRows(); @@ -473,8 +475,8 @@ module.exports = (session) => { ); const fetched = await fetchPerson('Jennifer'); - expect(fetched.pets.map((it) => it.name)).to.eql(['Felix']); - expect(fetched.favoritePet.name).to.equal('Felix'); + expect(fetched.pets.map((it) => it.name)).toEqual(['Felix']); + expect(fetched.favoritePet.name).toBe('Felix'); }); }); @@ -504,11 +506,11 @@ module.exports = (session) => { { allowRefs: true }, ); - expect(countUpdates()).to.equal(1); + expect(countUpdates()).toBe(1); const jennifer = await fetchPerson('Jennifer'); - expect(jennifer.favoritePet.name).to.equal('Felix'); - expect(jennifer.favoriteToy.name).to.equal('Ball'); + expect(jennifer.favoritePet.name).toBe('Felix'); + expect(jennifer.favoriteToy.name).toBe('Ball'); }); it('should patch each owner with deferred foreign keys once', async () => { @@ -528,7 +530,7 @@ module.exports = (session) => { { allowRefs: true }, ); - expect(countUpdates()).to.equal(2); + expect(countUpdates()).toBe(2); }); it('should not patch anything if the graph has no cycles', async () => { @@ -540,7 +542,7 @@ module.exports = (session) => { { allowRefs: true }, ); - expect(countUpdates()).to.equal(0); + expect(countUpdates()).toBe(0); }); }); @@ -548,7 +550,7 @@ module.exports = (session) => { let Owner; let Item; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('cyclic_item') .dropTableIfExists('cyclic_owner') @@ -561,14 +563,15 @@ module.exports = (session) => { table.increments('id').primary(); table.string('name'); table.integer('ownerId').unsigned(); + table.integer('walkerId').unsigned(); }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('cyclic_item').dropTableIfExists('cyclic_owner'); }); - before(() => { + beforeAll(() => { Owner = class Owner extends Model { static get tableName() { return 'cyclic_owner'; @@ -587,6 +590,12 @@ module.exports = (session) => { modelClass: Item, join: { from: 'cyclic_owner.requiredItemId', to: 'cyclic_item.id' }, }, + + walkedItems: { + relation: Model.HasManyRelation, + modelClass: Item, + join: { from: 'cyclic_owner.id', to: 'cyclic_item.walkerId' }, + }, }; } }; @@ -603,33 +612,68 @@ module.exports = (session) => { // MySQL without strict mode stores 0 for the missing value instead of // rejecting the insert. - const itUnlessMySql = session.isMySql() ? it.skip : it; + it.skipIf(session.isMySql())( + 'should fail in the database and roll back inside a transaction', + async () => { + let error; + + try { + await Owner.transaction((trx) => + Owner.query(trx).insertGraph( + [ + { name: 'Unrelated', requiredItemId: 0 }, + { + name: 'Jennifer', + items: [{ '#id': 'felix', name: 'Felix' }], + requiredItem: { '#ref': 'felix' }, + }, + ], + { allowRefs: true }, + ), + ); + } catch (err) { + error = err; + } - itUnlessMySql('should fail in the database and roll back inside a transaction', async () => { - let error; + expect(error).toBeInstanceOf(Error); + expect(error.message).not.toBe('the object graph contains cyclic references'); + expect(await Owner.query().resultSize()).toBe(0); + expect(await Item.query().resultSize()).toBe(0); + }, + ); - try { - await Owner.transaction((trx) => - Owner.query(trx).insertGraph( - [ - { name: 'Unrelated', requiredItemId: 0 }, - { - name: 'Jennifer', - items: [{ '#id': 'felix', name: 'Felix' }], - requiredItem: { '#ref': 'felix' }, - }, - ], - { allowRefs: true }, - ), - ); - } catch (err) { - error = err; - } + it('should insert a #ref row whose other place reads the id of a row that reads its own id', async () => { + await Owner.query().upsertGraph( + [ + { + '#id': 'q', + name: 'Q', + requiredItem: { '#ref': 'pt' }, + walkedItems: [{ '#ref': 'pt' }], + }, + { name: 'R', requiredItem: { name: 'X' }, items: [{ '#id': 'pt', name: 'Pt' }] }, + ], + { relate: true, allowRefs: true }, + ); - expect(error).to.be.an('error'); - expect(error.message).to.not.equal('the object graph contains cyclic references'); - expect(await Owner.query().resultSize()).to.equal(0); - expect(await Item.query().resultSize()).to.equal(0); + // The ids depend on the tests before, so compare by name: + const owners = await Owner.query().orderBy('name'); + const items = await Item.query().orderBy('name'); + const nameOf = (rows, id) => rows.find((row) => row.id === id)?.name ?? null; + expect(owners.map((owner) => [owner.name, nameOf(items, owner.requiredItemId)])).toEqual([ + ['Q', 'Pt'], + ['R', 'X'], + ]); + expect( + items.map((item) => [ + item.name, + nameOf(owners, item.ownerId), + nameOf(owners, item.walkerId), + ]), + ).toEqual([ + ['Pt', 'R', 'Q'], + ['X', null, null], + ]); }); }); @@ -637,7 +681,7 @@ module.exports = (session) => { let CPerson; let CPet; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('cyclic_cpet') .dropTableIfExists('cyclic_cperson') @@ -659,11 +703,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('cyclic_cpet').dropTableIfExists('cyclic_cperson'); }); - before(() => { + beforeAll(() => { CPerson = class CPerson extends Model { static get tableName() { return 'cyclic_cperson'; @@ -731,20 +775,23 @@ module.exports = (session) => { }, { idA: 2, idB: 1, name: 'Brad' }, ], - { allowRefs: true, insertMissing: true }, + // `insertGraph()` always inserts models with ids: + method === 'upsertGraph' + ? { allowRefs: true, insertMissing: true } + : { allowRefs: true }, ); const jennifer = await CPerson.query() .findById([1, 1]) .withGraphFetched('[pets, favoritePet]'); - expect(jennifer.favoritePetIdA).to.equal(1); - expect(jennifer.favoritePetIdB).to.equal(3); - expect(jennifer.favoritePet.name).to.equal('Felix'); - expect(jennifer.pets).to.have.length(2); + expect(jennifer.favoritePetIdA).toBe(1); + expect(jennifer.favoritePetIdB).toBe(3); + expect(jennifer.favoritePet.name).toBe('Felix'); + expect(jennifer.pets).toHaveLength(2); const brad = await CPerson.query().findById([2, 1]); - expect(brad.favoritePetIdA).to.equal(null); - expect(brad.favoritePetIdB).to.equal(null); + expect(brad.favoritePetIdA).toBeNull(); + expect(brad.favoritePetIdB).toBeNull(); }); } }); @@ -753,7 +800,7 @@ module.exports = (session) => { let Owner; let Item; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('cyclic_nopk_item') .dropTableIfExists('cyclic_nopk_owner') @@ -768,13 +815,13 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema .dropTableIfExists('cyclic_nopk_item') .dropTableIfExists('cyclic_nopk_owner'); }); - before(() => { + beforeAll(() => { Owner = class Owner extends Model { static get tableName() { return 'cyclic_nopk_owner'; @@ -825,8 +872,8 @@ module.exports = (session) => { ), ); - expect(await Owner.query()).to.eql([{ name: 'Other', favoriteItemId: null }]); - expect(await Item.query().resultSize()).to.equal(0); + expect(await Owner.query()).toEqual([{ name: 'Other', favoriteItemId: null }]); + expect(await Item.query().resultSize()).toBe(0); await Owner.query().delete(); await Item.query().delete(); diff --git a/tests/integration/misc/defaultModelFieldValues.js b/tests/integration/misc/defaultModelFieldValues.js index 3eca23dbb..ab214b357 100644 --- a/tests/integration/misc/defaultModelFieldValues.js +++ b/tests/integration/misc/defaultModelFieldValues.js @@ -1,7 +1,7 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { const { knex } = session; // Typescript adds undefined default values for all declared class fields @@ -97,7 +97,7 @@ module.exports = (session) => { static tableName = 'toy'; } - before(async () => { + beforeAll(async () => { await knex.schema .dropTableIfExists('petToy') .dropTableIfExists('pet') @@ -123,7 +123,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema .dropTableIfExists('petToy') .dropTableIfExists('pet') @@ -139,7 +139,7 @@ module.exports = (session) => { pets: [{ name: 'Catto' }, { name: 'Doggo', toys: [{ toyName: 'Bone' }] }], }); - expect(result).to.containSubset({ + expect(result).toContainSubset({ firstName: 'Arnold', pets: [ { name: 'Catto', owner: undefined, toys: undefined }, @@ -159,7 +159,7 @@ module.exports = (session) => { }, }); - expect(resultFromDb).to.containSubset({ + expect(resultFromDb).toContainSubset({ firstName: 'Arnold', pets: [ { name: 'Catto', owner: undefined, toys: [] }, @@ -185,7 +185,7 @@ module.exports = (session) => { }, }); - expect(result).to.containSubset({ + expect(result).toContainSubset({ firstName: 'Arnold', pets: [ { name: 'Catto', owner: undefined, toys: [] }, @@ -212,7 +212,7 @@ module.exports = (session) => { }, }); - expect(result).to.containSubset({ + expect(result).toContainSubset({ firstName: 'Arnold', pets: [ { name: 'Catto', owner: undefined, toys: [] }, @@ -231,8 +231,8 @@ module.exports = (session) => { await toy.$query(knex).patch({ toyName: 'Wheel' }); toy = await Toy.query(knex).findById(toy.id); - expect(toy.price).to.equal(100); - expect(toy.toyName).to.equal('Wheel'); + expect(toy.price).toBe(100); + expect(toy.toyName).toBe('Wheel'); toy = await Toy.query(knex).insert({ toyName: 'Wheel' }); await toy.$query(knex).update(); @@ -249,18 +249,18 @@ module.exports = (session) => { // HasManyRelation const catto = await Person.relatedQuery('pets', knex).for(personId).findById(cattoId); - expect(catto).to.containSubset({ name: 'Catto' }); + expect(catto).toMatchObject({ name: 'Catto' }); const doggo = await Person.relatedQuery('pets', knex).for(personId).findById(doggoId); - expect(doggo).to.containSubset({ name: 'Doggo' }); + expect(doggo).toMatchObject({ name: 'Doggo' }); // BelongsToOneRelation const person = await doggo.$relatedQuery('owner', knex); - expect(person).to.containSubset({ firstName: 'Arnold' }); + expect(person).toMatchObject({ firstName: 'Arnold' }); // ManyToManyRelation const toys = await Pet.relatedQuery('toys', knex).for(doggo); - expect(toys).to.have.length(1); - expect(toys).to.containSubset([{ toyName: 'Bone' }]); + expect(toys).toHaveLength(1); + expect(toys).toContainSubset([{ toyName: 'Bone' }]); }); it('relatedQuery: insert', async () => { @@ -270,7 +270,7 @@ module.exports = (session) => { .for(doggo) .insert({ firstName: 'Arnold' }); let arnold = await Person.query(knex).withGraphFetched('pets').findById(arnoldId); - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ firstName: 'Arnold', pets: [{ name: 'Doggo' }], }); @@ -280,14 +280,14 @@ module.exports = (session) => { .for(arnold.id) .insert({ name: 'Catto' }); arnold = await Person.query(knex).withGraphFetched('pets').findById(arnoldId); - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ firstName: 'Arnold', pets: [{ name: 'Doggo' }, { name: 'Catto' }], }); // ManyToManyRelation const toy = await Pet.relatedQuery('toys', knex).for(catto).insert({ toyName: 'Bone' }); - expect(toy).to.containSubset({ toyName: 'Bone' }); + expect(toy).toMatchObject({ toyName: 'Bone' }); const result = await Person.query(knex) .findById(arnoldId) @@ -297,7 +297,7 @@ module.exports = (session) => { }, }); - expect(result).to.containSubset({ + expect(result).toContainSubset({ firstName: 'Arnold', pets: [ { name: 'Catto', owner: undefined, toys: [{ toyName: 'Bone' }] }, diff --git a/tests/integration/misc/fieldExpressionKeys.js b/tests/integration/misc/fieldExpressionKeys.js index 8b37f7189..712756949 100644 --- a/tests/integration/misc/fieldExpressionKeys.js +++ b/tests/integration/misc/fieldExpressionKeys.js @@ -1,12 +1,12 @@ -const { Model, snakeCaseMappers, raw, ref } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model, ValidationError, snakeCaseMappers, raw, ref } from 'objection'; -module.exports = (session) => { - describe('keys with a colon in patch(), update() and merge() data', () => { +export default (session) => { + describe('keys with a colon in insert(), patch(), update() and merge() data', () => { let knex = session.knex; let Item; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('field_expression_keys') .createTable('field_expression_keys', (table) => { @@ -15,18 +15,18 @@ module.exports = (session) => { table.string('ns:foo_bar'); table.string('ns:'); - if (session.isPostgres()) { + if (!session.isMsSql()) { table.jsonb('meta'); table.jsonb('extra_meta'); } }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('field_expression_keys'); }); - before(() => { + beforeAll(() => { Item = class Item extends Model { static get tableName() { return 'field_expression_keys'; @@ -58,7 +58,7 @@ module.exports = (session) => { await knex('field_expression_keys').insert({ id: 1, 'ns:name': 'a', - ...(session.isPostgres() + ...(!session.isMsSql() ? { meta: JSON.stringify({ size: 1 }), extra_meta: JSON.stringify({ size: 1 }) } : {}), }); @@ -67,13 +67,13 @@ module.exports = (session) => { it('patch() should write a declared key with a colon to its column', async () => { await Item.query().patch({ 'ns:name': 'b' }).where('id', 1); const item = await Item.query().findById(1); - expect(item['ns:name']).to.equal('b'); + expect(item['ns:name']).toBe('b'); }); it('update() should write a declared key with a colon to its column', async () => { await Item.query().update({ id: 1, 'ns:name': 'c' }).where('id', 1); const item = await Item.query().findById(1); - expect(item['ns:name']).to.equal('c'); + expect(item['ns:name']).toBe('c'); }); it('update() should write a declared key with a colon given as a query property', async () => { @@ -81,7 +81,69 @@ module.exports = (session) => { .update({ id: 1, 'ns:name': raw('?', 'q') }) .where('id', 1); const item = await Item.query().findById(1); - expect(item['ns:name']).to.equal('q'); + expect(item['ns:name']).toBe('q'); + }); + + it('update() should reject undeclared keys with a colon', async () => { + class SchemalessItem extends Model { + static get tableName() { + return 'field_expression_keys'; + } + } + + const expectRejected = async (query, keys) => { + const err = await query.catch((err) => err); + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('ModelValidation'); + expect(Object.keys(err.data)).toEqual(keys); + }; + + const item = await Item.query().findById(1); + await expectRejected( + Item.query().update({ id: 1, 'ns:name': 'b', 'meta:color': 'red', 'meta:size': ref('id') }), + ['meta:color', 'meta:size'], + ); + await expectRejected(item.$query().update({ id: 1, 'meta:color': 'red' }), ['meta:color']); + await expectRejected(Item.fromJson({ id: 1, 'meta:color': 'red' }).$query().update(), [ + 'meta:color', + ]); + await expectRejected(SchemalessItem.query(knex).update({ id: 1, 'ns:name': 'b' }), [ + 'ns:name', + ]); + + const row = await knex('field_expression_keys').where('id', 1).first(); + expect(row['ns:name']).toBe('a'); + }); + + it('insert() should reject undeclared keys with a colon', async () => { + class SchemalessItem extends Model { + static get tableName() { + return 'field_expression_keys'; + } + } + + const expectRejected = async (query, keys) => { + const err = await query.catch((err) => err); + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('ModelValidation'); + expect(Object.keys(err.data)).toEqual(keys); + }; + + await expectRejected( + Item.query().insert({ id: 2, 'ns:name': 'b', 'meta:color': 'red', 'meta:size': ref('id') }), + ['meta:color', 'meta:size'], + ); + await expectRejected(Item.fromJson({ id: 2, 'meta:color': 'red' }).$query().insert(), [ + 'meta:color', + ]); + await expectRejected(Item.query().insertGraph({ id: 2, 'meta:color': 'red' }), [ + 'meta:color', + ]); + await expectRejected(SchemalessItem.query(knex).insert({ id: 2, 'ns:name': 'b' }), [ + 'ns:name', + ]); + + expect(await knex('field_expression_keys').where('id', 2)).toEqual([]); }); it('patch() should write a declared key with a colon mapped by snakeCaseMappers', async () => { @@ -103,7 +165,36 @@ module.exports = (session) => { await SnakeCaseItem.query().patch({ 'ns:foo_bar': 'b' }).where('id', 1); const row = await knex('field_expression_keys').where('id', 1).first(); - expect(row['ns:foo_bar']).to.equal('b'); + expect(row['ns:foo_bar']).toBe('b'); + }); + + it('insert(), patch() and update() should round-trip a declared key with a colon mapped by snakeCaseMappers', async () => { + class SnakeCaseItem extends Item { + static get columnNameMappers() { + return snakeCaseMappers(); + } + + static get jsonSchema() { + return { + type: 'object', + properties: { + id: { type: 'integer' }, + 'ns:fooBar': { type: 'string' }, + }, + }; + } + } + + const fetchRow = () => knex('field_expression_keys').where('id', 2).first(); + + await SnakeCaseItem.query().insert({ id: 2, 'ns:fooBar': 'a' }); + expect((await fetchRow())['ns:foo_bar']).toBe('a'); + const item = await SnakeCaseItem.query().findById(2); + expect(item['ns:fooBar']).toBe('a'); + await item.$query().patch({ 'ns:fooBar': 'b' }); + expect((await fetchRow())['ns:foo_bar']).toBe('b'); + await item.$query().update({ id: 2, 'ns:fooBar': 'c' }); + expect((await fetchRow())['ns:foo_bar']).toBe('c'); }); it('patch() should not parse the keys with a colon as database json', async () => { @@ -115,7 +206,7 @@ module.exports = (session) => { await StrictItem.query().patch({ 'ns:name': 'b' }).where('id', 1); const item = await Item.query().findById(1); - expect(item['ns:name']).to.equal('b'); + expect(item['ns:name']).toBe('b'); }); describe('without a schema', () => { @@ -132,7 +223,16 @@ module.exports = (session) => { await SchemalessItem.query(knex).insert( SchemalessItem.fromJson({ id: 2, 'ns:': raw('?', 'x') }), ); - expect(await fetchValue(2)).to.equal('x'); + expect(await fetchValue(2)).toBe('x'); + }); + + it('insert(), update() and patch() should write a key with a colon that is not a field expression', async () => { + await SchemalessItem.query(knex).insert({ id: 2, 'ns:': 'x' }); + expect(await fetchValue(2)).toBe('x'); + await SchemalessItem.query(knex).update({ id: 2, 'ns:': 'y' }).where('id', 2); + expect(await fetchValue(2)).toBe('y'); + await SchemalessItem.query(knex).patch({ 'ns:': 'z' }).where('id', 2); + expect(await fetchValue(2)).toBe('z'); }); it('upsertGraph() should fetch a key with a colon that is not a field expression', async () => { @@ -140,24 +240,53 @@ module.exports = (session) => { { id: 2, 'ns:': 'x' }, { insertMissing: true, fetchStrategy: 'OnlyNeeded' }, ); - expect(await fetchValue(2)).to.equal('x'); + expect(await fetchValue(2)).toBe('x'); }); }); - if (session.isPostgres()) { + if (!session.isMsSql()) { + // MySQL and SQLite return json columns of raw rows as text. + const parseJson = (value) => (typeof value === 'string' ? JSON.parse(value) : value); + it('patch() should write an undeclared key with a colon to the json path', async () => { await Item.query().patch({ 'ns:name': 'd', 'meta:color': 'red' }).where('id', 1); const item = await Item.query().findById(1); - expect(item['ns:name']).to.equal('d'); - expect(item.meta).to.eql({ size: 1, color: 'red' }); + expect(item['ns:name']).toBe('d'); + expect(item.meta).toEqual({ size: 1, color: 'red' }); }); - it('update() should write an undeclared key with a query property to the json path', async () => { - await Item.query() - .update({ id: 1, 'ns:name': 'e', 'meta:color': ref('id') }) - .where('id', 1); + it('patch() should write an undeclared key with a colon to the json path when built before', async () => { + const query = Item.query().patch({ 'meta:color': 'red' }).where('id', 1); + query.toKnexQuery(); + query.clone().toKnexQuery(); + expect(await query).toBe(1); const item = await Item.query().findById(1); - expect(item.meta).to.eql({ size: 1, color: 1 }); + expect(item.meta).toEqual({ size: 1, color: 'red' }); + }); + + it('upsertGraph() should not return the undeclared keys with a colon', async () => { + const item = await Item.query().upsertGraph({ id: 1, 'meta:color': 'red' }); + expect(item).not.toHaveProperty('meta:color'); + }); + + it('patchAndFetchById() should not return the undeclared keys with a colon', async () => { + const item = await Item.query().patchAndFetchById(1, { 'meta:color': 'red' }); + expect(item).not.toHaveProperty('meta:color'); + expect(item.meta).toEqual({ size: 1, color: 'red' }); + }); + + it('$query().patch() should not set the keys with a colon mapped by snakeCaseMappers', async () => { + class SnakeCaseItem extends Item { + static get columnNameMappers() { + return snakeCaseMappers(); + } + } + + const item = await SnakeCaseItem.query().findById(1); + await item.$query().patch({ 'extraMeta:color': 'red' }); + expect(item).not.toHaveProperty('extraMeta:color'); + const row = await knex('field_expression_keys').where('id', 1).first(); + expect(parseJson(row.extra_meta)).toEqual({ size: 1, color: 'red' }); }); it('patch() should pick an undeclared key with a colon by its json column with pickJsonSchemaProperties', async () => { @@ -167,19 +296,19 @@ module.exports = (session) => { } } - expect(await PickItem.query().patch({ 'meta:color': 'red' }).where('id', 1)).to.equal(1); + expect(await PickItem.query().patch({ 'meta:color': 'red' }).where('id', 1)).toBe(1); await PickItem.query() .patch({ 'ns:name': 'f', 'meta:size': 2, 'other:a': 1 }) .where('id', 1); const item = await Item.query().findById(1); - expect(item['ns:name']).to.equal('f'); - expect(item.meta).to.eql({ size: 2, color: 'red' }); + expect(item['ns:name']).toBe('f'); + expect(item.meta).toEqual({ size: 2, color: 'red' }); }); it('upsertGraph() should write an undeclared key with a colon to the json path', async () => { await Item.query().upsertGraph({ id: 1, 'meta:color': 'red' }); const item = await Item.query().findById(1); - expect(item.meta).to.eql({ size: 1, color: 'red' }); + expect(item.meta).toEqual({ size: 1, color: 'red' }); }); it('upsertGraph() should write an undeclared key with a colon to the json path with pickJsonSchemaProperties', async () => { @@ -191,26 +320,41 @@ module.exports = (session) => { await PickItem.query().upsertGraph({ id: 1, 'ns:name': 'g', 'meta:color': 'red' }); const item = await Item.query().findById(1); - expect(item['ns:name']).to.equal('g'); - expect(item.meta).to.eql({ size: 1, color: 'red' }); + expect(item['ns:name']).toBe('g'); + expect(item.meta).toEqual({ size: 1, color: 'red' }); }); it('merge() should write an undeclared key with a colon to the json path', async () => { await Item.query().insert({ id: 1 }).onConflict('id').merge({ 'meta:color': 'red' }); const item = await Item.query().findById(1); - expect(item.meta).to.eql({ size: 1, color: 'red' }); + expect(item.meta).toEqual({ size: 1, color: 'red' }); }); - it('merge() should write a key with a colon to the json path of an aliased table', async () => { - await Item.query() - .alias('i') + it('merge() should write an undeclared key with a colon to the json path when built before', async () => { + const query = Item.query() .insert({ id: 1 }) .onConflict('id') - .merge({ 'meta:color': 'red', 'meta:size': 2 }); + .merge({ 'meta:color': 'red' }); + query.toKnexQuery(); + await query; const item = await Item.query().findById(1); - expect(item.meta).to.eql({ size: 2, color: 'red' }); + expect(item.meta).toEqual({ size: 1, color: 'red' }); }); + // knex doesn't support aliases in inserts on MySQL. + it.skipIf(session.isMySql())( + 'merge() should write a key with a colon to the json path of an aliased table', + async () => { + await Item.query() + .alias('i') + .insert({ id: 1 }) + .onConflict('id') + .merge({ 'meta:color': 'red', 'meta:size': 2 }); + const item = await Item.query().findById(1); + expect(item.meta).toEqual({ size: 2, color: 'red' }); + }, + ); + it('merge() should write a key with a colon mapped by snakeCaseMappers to the json path', async () => { class SnakeCaseItem extends Item { static get columnNameMappers() { @@ -223,7 +367,7 @@ module.exports = (session) => { .onConflict('id') .merge({ 'extraMeta:color': 'red' }); const row = await knex('field_expression_keys').where('id', 1).first(); - expect(row.extra_meta).to.eql({ size: 1, color: 'red' }); + expect(parseJson(row.extra_meta)).toEqual({ size: 1, color: 'red' }); }); it('patch() should not parse the keys of models without a schema as database json', async () => { @@ -239,8 +383,85 @@ module.exports = (session) => { await SchemalessItem.query(knex).patch({ 'meta:color': 'blue' }).where('id', 1); const row = await knex('field_expression_keys').where('id', 1).first(); - expect(row.meta).to.eql({ size: 1, color: 'blue' }); + expect(parseJson(row.meta)).toEqual({ size: 1, color: 'blue' }); }); } }); + + describe.skipIf(session.isMsSql())('upsertGraph() with keys in json paths', () => { + const { knex } = session; + + class Pet extends Model { + static get tableName() { + return 'json_key_pet'; + } + + static get jsonAttributes() { + return ['data']; + } + } + + class Person extends Model { + static get tableName() { + return 'json_key_person'; + } + + static get relationMappings() { + return { + pets: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: 'json_key_person.id', to: 'json_key_pet.data:ownerId' }, + }, + sitted: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: 'json_key_person.id', to: 'json_key_pet.data:sitterId' }, + }, + }; + } + } + + beforeAll(async () => { + await knex.schema + .dropTableIfExists('json_key_pet') + .dropTableIfExists('json_key_person') + .createTable('json_key_person', (table) => { + table.integer('id').primary(); + }) + .createTable('json_key_pet', (table) => { + table.integer('id').primary(); + table.jsonb('data'); + }); + }); + + afterAll(() => + knex.schema.dropTableIfExists('json_key_pet').dropTableIfExists('json_key_person'), + ); + + // The places use different json paths of one column. The relate writes the + // whole json column, so the unrelate shows in its query: + it('should unrelate a row that another relation on another key keeps', async () => { + await knex('json_key_person').insert([{ id: 1 }, { id: 2 }]); + await knex('json_key_pet').insert({ id: 3, data: JSON.stringify({ ownerId: 1 }) }); + + const queries = []; + const onQuery = ({ sql }) => queries.push(sql); + knex.on('query', onQuery); + + try { + await Person.query(knex).upsertGraph( + [ + { id: 1, pets: [] }, + { id: 2, sitted: [{ id: 3 }] }, + ], + { relate: true }, + ); + } finally { + knex.removeListener('query', onQuery); + } + + expect(queries.filter((sql) => /^update .*ownerId/i.test(sql))).toHaveLength(1); + }); + }); }; diff --git a/tests/integration/misc/generatedId.js b/tests/integration/misc/generatedId.js index 51eb7e88a..645ed8275 100644 --- a/tests/integration/misc/generatedId.js +++ b/tests/integration/misc/generatedId.js @@ -1,11 +1,11 @@ -const expect = require('expect.js'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('generated id', () => { let TestModel; - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('generated_id_test') .createTable('generated_id_test', (table) => { @@ -14,11 +14,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema.dropTableIfExists('generated_id_test'); }); - before(() => { + beforeAll(() => { TestModel = class TestModel extends Model { static get tableName() { return 'generated_id_test'; @@ -40,11 +40,11 @@ module.exports = (session) => { return TestModel.query() .insert({ value: 'hello' }) .then((ret) => { - expect(ret.idCol).to.equal('someRandomId'); + expect(ret.idCol).toBe('someRandomId'); return session.knex(TestModel.getTableName()); }) .then((rows) => { - expect(rows[0]).to.eql({ value: 'hello', idCol: 'someRandomId' }); + expect(rows[0]).toEqual({ value: 'hello', idCol: 'someRandomId' }); }); }); }); diff --git a/tests/integration/misc/hasOneTree.js b/tests/integration/misc/hasOneTree.js index 2002d2e8a..86e283f66 100644 --- a/tests/integration/misc/hasOneTree.js +++ b/tests/integration/misc/hasOneTree.js @@ -1,10 +1,11 @@ -const { Model } = require('../../../'); +import { describe, it, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('has one relation tree', () => { let TestModel; - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('has_one_relation_tree_test') .createTable('has_one_relation_tree_test', (table) => { @@ -14,11 +15,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema.dropTableIfExists('has_one_relation_tree_test'); }); - before(() => { + beforeAll(() => { TestModel = class TestModel extends Model { static get tableName() { return 'has_one_relation_tree_test'; diff --git a/tests/integration/misc/index.js b/tests/integration/misc/index.js index 3cb3f09e0..20411d432 100644 --- a/tests/integration/misc/index.js +++ b/tests/integration/misc/index.js @@ -1,11 +1,21 @@ -const fs = require('fs'); -const path = require('path'); +import { describe } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; -module.exports = (session) => { +const tests = await Promise.all( + fs + .readdirSync(import.meta.dirname) + .filter((file) => file.endsWith('.js')) + .filter((file) => file !== 'index.js') + .sort() + .map( + async (file) => (await import(pathToFileURL(path.join(import.meta.dirname, file)))).default, + ), +); + +export default (session) => { describe('misc', () => { - fs.readdirSync(__dirname) - .filter((file) => file.endsWith('.js')) - .filter((file) => file !== 'index.js') - .forEach((file) => require(path.join(__dirname, file))(session)); + tests.forEach((test) => test(session)); }); }; diff --git a/tests/integration/misc/#1074.js b/tests/integration/misc/issue1074.js similarity index 85% rename from tests/integration/misc/#1074.js rename to tests/integration/misc/issue1074.js index aeb5389e2..c34ae22cb 100644 --- a/tests/integration/misc/#1074.js +++ b/tests/integration/misc/issue1074.js @@ -1,12 +1,12 @@ -const { Model } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`selects in relationMapping filters don't work #1074`, () => { let knex = session.knex; let Person; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('cousins') .dropTableIfExists('persons') @@ -21,11 +21,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('cousins').dropTableIfExists('persons'); }); - before(() => { + beforeAll(() => { Person = class Person extends Model { static get tableName() { return 'persons'; @@ -88,8 +88,8 @@ module.exports = (session) => { .withGraphFetched({ parent: true, cousins: true }) .where('name', 'Matti') .then((result) => { - expect(Object.keys(result[0].parent)).to.eql(['name']); - expect(Object.keys(result[0].cousins[0])).to.eql(['name']); + expect(Object.keys(result[0].parent)).toEqual(['name']); + expect(Object.keys(result[0].cousins[0])).toEqual(['name']); }); }); }); diff --git a/tests/integration/misc/#1202.js b/tests/integration/misc/issue1202.js similarity index 84% rename from tests/integration/misc/#1202.js rename to tests/integration/misc/issue1202.js index f80856cf0..bbd361049 100644 --- a/tests/integration/misc/#1202.js +++ b/tests/integration/misc/issue1202.js @@ -1,13 +1,13 @@ -const { Model } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`relations should be loaded lazily #1202`, () => { let knex = session.knex; let Person; let loadedRelations = []; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('cousins') .dropTableIfExists('persons') @@ -22,7 +22,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('cousins').dropTableIfExists('persons'); }); @@ -98,7 +98,7 @@ module.exports = (session) => { return Person.query() .insert({ name: 'Arnold' }) .then(() => { - expect(loadedRelations).to.have.length(0); + expect(loadedRelations).toHaveLength(0); }); }); @@ -107,7 +107,7 @@ module.exports = (session) => { .patch({ name: 'Arnold' }) .findById(1) .then(() => { - expect(loadedRelations).to.have.length(0); + expect(loadedRelations).toHaveLength(0); }); }); @@ -115,8 +115,8 @@ module.exports = (session) => { return Person.query() .findOne({ name: 'Arnold' }) .then((result) => { - expect(result).to.containSubset({ name: 'Arnold' }); - expect(loadedRelations).to.have.length(0); + expect(result).toMatchObject({ name: 'Arnold' }); + expect(loadedRelations).toHaveLength(0); }); }); @@ -126,7 +126,7 @@ module.exports = (session) => { .then((result) => { result.toJSON(); result.$toDatabaseJson(); - expect(loadedRelations).to.have.length(0); + expect(loadedRelations).toHaveLength(0); }); }); @@ -134,11 +134,11 @@ module.exports = (session) => { return Person.query() .withGraphFetched('parent') .then(() => { - expect(loadedRelations).to.eql(['parent']); + expect(loadedRelations).toEqual(['parent']); return Person.query().withGraphFetched('cousins'); }) .then(() => { - expect(loadedRelations).to.eql(['parent', 'cousins']); + expect(loadedRelations).toEqual(['parent', 'cousins']); }); }); @@ -146,11 +146,11 @@ module.exports = (session) => { return Person.query() .withGraphJoined('parent') .then(() => { - expect(loadedRelations).to.eql(['parent']); + expect(loadedRelations).toEqual(['parent']); return Person.query().withGraphJoined('cousins'); }) .then(() => { - expect(loadedRelations).to.eql(['parent', 'cousins']); + expect(loadedRelations).toEqual(['parent', 'cousins']); }); }); @@ -161,7 +161,7 @@ module.exports = (session) => { return arnold.$relatedQuery('cousins'); }) .then(() => { - expect(loadedRelations).to.eql(['cousins']); + expect(loadedRelations).toEqual(['cousins']); }); }); @@ -174,7 +174,7 @@ module.exports = (session) => { }, }) .then(() => { - expect(loadedRelations).to.eql(['parent']); + expect(loadedRelations).toEqual(['parent']); }); }); @@ -188,7 +188,7 @@ module.exports = (session) => { ], }); - expect(loadedRelations).to.eql(['cousins']); + expect(loadedRelations).toEqual(['cousins']); }); }); }; diff --git a/tests/integration/misc/#1215.js b/tests/integration/misc/issue1215.js similarity index 93% rename from tests/integration/misc/#1215.js rename to tests/integration/misc/issue1215.js index 44378b46c..f67100931 100644 --- a/tests/integration/misc/#1215.js +++ b/tests/integration/misc/issue1215.js @@ -1,12 +1,12 @@ -const { Model } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`joinRelated in filter in model relationMappings #1215`, () => { let knex = session.knex; let Person; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('cousins') .dropTableIfExists('persons') @@ -21,7 +21,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('cousins').dropTableIfExists('persons'); }); @@ -119,7 +119,7 @@ module.exports = (session) => { cousins: true, }) .then((result) => { - expect(result).to.containSubset({ + expect(result).toContainSubset({ name: 'Matti', children: [ diff --git a/tests/integration/misc/#1223.js b/tests/integration/misc/issue1223.js similarity index 95% rename from tests/integration/misc/#1223.js rename to tests/integration/misc/issue1223.js index 6fc641ac8..193738fe2 100644 --- a/tests/integration/misc/#1223.js +++ b/tests/integration/misc/issue1223.js @@ -1,12 +1,12 @@ -const { Model } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`Recursive eagering with an alias doesn't work with 1-m or m-m relations #1223`, () => { let knex = session.knex; let Person; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('cousins') .dropTableIfExists('persons') @@ -21,7 +21,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('cousins').dropTableIfExists('persons'); }); @@ -138,7 +138,7 @@ module.exports = (session) => { }, }) .then((result) => { - expect(result).to.containSubset({ + expect(result).toContainSubset({ parentId: null, name: 'Root', alias1: [ diff --git a/tests/integration/misc/#1227.js b/tests/integration/misc/issue1227.js similarity index 89% rename from tests/integration/misc/#1227.js rename to tests/integration/misc/issue1227.js index 6978349da..4b38d684f 100644 --- a/tests/integration/misc/#1227.js +++ b/tests/integration/misc/issue1227.js @@ -1,12 +1,12 @@ -const { Model } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`primary key to primary key relations with upsertGraph #1227`, () => { let knex = session.knex; let Person, Animal; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('cousins') .dropTableIfExists('persons') @@ -20,7 +20,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('pets').dropTableIfExists('persons'); }); @@ -72,7 +72,7 @@ module.exports = (session) => { return Person.query().findById(person.id).withGraphFetched('pet'); }) .then((person) => { - expect(person).to.containSubset({ + expect(person).toMatchObject({ name: 'person', pet: { name: 'pet', diff --git a/tests/integration/misc/#1265.js b/tests/integration/misc/issue1265.js similarity index 91% rename from tests/integration/misc/#1265.js rename to tests/integration/misc/issue1265.js index d0098d93b..d8168a5ee 100644 --- a/tests/integration/misc/#1265.js +++ b/tests/integration/misc/issue1265.js @@ -1,12 +1,12 @@ -const { Model } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`upsertGraph with composite key relation doesn't copy foreign keys #1265`, () => { let knex = session.knex; let Person, Animal; - before(() => { + beforeAll(() => { return knex.schema .createTable('Person', (table) => { table.increments('id').primary(); @@ -24,7 +24,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('Animal').dropTableIfExists('Person'); }); @@ -115,8 +115,8 @@ module.exports = (session) => { return Person.query().findOne({ firstName: 'Jennifer' }).withGraphFetched('pets'); }) .then((jennifer) => { - expect(jennifer.pets).to.have.length(3); - expect(jennifer.pets).to.containSubset([ + expect(jennifer.pets).toHaveLength(3); + expect(jennifer.pets).toContainSubset([ { name: 'Doggo', species: 'dog', diff --git a/tests/integration/misc/#1455.js b/tests/integration/misc/issue1455.js similarity index 95% rename from tests/integration/misc/#1455.js rename to tests/integration/misc/issue1455.js index 56bffb562..b45b1512b 100644 --- a/tests/integration/misc/#1455.js +++ b/tests/integration/misc/issue1455.js @@ -1,7 +1,7 @@ -const { Model, transaction } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Model, transaction } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('UpsertGraph deletes rows for relation which is not mentioned in graph #1455', () => { let knex = session.knex; let Role; @@ -137,7 +137,7 @@ module.exports = (session) => { }); }) .then((setsAfterUpsertGraph) => { - expect(setsAfterUpsertGraph).to.containSubset({ + expect(setsAfterUpsertGraph).toContainSubset({ id: 1, name: 'First Role', sets: [ diff --git a/tests/integration/misc/#1467.js b/tests/integration/misc/issue1467.js similarity index 94% rename from tests/integration/misc/#1467.js rename to tests/integration/misc/issue1467.js index c3cf12a52..1bae9d3d3 100644 --- a/tests/integration/misc/#1467.js +++ b/tests/integration/misc/issue1467.js @@ -1,7 +1,7 @@ -const { Model, snakeCaseMappers } = require('../../../'); -const chai = require('chai'); +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Model, snakeCaseMappers } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('Not CamelCasing ref.column #1467', () => { let knex = session.knex; let Campaign, Deliverable; @@ -153,7 +153,7 @@ module.exports = (session) => { return Campaign.query().findOne({}).withGraphFetched('cogs'); }) .then((c1) => { - chai.expect(c1.cogs.length).to.equal(1); + expect(c1.cogs.length).toBe(1); }) .then(() => { return Campaign.query().upsertGraph( @@ -165,7 +165,7 @@ module.exports = (session) => { return Campaign.query().findOne({}).withGraphFetched('cogs'); }) .then((c2) => { - chai.expect(c2.cogs.length).to.equal(0); + expect(c2.cogs.length).toBe(0); }) .then(() => { return Deliverable.query().upsertGraph( @@ -177,7 +177,7 @@ module.exports = (session) => { return Deliverable.query().findOne({}).withGraphFetched('cogs'); }) .then((d1) => { - chai.expect(d1.cogs.length).to.equal(1); + expect(d1.cogs.length).toBe(1); }) .then(() => { return Deliverable.query().upsertGraph( @@ -189,7 +189,7 @@ module.exports = (session) => { return Deliverable.query().findOne({}).withGraphFetched('cogs'); }) .then((d2) => { - chai.expect(d2.cogs.length).to.equal(0); + expect(d2.cogs.length).toBe(0); }); }); }); diff --git a/tests/integration/misc/#1489.js b/tests/integration/misc/issue1489.js similarity index 88% rename from tests/integration/misc/#1489.js rename to tests/integration/misc/issue1489.js index a74c760ac..e00c3ba36 100644 --- a/tests/integration/misc/#1489.js +++ b/tests/integration/misc/issue1489.js @@ -1,13 +1,13 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('relation $beforeInsert not called when insertGraph is used #1627', () => { let knex = session.knex; let User; let Role; - before(() => { + beforeAll(() => { const { knex } = session; return Promise.resolve() @@ -27,7 +27,7 @@ module.exports = (session) => { }); }); - after(async () => { + afterAll(async () => { await knex.schema.dropTableIfExists('roles'); await knex.schema.dropTableIfExists('users'); }); @@ -74,7 +74,7 @@ module.exports = (session) => { role: 'admin', }); - expect(role.$relationBeforeInsertCalled).to.equal(1); + expect(role.$relationBeforeInsertCalled).toBe(1); }); }); }; diff --git a/tests/integration/misc/#1627.js b/tests/integration/misc/issue1627.js similarity index 88% rename from tests/integration/misc/#1627.js rename to tests/integration/misc/issue1627.js index ae70b32ae..26992aa30 100644 --- a/tests/integration/misc/#1627.js +++ b/tests/integration/misc/issue1627.js @@ -1,8 +1,8 @@ -const crypto = require('crypto'); -const { expect } = require('chai'); -const { Model, snakeCaseMappers } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import crypto from 'node:crypto'; +import { Model, snakeCaseMappers } from 'objection'; -module.exports = (session) => { +export default (session) => { if (!session.isMySql()) { return; } @@ -12,7 +12,7 @@ module.exports = (session) => { let User; let Role; - before(() => { + beforeAll(() => { const { knex } = session; return Promise.resolve() @@ -30,7 +30,7 @@ module.exports = (session) => { }); }); - after(async () => { + afterAll(async () => { await knex.schema.dropTableIfExists('roles'); await knex.schema.dropTableIfExists('users'); }); @@ -82,7 +82,7 @@ module.exports = (session) => { const result = await User.query().findById(inserted.id).withGraphFetched('roles'); - expect(result).to.eql(inserted); + expect(result).toEqual(inserted); }); it('should fetch multiple relations correctly', async () => { const ids = [ @@ -96,7 +96,7 @@ module.exports = (session) => { })); const inserted = await User.query().insertGraph(graph); const result = await User.query().findByIds(ids).withGraphFetched('roles'); - expect(result).to.eql(inserted); + expect(result).toEqual(inserted); }); }); }; diff --git a/tests/integration/misc/#1718.js b/tests/integration/misc/issue1718.js similarity index 75% rename from tests/integration/misc/#1718.js rename to tests/integration/misc/issue1718.js index 0af00b510..8b93a143d 100644 --- a/tests/integration/misc/#1718.js +++ b/tests/integration/misc/issue1718.js @@ -1,9 +1,9 @@ -const expect = require('expect.js'); -const { Model } = require('../../../'); -const { AjvValidator } = require('../../../lib/model/AjvValidator'); -const { ValidationError } = require('../../../lib/model/ValidationError'); +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Model } from 'objection'; +import { AjvValidator } from '../../../lib/model/AjvValidator.js'; +import { ValidationError } from '../../../lib/model/ValidationError.js'; -module.exports = (session) => { +export default (session) => { describe('Pass through data in exceptions when Ajv verbose option is enabled #1718', () => { class MyModel extends Model { static get tableName() { @@ -52,10 +52,10 @@ module.exports = (session) => { return MyModel.query(session.knex) .insert({ id: 2 }) .catch((err) => { - expect(err).to.be.an(ValidationError); - expect(err.data).to.be.an('object'); - expect(err.data.id).to.be.an('array'); - expect(err.data).to.eql({ + expect(err).toBeInstanceOf(ValidationError); + expect(err.data).toBeTypeOf('object'); + expect(err.data.id).toBeInstanceOf(Array); + expect(err.data).toEqual({ id: [ { message: 'must be integer', diff --git a/tests/integration/misc/#1757.js b/tests/integration/misc/issue1757.js similarity index 91% rename from tests/integration/misc/#1757.js rename to tests/integration/misc/issue1757.js index 553f6a5a4..eeea8f8e6 100644 --- a/tests/integration/misc/#1757.js +++ b/tests/integration/misc/issue1757.js @@ -1,7 +1,7 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('skipFetched not working for nested relation #1757', () => { let knex = session.knex; @@ -61,7 +61,7 @@ module.exports = (session) => { } } - before(async () => { + beforeAll(async () => { const { knex } = session; await knex.schema.dropTableIfExists('Animal'); @@ -84,7 +84,7 @@ module.exports = (session) => { }); }); - after(async () => { + afterAll(async () => { await knex.schema.dropTableIfExists('Animal'); await knex.schema.dropTableIfExists('Person'); }); @@ -113,7 +113,7 @@ module.exports = (session) => { await doggo.$fetchGraph('owner', { transaction: knex }); await doggo.$fetchGraph('owner.parent', { transaction: knex }); - expect(doggo.owner.parent.firstName).to.equal('JL'); + expect(doggo.owner.parent.firstName).toBe('JL'); // Reload doggo doggo = await Animal.query(knex).findOne({ name: 'Doggo' }); @@ -121,7 +121,7 @@ module.exports = (session) => { await doggo.$fetchGraph('owner', { skipFetched: true, transaction: knex }); await doggo.$fetchGraph('owner.parent', { skipFetched: true, transaction: knex }); - expect(doggo.owner.parent.firstName).to.equal('JL'); + expect(doggo.owner.parent.firstName).toBe('JL'); }); }); }; diff --git a/tests/integration/misc/#1803.js b/tests/integration/misc/issue1803.js similarity index 90% rename from tests/integration/misc/#1803.js rename to tests/integration/misc/issue1803.js index b39739c71..0476caf2e 100644 --- a/tests/integration/misc/#1803.js +++ b/tests/integration/misc/issue1803.js @@ -1,14 +1,14 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('HasOneThroughRelation with the related table as the join table #1803', () => { let knex = session.knex; let Team; let Form; let User; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('teams_forms_1803') .dropTableIfExists('teams_1803') @@ -33,7 +33,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema .dropTableIfExists('teams_forms_1803') .dropTableIfExists('teams_1803') @@ -41,7 +41,7 @@ module.exports = (session) => { .dropTableIfExists('users_1803'); }); - before(() => { + beforeAll(() => { User = class User extends Model { static get tableName() { return 'users_1803'; @@ -138,7 +138,7 @@ module.exports = (session) => { it('withGraphFetched', async () => { const forms = await Form.query().withGraphFetched('manager').orderBy('id'); - expect(forms.map((form) => form.manager && form.manager.username)).to.eql([ + expect(forms.map((form) => form.manager && form.manager.username)).toEqual([ 'manager', 'boss', null, @@ -148,7 +148,7 @@ module.exports = (session) => { it('withGraphJoined', async () => { const forms = await Form.query().withGraphJoined('manager').orderBy('forms_1803.id'); - expect(forms.map((form) => form.manager && form.manager.username)).to.eql([ + expect(forms.map((form) => form.manager && form.manager.username)).toEqual([ 'manager', 'boss', null, @@ -159,14 +159,14 @@ module.exports = (session) => { const form = await Form.query().findById(1); const manager = await form.$relatedQuery('manager'); - expect(manager.username).to.equal('manager'); + expect(manager.username).toBe('manager'); }); it('$relatedQuery with an alias', async () => { const form = await Form.query().findById(2); const manager = await form.$relatedQuery('manager').alias('m').where('m.id', '>', 0); - expect(manager.username).to.equal('boss'); + expect(manager.username).toBe('boss'); }); it('relatedQuery', async () => { @@ -174,15 +174,15 @@ module.exports = (session) => { .select('id', Form.relatedQuery('manager').select('manager.username').as('managerUsername')) .orderBy('id'); - expect(forms.map((form) => form.managerUsername)).to.eql(['manager', 'boss', null]); + expect(forms.map((form) => form.managerUsername)).toEqual(['manager', 'boss', null]); }); it('withGraphFetched with extra properties', async () => { const forms = await Form.query().withGraphFetched('managerWithRole').orderBy('id'); - expect(forms.map((form) => form.managerWithRole && form.managerWithRole.employeeRole)).to.eql( - ['dev', 'cto', null], - ); + expect( + forms.map((form) => form.managerWithRole && form.managerWithRole.employeeRole), + ).toEqual(['dev', 'cto', null]); }); it('upsertGraph with unchanged extra properties', async () => { @@ -197,19 +197,19 @@ module.exports = (session) => { it('$relatedQuery patch', async () => { const form = await Form.query().findById(1); - expect(await form.$relatedQuery('manager').patch({ role: 'cfo' })).to.equal(1); + expect(await form.$relatedQuery('manager').patch({ role: 'cfo' })).toBe(1); await expectUsers([1, 'ceo'], [2, 'cfo'], [3, 'dev']); }); it('$relatedQuery delete', async () => { const form = await Form.query().findById(1); - expect(await form.$relatedQuery('manager').delete()).to.equal(1); + expect(await form.$relatedQuery('manager').delete()).toBe(1); await expectUsers([1, 'ceo'], [3, 'dev']); }); it('relatedQuery delete', async () => { - expect(await Form.relatedQuery('manager').for(2).delete()).to.equal(1); + expect(await Form.relatedQuery('manager').for(2).delete()).toBe(1); await expectUsers([2, 'cto'], [3, 'dev']); }); @@ -322,7 +322,7 @@ module.exports = (session) => { { relate: true }, ); - expect(await knex('teams_forms_1803')).to.eql([{ teamId: 1, formId: 1 }]); + expect(await knex('teams_forms_1803')).toEqual([{ teamId: 1, formId: 1 }]); await expectUsersUnchanged(); }); @@ -347,22 +347,22 @@ module.exports = (session) => { error = err; } - expect(error).to.be.an.instanceOf(Error); - expect(error.message).to.contain(message); + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain(message); } async function expectUsers(...expected) { const rows = await knex('users_1803').orderBy('id'); - expect(rows.map((row) => [row.id, row.role])).to.eql(expected); + expect(rows.map((row) => [row.id, row.role])).toEqual(expected); } async function expectForms(...expected) { const rows = await knex('forms_1803').orderBy('id'); - expect(rows.map((row) => row.id)).to.eql(expected); + expect(rows.map((row) => row.id)).toEqual(expected); } async function expectUsersUnchanged() { - expect(await knex('users_1803').orderBy('id')).to.eql(users); + expect(await knex('users_1803').orderBy('id')).toEqual(users); } }); }; diff --git a/tests/integration/misc/#1832.js b/tests/integration/misc/issue1832.js similarity index 51% rename from tests/integration/misc/#1832.js rename to tests/integration/misc/issue1832.js index ea0cb901a..e29220bf0 100644 --- a/tests/integration/misc/#1832.js +++ b/tests/integration/misc/issue1832.js @@ -1,9 +1,8 @@ -const { Model } = require('../../../'); -const { expect } = require('chai'); -const { resetWarnings } = require('../../../lib/utils/warnings'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { - describe('warn when fetching relations of owners without join properties #1832', () => { +export default (session) => { + describe('throw when fetching relations of owners loaded without join properties #1832', () => { const { knex } = session; class Person extends Model { @@ -102,10 +101,7 @@ module.exports = (session) => { } } - let warnings; - let originalWarn; - - before(async () => { + beforeAll(async () => { await knex.schema .dropTableIfExists('issue1832AnimalTag') .dropTableIfExists('issue1832AnimalKeeper') @@ -141,7 +137,7 @@ module.exports = (session) => { await knex('issue1832AnimalTag').insert({ animalId: 1, tag: 'good' }); }); - after(async () => { + afterAll(async () => { await knex.schema .dropTableIfExists('issue1832AnimalTag') .dropTableIfExists('issue1832AnimalKeeper') @@ -149,152 +145,170 @@ module.exports = (session) => { .dropTableIfExists('issue1832Person'); }); - beforeEach(() => { - resetWarnings(); - warnings = []; - originalWarn = console.warn; - console.warn = (message) => warnings.push(message); - }); + const missingIdError = + 'Unable to fetch relation "pets" of Person: some owner models loaded from the database ' + + 'are missing the join property "issue1832Person.id". Select the column in the owner query.'; - afterEach(() => { - console.warn = originalWarn; - resetWarnings(); - }); + it('$fetchGraph() should throw if the id is not selected for a HasManyRelation', async () => { + const jennifer = await Person.query(knex).findById(1).select('name'); - const expectedWarning = - '[objection] Fetching relation "pets" of Person: some owner models are missing the join ' + - 'property "issue1832Person.id", so no related models are found for them. ' + - 'Select the column in the owner query.'; + await expect(jennifer.$fetchGraph('pets', { transaction: knex })).rejects.toThrow( + missingIdError, + ); + expect(jennifer.pets).toBeUndefined(); + }); - it('$fetchGraph() should warn if the id is not selected for a HasManyRelation', async () => { + it('$relatedQuery() should throw if the id is not selected for a HasManyRelation', async () => { const jennifer = await Person.query(knex).findById(1).select('name'); - await jennifer.$fetchGraph('pets', { transaction: knex }); - expect(jennifer.pets).to.eql([]); - expect(warnings).to.eql([expectedWarning]); + await expect(jennifer.$relatedQuery('pets', knex)).rejects.toThrow(missingIdError); }); - it('$relatedQuery() should warn if the id is not selected for a HasManyRelation', async () => { + it('should throw if the id of some of the owners is not selected', async () => { + const people = [ + ...(await Person.query(knex).select('id', 'name')), + ...(await Person.query(knex).select('name')), + ]; + + await expect(Person.fetchGraph(people, 'pets', { transaction: knex })).rejects.toThrow( + missingIdError, + ); + }); + + it('should throw for clones of owners loaded without the id', async () => { const jennifer = await Person.query(knex).findById(1).select('name'); - const pets = await jennifer.$relatedQuery('pets', knex); + const clone = jennifer.$clone(); - expect(pets).to.eql([]); - expect(warnings).to.eql([expectedWarning]); + expect(clone.toJSON()).toEqual({ name: 'Jennifer' }); + await expect(clone.$fetchGraph('pets', { transaction: knex })).rejects.toThrow( + missingIdError, + ); }); - it('should only warn once per relation', async () => { - const people = await Person.query(knex).select('name'); - await Person.fetchGraph(people, 'pets', { transaction: knex }); - await Person.fetchGraph(people, 'pets', { transaction: knex }); + it('should find no related models for owners built in memory without the id', async () => { + const jennifer = Person.fromJson({ name: 'Jennifer' }); + await jennifer.$fetchGraph('pets', { transaction: knex }); + const pets = await Person.fromJson({ name: 'Jennifer' }).$relatedQuery('pets', knex); - expect(warnings).to.eql([expectedWarning]); + expect(jennifer.pets).toEqual([]); + expect(pets).toEqual([]); }); - it('should not warn if the foreign key is selected or null', async () => { + it('should not throw if the foreign key is selected or null', async () => { const animals = await Animal.query(knex).orderBy('id'); await Animal.fetchGraph(animals, 'owner', { transaction: knex }); - expect(animals.map((it) => it.owner && it.owner.name)).to.eql(['Jennifer', null]); - expect(warnings).to.eql([]); + expect(animals.map((it) => it.owner && it.owner.name)).toEqual(['Jennifer', null]); }); - it('withGraphFetched() should not warn if the foreign key is not selected', async () => { + it('withGraphFetched() should not throw if the foreign key is not selected', async () => { const doggo = await Animal.query(knex) .findById(1) .select('id', 'name') .withGraphFetched('owner'); - expect(doggo.owner.name).to.equal('Jennifer'); - expect(warnings).to.eql([]); + expect(doggo.owner.name).toBe('Jennifer'); }); - it('$fetchGraph() should warn if a non-id column is not selected for a HasManyRelation', async () => { + it('$fetchGraph() should throw if a non-id column is not selected for a HasManyRelation', async () => { const jennifer = await Person.query(knex).findById(1).select('id', 'name'); - await jennifer.$fetchGraph('petsByCode', { transaction: knex }); - expect(jennifer.petsByCode).to.eql([]); - expect(warnings).to.eql([ - '[objection] Fetching relation "petsByCode" of Person: some owner models are missing the ' + - 'join property "issue1832Person.code", so no related models are found for them. ' + - 'Select the column in the owner query.', - ]); + await expect(jennifer.$fetchGraph('petsByCode', { transaction: knex })).rejects.toThrow( + 'Unable to fetch relation "petsByCode" of Person: some owner models loaded from the ' + + 'database are missing the join property "issue1832Person.code". Select the column in ' + + 'the owner query.', + ); }); - it('$fetchGraph() should warn if a foreign key in the composite id is not selected', async () => { + it('$fetchGraph() should throw if a foreign key in the composite id is not selected', async () => { const tag = await Tag.query(knex).first().select('tag'); - await tag.$fetchGraph('animal', { transaction: knex }); - expect(tag.animal).to.equal(null); - expect(warnings).to.eql([ - '[objection] Fetching relation "animal" of Tag: some owner models are missing the join ' + - 'property "issue1832AnimalTag.animalId", so no related models are found for them. ' + - 'Select the column in the owner query.', - ]); + await expect(tag.$fetchGraph('animal', { transaction: knex })).rejects.toThrow( + 'Unable to fetch relation "animal" of Tag: some owner models loaded from the database ' + + 'are missing the join property "issue1832AnimalTag.animalId". Select the column in ' + + 'the owner query.', + ); }); // Nullable foreign keys that were never set are `undefined` too, e.g. on // models returned by `insert()` or created with `fromJson()` (#132). - it('should not warn if the foreign key of an inserted model is not set', async () => { + it('should not throw if the foreign key of an inserted model is not set', async () => { const inserted = await Animal.query(knex).insert({ id: 3, name: 'Newcomer' }); try { await inserted.$fetchGraph('owner', { transaction: knex }); - expect(inserted.owner).to.equal(null); - expect(warnings).to.eql([]); + expect(inserted.owner).toBeNull(); } finally { await Animal.query(knex).deleteById(3); } }); - it('should not warn if the foreign key of a model created with fromJson() is not set', async () => { + it('should not throw if the foreign key of a model created with fromJson() is not set', async () => { const stray = Animal.fromJson({ id: 2, name: 'Stray' }); await stray.$fetchGraph('owner', { transaction: knex }); const owner = await stray.$relatedQuery('owner', knex); - expect(stray.owner).to.equal(null); - expect(owner).to.equal(undefined); - expect(warnings).to.eql([]); + expect(stray.owner).toBeNull(); + expect(owner).toBeUndefined(); }); - // A foreign key left out of a partial `select()` can't be told apart from - // one that was never set, so it doesn't warn either (#132). - it('$fetchGraph() should not warn if the foreign key is not selected', async () => { + // Inserted models are the instances passed to `insert()`, with only the + // returned columns merged into them, so they don't count as loaded. + it.skipIf(!session.isPostgres())( + 'should not throw if the foreign key of a model inserted with returning() is not set', + async () => { + const inserted = await Animal.query(knex) + .insert({ id: 3, name: 'Newcomer' }) + .returning('id'); + + try { + await inserted.$fetchGraph('owner', { transaction: knex }); + + expect(inserted.owner).toBeNull(); + } finally { + await Animal.query(knex).deleteById(3); + } + }, + ); + + it('$fetchGraph() should throw if the foreign key is not selected for a BelongsToOneRelation', async () => { const doggo = await Animal.query(knex).findById(1).select('id', 'name'); - await doggo.$fetchGraph('owner', { transaction: knex }); - expect(doggo.owner).to.equal(null); - expect(warnings).to.eql([]); + await expect(doggo.$fetchGraph('owner', { transaction: knex })).rejects.toThrow( + 'Unable to fetch relation "owner" of Animal: some owner models loaded from the database ' + + 'are missing the join property "issue1832Animal.ownerId". Select the column in the ' + + 'owner query.', + ); }); - const expectedThroughWarning = (relation) => - `[objection] Fetching relation "${relation}" of Animal: some owner models are missing the ` + - 'join property "issue1832Animal.id", so no related models are found for them. ' + - 'Select the column in the owner query.'; + const throughError = (relation) => + `Unable to fetch relation "${relation}" of Animal: some owner models loaded from the ` + + 'database are missing the join property "issue1832Animal.id". Select the column in the ' + + 'owner query.'; - it('$fetchGraph() should warn if the id is not selected for a ManyToManyRelation', async () => { + it('$fetchGraph() should throw if the id is not selected for a ManyToManyRelation', async () => { const doggo = await Animal.query(knex).findById(1).select('name'); - await doggo.$fetchGraph('keepers', { transaction: knex }); - expect(doggo.keepers).to.eql([]); - expect(warnings).to.eql([expectedThroughWarning('keepers')]); + await expect(doggo.$fetchGraph('keepers', { transaction: knex })).rejects.toThrow( + throughError('keepers'), + ); }); - it('$fetchGraph() should warn if the id is not selected for a HasOneThroughRelation', async () => { + it('$fetchGraph() should throw if the id is not selected for a HasOneThroughRelation', async () => { const doggo = await Animal.query(knex).findById(1).select('name'); - await doggo.$fetchGraph('keeper', { transaction: knex }); - expect(doggo.keeper).to.equal(null); - expect(warnings).to.eql([expectedThroughWarning('keeper')]); + await expect(doggo.$fetchGraph('keeper', { transaction: knex })).rejects.toThrow( + throughError('keeper'), + ); }); - it('should not warn if the id is selected for a ManyToManyRelation', async () => { + it('should not throw if the id is selected for a ManyToManyRelation', async () => { const animals = await Animal.query(knex).orderBy('id'); await Animal.fetchGraph(animals, '[keepers, keeper]', { transaction: knex }); - expect(animals.map((it) => it.keepers.map((it) => it.name))).to.eql([['Jennifer'], []]); - expect(animals.map((it) => it.keeper && it.keeper.name)).to.eql(['Jennifer', null]); - expect(warnings).to.eql([]); + expect(animals.map((it) => it.keepers.map((it) => it.name))).toEqual([['Jennifer'], []]); + expect(animals.map((it) => it.keeper && it.keeper.name)).toEqual(['Jennifer', null]); }); }); }; diff --git a/tests/integration/misc/#1874.js b/tests/integration/misc/issue1874.js similarity index 78% rename from tests/integration/misc/#1874.js rename to tests/integration/misc/issue1874.js index 5b65b8bb3..c2c257333 100644 --- a/tests/integration/misc/#1874.js +++ b/tests/integration/misc/issue1874.js @@ -1,27 +1,27 @@ -const { Model } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`upsertGraph should not consider JSON values loosely equal to primitives #1874`, () => { let knex = session.knex; let Item; let updates; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('issue1874_items') .createTable('issue1874_items', (table) => { table.integer('id').primary(); table.boolean('flag'); - table.text('data'); + table.json('data'); }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('issue1874_items'); }); - before(() => { + beforeAll(() => { Item = class Item extends Model { static get tableName() { return 'issue1874_items'; @@ -49,8 +49,8 @@ module.exports = (session) => { await Item.query().upsertGraph({ id: 1, data: [] }); const item = await Item.query().findById(1); - expect(updates).to.equal(1); - expect(item.data).to.eql([]); + expect(updates).toBe(1); + expect(item.data).toEqual([]); }); it('should update `[]` to `false`', async () => { @@ -58,14 +58,14 @@ module.exports = (session) => { await Item.query().upsertGraph({ id: 1, data: false }); const item = await Item.query().findById(1); - expect(updates).to.equal(2); - expect(item.data).to.equal(false); + expect(updates).toBe(2); + expect(item.data).toBe(false); }); it('should still consider loosely equal primitives unchanged', async () => { await Item.query().upsertGraph({ id: 1, flag: 1, data: false }); - expect(updates).to.equal(0); + expect(updates).toBe(0); }); }); }; diff --git a/tests/integration/misc/issue2001.js b/tests/integration/misc/issue2001.js new file mode 100644 index 000000000..4a83b7c3b --- /dev/null +++ b/tests/integration/misc/issue2001.js @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; + +export default (session) => { + describe('relation queries keep the foreign keys of the models they assign #2001', () => { + let Model1; + + beforeAll(() => { + Model1 = class extends session.unboundModels.Model1 { + static get relatedFindQueryMutates() { + return true; + } + + static get relatedInsertQueryMutates() { + return true; + } + }.bindKnex(session.knex); + }); + + beforeEach(() => { + return session.populate([ + { + id: 1, + model1Prop1: 'parent', + + model1Relation1Inverse: { + id: 2, + model1Prop1: 'child', + }, + + model1Relation2: [ + { + idCol: 1, + model2Prop1: 'has many', + }, + ], + }, + ]); + }); + + it('withGraphFetched() assigning empty relations', async () => { + const models = await Model1.query() + .withGraphFetched('[model1Relation1, model1Relation1Inverse, model1Relation2]') + .modifyGraph('[model1Relation1, model1Relation1Inverse, model1Relation2]', (builder) => + builder.whereRaw('1 = 0'), + ) + .orderBy('id'); + + expect(models.map((it) => it.model1Id)).toEqual([null, 1]); + expect(models[1].model1Relation1).toBeNull(); + }); + + it('withGraphJoined() assigning empty relations', async () => { + const models = await Model1.query() + .withGraphJoined('model1Relation1') + .modifyGraph('model1Relation1', (builder) => builder.whereRaw('1 = 0')) + .orderBy('Model1.id'); + + expect(models.map((it) => it.model1Id)).toEqual([null, 1]); + expect(models[1].model1Relation1).toBeNull(); + }); + + it('$relatedQuery() assigning an empty relation', async () => { + const child = await Model1.query().findById(2); + await child.$relatedQuery('model1Relation1').whereRaw('1 = 0'); + + expect(child.model1Relation1).toBeNull(); + expect(child.model1Id).toBe(1); + }); + + it('$relatedQuery().insert() replacing the model of a HasOneRelation', async () => { + const parent = await Model1.query().findById(1).withGraphFetched('model1Relation1Inverse'); + const child = parent.model1Relation1Inverse; + const inserted = await parent + .$relatedQuery('model1Relation1Inverse') + .insert({ model1Prop1: 'new child' }); + + // Both rows still point to the parent in the database: + expect(parent.model1Relation1Inverse).toBe(inserted); + expect(inserted.model1Id).toBe(1); + expect(child.model1Id).toBe(1); + }); + }); +}; diff --git a/tests/integration/misc/issue205.js b/tests/integration/misc/issue205.js new file mode 100644 index 000000000..8d82cf622 --- /dev/null +++ b/tests/integration/misc/issue205.js @@ -0,0 +1,301 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model, snakeCaseMappers } from 'objection'; + +export default (session) => { + describe('JSON attributes are parsed once, depending on the driver #205', () => { + const { knex } = session; + + class Item extends Model { + static get tableName() { + return 'issue205Item'; + } + + static get jsonSchema() { + return { + type: 'object', + properties: { + id: { type: 'integer' }, + title: { type: ['string', 'null'] }, + meta: { type: ['object', 'string', 'null'] }, + data: { type: ['object', 'string', 'null'] }, + }, + }; + } + + static get relationMappings() { + return { + parent: { + relation: Model.BelongsToOneRelation, + modelClass: Item, + join: { + from: 'issue205Item.parentId', + to: 'issue205Item.id', + }, + }, + }; + } + } + + // JSON strings that look like JSON: + const values = ['123', 'true', 'null', '{"a":1}', { a: 1 }]; + + beforeAll(() => { + Item.knex(knex); + + return knex.schema.dropTableIfExists('issue205Item').createTable('issue205Item', (t) => { + t.integer('id').primary(); + t.integer('parentId'); + t.string('title'); + t.jsonb('meta'); + // Holds JSON as text on all databases: + t.text('data'); + }); + }); + + afterAll(() => { + return knex.schema.dropTableIfExists('issue205Item'); + }); + + beforeEach(() => { + return knex('issue205Item').delete(); + }); + + // MySQL doesn't return the ids of several inserted rows: + const insertItems = async (items) => { + for (const item of items) { + await Item.query().insert(item); + } + }; + + it('should return the values of JSON columns unchanged', async () => { + await insertItems(values.map((meta, index) => ({ id: index + 1, meta }))); + const items = await Item.query().orderBy('id'); + expect(items.map((item) => item.meta)).toEqual(values); + }); + + it('should return the values of JSON columns unchanged in withGraphJoined()', async () => { + await insertItems( + values.map((meta, index) => ({ id: index + 1, parentId: index + 1, meta })), + ); + const items = await Item.query().withGraphJoined('parent').orderBy('issue205Item.id'); + expect(items.map((item) => item.parent.meta)).toEqual(values); + }); + + it('should write the values of JSON columns back unchanged', async () => { + await Item.query().insert({ id: 1, meta: '123' }); + const item = await Item.query().findById(1); + await item.$query().patch({ title: 'patched' }); + await item.$query().update(item); + expect(await Item.query().findById(1)).toEqual( + Item.fromJson({ id: 1, parentId: null, title: 'patched', meta: '123', data: null }), + ); + }); + + if (session.isPostgres()) { + it('should merge the parsed rows of returning() into the models', async () => { + const items = await Item.query() + .insert(values.map((meta, index) => ({ id: index + 1, meta }))) + .returning('*'); + expect(items.map((item) => item.meta)).toEqual(values); + }); + + // `pg` returns JSON columns parsed, and text columns as text: + it('should leave JSON in text columns as text', async () => { + await knex('issue205Item').insert({ id: 1, data: '{"a":1}' }); + const item = await Item.query().findById(1); + expect(item.data).toBe('{"a":1}'); + }); + } else { + it('should parse JSON in text columns', async () => { + await knex('issue205Item').insert({ id: 1, data: '{"a":1}' }); + const item = await Item.query().findById(1); + expect(item.data).toEqual({ a: 1 }); + }); + + it('should keep text in JSON columns that is not JSON', async () => { + await knex('issue205Item').insert({ id: 1, data: 'not json' }); + const item = await Item.query().findById(1); + expect(item.data).toBe('not json'); + }); + } + + it("should parse JSON text strictly with jsonColumnFormat 'text'", async () => { + class TextItem extends Item { + static jsonColumnFormat = 'text'; + } + + await knex('issue205Item').insert([ + { id: 1, data: '{"a":1}' }, + { id: 2, data: 'not json' }, + ]); + expect((await TextItem.query().findById(1)).data).toEqual({ a: 1 }); + await expect(TextItem.query().findById(2)).rejects.toThrow( + "Invalid JSON in JSON attribute 'data' of model 'TextItem'", + ); + }); + + it("should not parse JSON text with jsonColumnFormat 'parsed'", async () => { + class ParsedItem extends Item { + static jsonColumnFormat = 'parsed'; + } + + await knex('issue205Item').insert({ id: 1, data: '{"a":1}' }); + expect((await ParsedItem.query().findById(1)).data).toBe('{"a":1}'); + }); + + it('should throw for an invalid jsonColumnFormat', async () => { + class InvalidItem extends Item { + static jsonColumnFormat = 'json'; + } + + await knex('issue205Item').insert({ id: 1 }); + await expect(InvalidItem.query().findById(1)).rejects.toThrow( + "Invalid jsonColumnFormat of model 'InvalidItem': 'json'.", + ); + }); + + it('should call overrides of fromDatabaseJson() and $setDatabaseJson() with the options', async () => { + const calls = []; + + class CustomItem extends Item { + static fromDatabaseJson(json, options) { + calls.push(['fromDatabaseJson', options.knex]); + return super.fromDatabaseJson(json, options); + } + + $setDatabaseJson(json, options) { + calls.push(['$setDatabaseJson', options.knex]); + return super.$setDatabaseJson(json, options); + } + } + + await insertItems([{ id: 1, meta: '123' }]); + // Use a transaction, to tell the query's knex from the bound one: + let queryKnex = null; + const item = await knex.transaction((trx) => { + queryKnex = trx; + return CustomItem.query(trx).findById(1); + }); + expect(item.meta).toBe('123'); + expect(calls).toEqual([ + ['fromDatabaseJson', queryKnex], + ['$setDatabaseJson', queryKnex], + ]); + }); + + it('should parse JSON text leniently for overrides that drop the options', async () => { + class CustomItem extends Item { + static fromDatabaseJson(json) { + return super.fromDatabaseJson(json); + } + } + + await knex('issue205Item').insert({ id: 1, meta: JSON.stringify('{"a":1}'), data: 'text' }); + const item = await CustomItem.query().findById(1); + expect(item.data).toBe('text'); + // Without the driver, JSON text is parsed once more: + expect(item.meta).toEqual(session.isPostgres() ? { a: 1 } : '{"a":1}'); + }); + + it('should not parse columns that are not JSON attributes', async () => { + await knex('issue205Item').insert({ id: 1, title: '{"a":1}' }); + const item = await Item.query().findById(1); + expect(item.title).toBe('{"a":1}'); + }); + }); + + describe('JSON attributes are parsed by their property names #205', () => { + const { knex } = session; + + class Person extends Model { + static get tableName() { + return 'issue205Person'; + } + + static get columnNameMappers() { + return snakeCaseMappers(); + } + + static get jsonAttributes() { + return ['someData']; + } + + static get relationMappings() { + return { + movies: { + relation: Model.ManyToManyRelation, + modelClass: Movie, + join: { + from: 'issue205Person.id', + through: { + from: 'issue205PersonMovie.person_id', + to: 'issue205PersonMovie.movie_id', + extra: { linkData: 'link_data' }, + }, + to: 'issue205Movie.id', + }, + }, + }; + } + } + + class Movie extends Model { + static get tableName() { + return 'issue205Movie'; + } + + static get columnNameMappers() { + return snakeCaseMappers(); + } + + static get jsonAttributes() { + return ['linkData']; + } + } + + beforeAll(async () => { + await knex.schema + .dropTableIfExists('issue205PersonMovie') + .dropTableIfExists('issue205Movie') + .dropTableIfExists('issue205Person') + .createTable('issue205Person', (t) => { + t.integer('id').primary(); + t.json('some_data'); + }) + .createTable('issue205Movie', (t) => { + t.integer('id').primary(); + }) + .createTable('issue205PersonMovie', (t) => { + t.integer('person_id'); + t.integer('movie_id'); + t.json('link_data'); + }); + + await knex('issue205Person').insert({ id: 1, some_data: '{"a":1}' }); + await knex('issue205Movie').insert({ id: 1 }); + await knex('issue205PersonMovie').insert({ person_id: 1, movie_id: 1, link_data: '{"b":2}' }); + }); + + afterAll(() => { + return knex.schema + .dropTableIfExists('issue205PersonMovie') + .dropTableIfExists('issue205Movie') + .dropTableIfExists('issue205Person'); + }); + + it('should parse the JSON extras of many-to-many relations', async () => { + const person = await Person.query(knex).findById(1).withGraphFetched('movies'); + expect(person.movies[0].linkData).toEqual({ b: 2 }); + }); + + it('should parse the JSON extras of many-to-many relations in withGraphJoined()', async () => { + const person = await Person.query(knex).findById(1).withGraphJoined('movies'); + expect(person.movies[0].linkData).toEqual({ b: 2 }); + }); + + it('should parse JSON columns selected under their property names', async () => { + const person = await Person.query(knex).select('id', 'some_data as someData').findById(1); + expect(person.someData).toEqual({ a: 1 }); + }); + }); +}; diff --git a/tests/integration/misc/#2105.js b/tests/integration/misc/issue2105.js similarity index 81% rename from tests/integration/misc/#2105.js rename to tests/integration/misc/issue2105.js index 5ad04ec9e..b29cd0920 100644 --- a/tests/integration/misc/#2105.js +++ b/tests/integration/misc/issue2105.js @@ -1,12 +1,12 @@ -const { Model } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`patch() breaks if useDefineForClassField is enabled in tsconfig #2105`, () => { let knex = session.knex; let Person; - before(() => { + beforeAll(() => { return knex.schema.dropTableIfExists('persons').createTable('persons', (table) => { table.increments('id').primary(); table.string('firstName'); @@ -14,11 +14,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('persons'); }); - before(() => { + beforeAll(() => { Person = class Person extends Model { constructor() { super(...arguments); @@ -60,8 +60,8 @@ module.exports = (session) => { .then(() => person); }) .then((person) => { - expect(person.firstName).to.eql('Jane'); - expect(person.lastName).to.eql('Doe'); + expect(person.firstName).toBe('Jane'); + expect(person.lastName).toBe('Doe'); }); }); @@ -73,8 +73,8 @@ module.exports = (session) => { return person.$set({ lastName: undefined }); }) .then((person) => { - expect(person.firstName).to.eql('John'); - expect(person.lastName).to.eql(undefined); + expect(person.firstName).toBe('John'); + expect(person.lastName).toBeUndefined(); }); }); }); diff --git a/tests/integration/misc/#2199.js b/tests/integration/misc/issue2199.js similarity index 81% rename from tests/integration/misc/#2199.js rename to tests/integration/misc/issue2199.js index ef8fd7104..e333ede19 100644 --- a/tests/integration/misc/#2199.js +++ b/tests/integration/misc/issue2199.js @@ -1,13 +1,13 @@ -const { randomUUID } = require('crypto'); -const { expect } = require('chai'); -const { Model, val, raw } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { Model, val, raw } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('Required properties given as query properties #2199', () => { let knex = session.knex; let Person; - before(() => { + beforeAll(() => { return knex.schema.dropTableIfExists('Person').createTable('Person', (table) => { table.increments('id').primary(); table.string('name').notNullable(); @@ -15,11 +15,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('Person'); }); - before(() => { + beforeAll(() => { Person = class Person extends Model { static get tableName() { return 'Person'; @@ -46,21 +46,21 @@ module.exports = (session) => { it('should insert required properties given as raw() and val()', async () => { await Person.query().insert({ name: val('Margot'), age: raw('40 + 2') }); const people = await Person.query(); - expect(people).to.containSubset([{ name: 'Margot', age: 42 }]); + expect(people).toContainSubset([{ name: 'Margot', age: 42 }]); }); it('should insert required properties given as a subquery', async () => { await Person.query().insert({ name: 'Margot', age: knex.select(knex.raw('42')) }); const people = await Person.query(); - expect(people).to.containSubset([{ name: 'Margot', age: 42 }]); + expect(people).toContainSubset([{ name: 'Margot', age: 42 }]); }); it('should still fail for missing required properties', async () => { try { await Person.query().insert({ name: raw('?', 'Margot') }); } catch (err) { - expect(err).to.be.an.instanceof(Person.ValidationError); - expect(Object.keys(err.data)).to.eql(['age']); + expect(err).toBeInstanceOf(Person.ValidationError); + expect(Object.keys(err.data)).toEqual(['age']); return; } throw new Error('should not get here'); @@ -70,18 +70,18 @@ module.exports = (session) => { describe('uuid[] column', () => { let Item; - before(() => { + beforeAll(() => { return knex.schema.dropTableIfExists('Item').createTable('Item', (table) => { table.increments('id').primary(); table.specificType('uuids', 'uuid[]').notNullable(); }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('Item'); }); - before(() => { + beforeAll(() => { Item = class Item extends Model { static get tableName() { return 'Item'; @@ -106,7 +106,7 @@ module.exports = (session) => { const uuids = [randomUUID(), randomUUID()]; await Item.query().insert({ uuids: val(uuids).asArray().castTo('uuid[]') }); const items = await Item.query(); - expect(items).to.containSubset([{ uuids }]); + expect(items).toContainSubset([{ uuids }]); }); }); } diff --git a/tests/integration/misc/#2219.js b/tests/integration/misc/issue2219.js similarity index 64% rename from tests/integration/misc/#2219.js rename to tests/integration/misc/issue2219.js index 6b947368b..8b1cc99d0 100644 --- a/tests/integration/misc/#2219.js +++ b/tests/integration/misc/issue2219.js @@ -1,13 +1,13 @@ -const { expect } = require('chai'); -const { Model, raw } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model, raw } from 'objection'; -module.exports = (session) => { - describe('Aggregates in withGraphJoined modifiers #2219', () => { +export default (session) => { + describe('Aggregates in graph queries #2219 #111', () => { let knex = session.knex; let Owner; let Pet; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('Toy') .dropTableIfExists('Pet') @@ -28,14 +28,14 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema .dropTableIfExists('Toy') .dropTableIfExists('Pet') .dropTableIfExists('Owner'); }); - before(() => { + beforeAll(() => { Owner = class Owner extends Model { static get tableName() { return 'Owner'; @@ -56,13 +56,23 @@ module.exports = (session) => { static get tableName() { return 'Pet'; } + + static get relationMappings() { + return { + owner: { + relation: Model.BelongsToOneRelation, + modelClass: Owner, + join: { from: 'Pet.ownerId', to: 'Owner.id' }, + }, + }; + } }; Owner.knex(knex); Pet.knex(knex); }); - before(async () => { + beforeAll(async () => { await knex('Owner').insert({ id: 1, name: 'Jennifer' }); await knex('Pet').insert([ { id: 1, ownerId: 1, name: 'Fluffy' }, @@ -100,7 +110,7 @@ module.exports = (session) => { it("count('* as n')", async () => { const pets = await fetchPets((query) => query.count('* as n')); - expect(toNumbers(pets, 'n')).to.eql([ + expect(toNumbers(pets, 'n')).toEqual([ { name: 'Fluffy', n: 2 }, { name: 'Doggo', n: 1 }, ]); @@ -120,13 +130,13 @@ module.exports = (session) => { .orderBy('pets.id') .then((owner) => owner.pets); - expect(toNumbers(pets, 'n')).to.eql([{ n: 2 }, { n: 1 }]); + expect(toNumbers(pets, 'n')).toEqual([{ n: 2 }, { n: 1 }]); }); it("count('Toy.id as n')", async () => { const pets = await fetchPets((query) => query.count('Toy.id as n')); - expect(toNumbers(pets, 'n')).to.eql([ + expect(toNumbers(pets, 'n')).toEqual([ { name: 'Fluffy', n: 2 }, { name: 'Doggo', n: 1 }, ]); @@ -135,7 +145,7 @@ module.exports = (session) => { it("count('Toy.id', { as: 'n' })", async () => { const pets = await fetchPets((query) => query.count('Toy.id', { as: 'n' })); - expect(toNumbers(pets, 'n')).to.eql([ + expect(toNumbers(pets, 'n')).toEqual([ { name: 'Fluffy', n: 2 }, { name: 'Doggo', n: 1 }, ]); @@ -144,7 +154,7 @@ module.exports = (session) => { it("count({ n: 'Toy.id' })", async () => { const pets = await fetchPets((query) => query.count({ n: 'Toy.id' })); - expect(toNumbers(pets, 'n')).to.eql([ + expect(toNumbers(pets, 'n')).toEqual([ { name: 'Fluffy', n: 2 }, { name: 'Doggo', n: 1 }, ]); @@ -153,7 +163,7 @@ module.exports = (session) => { it("sum('Toy.price as total')", async () => { const pets = await fetchPets((query) => query.sum('Toy.price as total')); - expect(toNumbers(pets, 'total')).to.eql([ + expect(toNumbers(pets, 'total')).toEqual([ { name: 'Fluffy', total: 30 }, { name: 'Doggo', total: 5 }, ]); @@ -164,43 +174,55 @@ module.exports = (session) => { query.max({ maxPrice: 'Toy.price' }).min('Toy.price as minPrice'), ); - expect(toNumbers(toNumbers(pets, 'maxPrice'), 'minPrice')).to.eql([ + expect(toNumbers(toNumbers(pets, 'maxPrice'), 'minPrice')).toEqual([ { name: 'Fluffy', maxPrice: 20, minPrice: 10 }, { name: 'Doggo', maxPrice: 5, minPrice: 5 }, ]); }); - it('ignores aggregates without an alias', async function () { + it.skipIf(session.isMsSql())('ignores aggregates without an alias', async () => { // SQL Server requires a name for every column of the joined subquery. - if (session.isMsSql()) { - return this.skip(); - } - const pets = await fetchPets((query) => query.count('Toy.id')); - expect(pets).to.eql([{ name: 'Fluffy' }, { name: 'Doggo' }]); + expect(pets).toEqual([{ name: 'Fluffy' }, { name: 'Doggo' }]); }); + it.skipIf(session.isMsSql())( + 'selects only the ids with aggregates without an alias only', + async () => { + // SQL Server requires a name for every column of the joined subquery. + const pets = await Owner.query() + .findById(1) + .withGraphJoined('pets(withToys)') + .modifiers({ + withToys: (query) => + query.count('Toy.id').join('Toy', 'Toy.petId', 'Pet.id').groupBy('Pet.id'), + }) + .orderBy('pets.id') + .then((owner) => owner.pets); + + expect(pets).toEqual([{}, {}]); + }, + ); + it("raw('count(*) as n')", async () => { const pets = await fetchPets((query) => query.select(raw('count(*) as n'))); - expect(toNumbers(pets, 'n')).to.eql([ + expect(toNumbers(pets, 'n')).toEqual([ { name: 'Fluffy', n: 2 }, { name: 'Doggo', n: 1 }, ]); }); - it("keeps selecting all root columns with count('* as n')", async () => { + it("selects only the id of the root with count('* as n')", async () => { const owners = await Owner.query() .withGraphJoined('pets') .count('* as n') - .groupBy('Owner.id', 'Owner.name', 'pets.id', 'pets.ownerId', 'pets.name') + .groupBy('Owner.id', 'pets.id', 'pets.ownerId', 'pets.name') .orderBy('pets.id'); - expect(owners.map((owner) => ({ ...owner, n: Number(owner.n) }))).to.eql([ + expect(owners.map((owner) => ({ ...owner, n: Number(owner.n) }))).toEqual([ { - id: 1, - name: 'Jennifer', n: 1, pets: [ { id: 1, ownerId: 1, name: 'Fluffy' }, @@ -209,5 +231,26 @@ module.exports = (session) => { }, ]); }); + + it("withGraphFetched() selects the join column of the root with count('* as n')", async () => { + const pets = await Pet.query().count('* as n').groupBy('ownerId').withGraphFetched('owner'); + + expect(toNumbers(pets, 'n')).toEqual([{ n: 2, owner: { id: 1, name: 'Jennifer' } }]); + }); + + it("withGraphFetched() selects the join column of the root with count('ownerId')", async () => { + const pets = await Pet.query().count('ownerId').groupBy('ownerId').withGraphFetched('owner'); + + expect(pets.map((pet) => pet.owner)).toEqual([{ id: 1, name: 'Jennifer' }]); + }); + + it("withGraphFetched() selects the join column of a relation with count('* as n')", async () => { + const owner = await Owner.query() + .findById(1) + .withGraphFetched('pets(counted)') + .modifiers({ counted: (query) => query.count('* as n').groupBy('ownerId') }); + + expect(toNumbers(owner.pets, 'n')).toEqual([{ n: 2 }]); + }); }); }; diff --git a/tests/integration/misc/#2258.js b/tests/integration/misc/issue2258.js similarity index 56% rename from tests/integration/misc/#2258.js rename to tests/integration/misc/issue2258.js index 64863b518..7baf9b8f3 100644 --- a/tests/integration/misc/#2258.js +++ b/tests/integration/misc/issue2258.js @@ -1,13 +1,10 @@ -const Knex = require('knex'); -const { expect } = require('chai'); -const { Model, knexSnakeCaseMappers } = require('../../../'); -const { resetWarnings } = require('../../../lib/utils/warnings'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import Knex from 'knex'; +import { Model, knexSnakeCaseMappers } from 'objection'; -module.exports = (session) => { - describe('warn when fetched related models are missing join properties #2258', () => { +export default (session) => { + describe('throw when fetched related models are missing join properties #2258', () => { let knex; - let warnings; - let originalWarn; class Registration extends Model { static get tableName() { @@ -76,7 +73,7 @@ module.exports = (session) => { } } - before(async () => { + beforeAll(async () => { knex = Knex({ ...session.opt.knexConfig, ...knexSnakeCaseMappers() }); await session.knex.schema @@ -106,7 +103,7 @@ module.exports = (session) => { await session.knex('issue_2258_user_role').insert({ user_id: 1, role_id: 1 }); }); - after(async () => { + afterAll(async () => { await session.knex.schema .dropTableIfExists('issue_2258_user_role') .dropTableIfExists('issue_2258_role') @@ -116,64 +113,45 @@ module.exports = (session) => { await knex.destroy(); }); - beforeEach(() => { - resetWarnings(); - warnings = []; - originalWarn = console.warn; - console.warn = (message) => warnings.push(message); - }); + const missingPropError = + 'Unable to fetch relation "snakeRegistrations" of User: some related models are missing ' + + 'the join property "issue_2258_registration.user_id". Check that the relation mapping ' + + 'uses the property names of the models, e.g. camelCase names when using ' + + 'knexSnakeCaseMappers.'; - afterEach(() => { - console.warn = originalWarn; - resetWarnings(); + it('withGraphFetched() should throw if related models are missing the join property', async () => { + await expect( + User.query(knex).findById(1).withGraphFetched('snakeRegistrations'), + ).rejects.toThrow(missingPropError); }); - const expectedWarning = - '[objection] Fetching relation "snakeRegistrations" of User: some related models are ' + - 'missing the join property "issue_2258_registration.user_id", so they are not assigned to ' + - 'any owner. Check that the relation mapping uses the property names of the models, ' + - 'e.g. camelCase names when using knexSnakeCaseMappers.'; - - it('withGraphFetched() should warn if related models are missing the join property', async () => { - const user = await User.query(knex).findById(1).withGraphFetched('snakeRegistrations'); - - expect(user.snakeRegistrations).to.eql([]); - expect(warnings).to.eql([expectedWarning]); - }); - - it('$fetchGraph() should warn if related models are missing the join property', async () => { + it('$fetchGraph() should throw if related models are missing the join property', async () => { const user = await User.query(knex).findById(1); - await user.$fetchGraph('snakeRegistrations', { transaction: knex }); - expect(user.snakeRegistrations).to.eql([]); - expect(warnings).to.eql([expectedWarning]); + await expect(user.$fetchGraph('snakeRegistrations', { transaction: knex })).rejects.toThrow( + missingPropError, + ); }); - it('should not warn if related models have the join property', async () => { + it('should not throw if related models have the join property', async () => { const user = await User.query(knex).findById(1).withGraphFetched('camelRegistrations'); - expect(user.camelRegistrations.map((it) => it.id)).to.eql([1]); - expect(warnings).to.eql([]); + expect(user.camelRegistrations.map((it) => it.id)).toEqual([1]); }); - const expectedManyToManyWarning = - '[objection] Fetching relation "strictRoles" of User: some related models are missing the ' + - 'join table column "issue_2258_user_role.userId" selected as "objectiontmpjoin0", so they ' + - 'are not assigned to any owner. Check that $parseDatabaseJson() and the column name ' + - 'mappers of the related model keep it.'; - - it('should warn if related models of a ManyToManyRelation are missing the owner join column', async () => { - const user = await User.query(knex).findById(1).withGraphFetched('strictRoles'); - - expect(user.strictRoles).to.eql([]); - expect(warnings).to.eql([expectedManyToManyWarning]); + it('should throw if related models of a ManyToManyRelation are missing the owner join column', async () => { + await expect(User.query(knex).findById(1).withGraphFetched('strictRoles')).rejects.toThrow( + 'Unable to fetch relation "strictRoles" of User: some related models are missing the ' + + 'join table column "issue_2258_user_role.userId" selected as "objectiontmpjoin0". ' + + 'Check that $parseDatabaseJson() and the column name mappers of the related model ' + + 'keep it.', + ); }); - it('should not warn if related models of a ManyToManyRelation have the owner join column', async () => { + it('should not throw if related models of a ManyToManyRelation have the owner join column', async () => { const user = await User.query(knex).findById(1).withGraphFetched('roles'); - expect(user.roles.map((it) => it.toJSON())).to.eql([{ id: 1, name: 'admin' }]); - expect(warnings).to.eql([]); + expect(user.roles.map((it) => it.toJSON())).toEqual([{ id: 1, name: 'admin' }]); }); }); }; diff --git a/tests/integration/misc/#2305.js b/tests/integration/misc/issue2305.js similarity index 77% rename from tests/integration/misc/#2305.js rename to tests/integration/misc/issue2305.js index 52baaf05a..e28ce05d9 100644 --- a/tests/integration/misc/#2305.js +++ b/tests/integration/misc/issue2305.js @@ -1,12 +1,12 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('models without a primary key (idColumn = null) #2305', () => { const { knex } = session; let JobExecution; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('job_execution') .createTable('job_execution', (table) => { @@ -15,11 +15,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('job_execution'); }); - before(() => { + beforeAll(() => { JobExecution = class JobExecution extends Model { static get tableName() { return 'job_execution'; @@ -40,96 +40,81 @@ module.exports = (session) => { const rows = () => knex('job_execution').orderBy('name'); it('should have no id columns', () => { - expect(JobExecution.getIdColumnArray()).to.eql([]); - expect(JobExecution.getIdPropertyArray()).to.eql([]); + expect(JobExecution.getIdColumnArray()).toEqual([]); + expect(JobExecution.getIdPropertyArray()).toEqual([]); }); it('should have no id', () => { const job = JobExecution.fromJson({ name: 'a' }); - expect(job.$id()).to.equal(undefined); - expect(job.$hasId()).to.equal(false); - expect(() => job.$id(1)).to.throw('model JobExecution has no idColumn'); + expect(job.$id()).toBeUndefined(); + expect(job.$hasId()).toBe(false); + expect(() => job.$id(1)).toThrow('model JobExecution has no idColumn'); }); it('should throw a clear error for id based queries', () => { const job = JobExecution.fromJson({ name: 'a' }); const message = 'model JobExecution has no idColumn'; - expect(() => JobExecution.query().findById(1).toKnexQuery()).to.throw(message); - expect(() => JobExecution.query().findByIds([1]).toKnexQuery()).to.throw(message); - expect(() => JobExecution.query().deleteById(1).toKnexQuery()).to.throw(message); - expect(() => job.$query().patch({ runs: 1 }).toKnexQuery()).to.throw(message); + expect(() => JobExecution.query().findById(1).toKnexQuery()).toThrow(message); + expect(() => JobExecution.query().findByIds([1]).toKnexQuery()).toThrow(message); + expect(() => JobExecution.query().deleteById(1).toKnexQuery()).toThrow(message); + expect(() => job.$query().patch({ runs: 1 }).toKnexQuery()).toThrow(message); }); it('should insert a model', async () => { const job = await JobExecution.query().insert({ name: 'a', runs: 1 }); - expect(job).to.be.an.instanceOf(JobExecution); - expect(job.toJSON()).to.eql({ name: 'a', runs: 1 }); - expect(await rows()).to.eql([{ name: 'a', runs: 1 }]); + expect(job).toBeInstanceOf(JobExecution); + expect(job.toJSON()).toEqual({ name: 'a', runs: 1 }); + expect(await rows()).toEqual([{ name: 'a', runs: 1 }]); }); - it('should insert multiple models', async function () { - if (!session.isPostgres()) { - // Only Postgres supports batch inserts. - return this.skip(); - } - + it('should insert multiple models', async () => { const jobs = await JobExecution.query().insert([ { name: 'a', runs: 1 }, { name: 'b', runs: 2 }, ]); - expect(jobs.map((job) => job.toJSON())).to.eql([ + expect(jobs.map((job) => job.toJSON())).toEqual([ { name: 'a', runs: 1 }, { name: 'b', runs: 2 }, ]); - expect(await rows()).to.eql([ + expect(await rows()).toEqual([ { name: 'a', runs: 1 }, { name: 'b', runs: 2 }, ]); }); - it('should insert with onConflict().ignore()', async function () { + it.skipIf(session.isMsSql())('should insert with onConflict().ignore()', async () => { // knex doesn't support `onConflict()` on SQL Server. - if (session.isMsSql()) { - return this.skip(); - } - await JobExecution.query().insert({ name: 'a', runs: 1 }); const job = await JobExecution.query() .insert({ name: 'a', runs: 2 }) .onConflict('name') .ignore(); - expect(job.toJSON()).to.eql({ name: 'a', runs: 2 }); - expect(await rows()).to.eql([{ name: 'a', runs: 1 }]); + expect(job.toJSON()).toEqual({ name: 'a', runs: 2 }); + expect(await rows()).toEqual([{ name: 'a', runs: 1 }]); }); - it('should return the columns given to returning()', async function () { - if (session.isMySql()) { - return this.skip(); - } - + it.skipIf(session.isMySql())('should return the columns given to returning()', async () => { const job = await JobExecution.query().insert({ name: 'a', runs: 1 }).returning('runs'); - expect(job.runs).to.equal(1); + expect(job.runs).toBe(1); }); it('should patch, update, find and delete by where clauses', async () => { await JobExecution.query().insert({ name: 'a', runs: 1 }); - expect(await JobExecution.query().patch({ runs: 2 }).where('name', 'a')).to.equal(1); - expect(await JobExecution.query().update({ name: 'a', runs: 3 }).where('name', 'a')).to.equal( - 1, - ); - expect((await JobExecution.query().findOne({ name: 'a' })).runs).to.equal(3); - expect(await JobExecution.query().delete().where('name', 'a')).to.equal(1); - expect(await rows()).to.eql([]); + expect(await JobExecution.query().patch({ runs: 2 }).where('name', 'a')).toBe(1); + expect(await JobExecution.query().update({ name: 'a', runs: 3 }).where('name', 'a')).toBe(1); + expect((await JobExecution.query().findOne({ name: 'a' })).runs).toBe(3); + expect(await JobExecution.query().delete().where('name', 'a')).toBe(1); + expect(await rows()).toEqual([]); }); describe('as the join model of a many-to-many relation', () => { - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('job_tag') .dropTableIfExists('job') @@ -146,7 +131,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema .dropTableIfExists('job_tag') .dropTableIfExists('job') @@ -207,13 +192,13 @@ module.exports = (session) => { await job.$relatedQuery('tags').relate(1); await job.$relatedQuery('tags').relate(2); const tags = await job.$relatedQuery('tags').orderBy('tag.id'); - expect(tags.map((tag) => tag.id)).to.eql([1, 2]); + expect(tags.map((tag) => tag.id)).toEqual([1, 2]); - expect(await job.$relatedQuery('tags').unrelate().where('tag.id', 1)).to.equal(1); - expect(await knex('job_tag')).to.eql([{ job_id: 1, tag_id: 2 }]); + expect(await job.$relatedQuery('tags').unrelate().where('tag.id', 1)).toBe(1); + expect(await knex('job_tag')).toEqual([{ job_id: 1, tag_id: 2 }]); // The join model class is not modified by the relation. - expect(JobTag.getIdColumn()).to.equal(null); + expect(JobTag.getIdColumn()).toBeNull(); }); } @@ -253,12 +238,12 @@ module.exports = (session) => { }); it('should count the rows with to-one joins', async () => { - expect(await query().withGraphJoined('tag').resultSize()).to.equal(3); + expect(await query().withGraphJoined('tag').resultSize()).toBe(3); }); it('should count no rows with to-one joins', async () => { await knex('job_tag').delete(); - expect(await query().withGraphJoined('tag').resultSize()).to.equal(0); + expect(await query().withGraphJoined('tag').resultSize()).toBe(0); }); it('should throw a clear error with to-many joins', async () => { @@ -266,7 +251,7 @@ module.exports = (session) => { try { await query().withGraphJoined('[tag, jobTags]').resultSize(); } catch (err) { - expect(err.message).to.equal('model JobTag has no idColumn'); + expect(err.message).toBe('model JobTag has no idColumn'); return; } throw new Error('should not get here'); diff --git a/tests/integration/misc/issue2320.js b/tests/integration/misc/issue2320.js new file mode 100644 index 000000000..9713b037c --- /dev/null +++ b/tests/integration/misc/issue2320.js @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; + +export default (session) => { + describe.skipIf(session.isMsSql())( + 'insert().onConflict().ignore() results #2320 #2597 #2661', + () => { + const { knex } = session; + + beforeAll(() => { + // knex doesn't support `onConflict()` on SQL Server. + }); + + class Tag extends Model { + static get tableName() { + return 'onConflictTags'; + } + + static get jsonSchema() { + return { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + meta: { type: ['object', 'null'] }, + }, + }; + } + } + + beforeAll(() => { + return knex.schema + .dropTableIfExists('onConflictTags') + .createTable('onConflictTags', (t) => { + t.increments('id').primary(); + t.string('name').unique(); + t.json('meta'); + }); + }); + + afterAll(() => { + return knex.schema.dropTableIfExists('onConflictTags'); + }); + + beforeEach(async () => { + await knex('onConflictTags').delete(); + await Tag.query(knex).insert({ name: 'a', meta: { v: 'original a' } }); + await Tag.query(knex).insert({ name: 'b', meta: { v: 'original b' } }); + }); + + async function findTag(name) { + return Tag.query(knex).findOne({ name }); + } + + it('should not assign an id to an ignored model', async () => { + const tag = Tag.fromJson({ name: 'a' }); + const result = await Tag.query(knex).insert(tag).onConflict('name').ignore(); + + expect(result).toBe(tag); + expect(tag.id).toBeUndefined(); + }); + + it('should assign the id to a model that is not ignored', async () => { + const result = await Tag.query(knex).insert({ name: 'c' }).onConflict('name').ignore(); + + expect(result.id).toBe((await findTag('c')).id); + }); + + it('insertAndFetch() should not fetch an ignored model without an id (#2661)', async () => { + const result = await Tag.query(knex) + .insertAndFetch({ name: 'a', meta: { v: 'new a' } }) + .onConflict('name') + .ignore(); + + expect(result.id).toBeUndefined(); + expect(result.name).toBe('a'); + expect(result.meta).toEqual({ v: 'new a' }); + }); + + if (session.isPostgres()) { + it('should merge the returned rows to the right models (#2320)', async () => { + const tags = ['a', 'c', 'b', 'd'].map((name) => Tag.fromJson({ name })); + const result = await Tag.query(knex).insert(tags).onConflict('name').ignore(); + + expect(result).toHaveLength(4); + expect(result.every((it, i) => it === tags[i])).toBe(true); + expect(tags.map((it) => it.name)).toEqual(['a', 'c', 'b', 'd']); + expect(tags[0].id).toBeUndefined(); + expect(tags[1].id).toBe((await findTag('c')).id); + expect(tags[2].id).toBeUndefined(); + expect(tags[3].id).toBe((await findTag('d')).id); + }); + + it('should not crash with object properties in the jsonSchema (#2597)', async () => { + const result = await Tag.query(knex) + .insert([ + { name: 'c', meta: { v: 'new c' } }, + { name: 'a', meta: { v: 'new a' } }, + ]) + .onConflict('name') + .ignore() + .returning('*'); + + expect(result.map((it) => it.id)).toEqual([(await findTag('c')).id, undefined]); + expect(result.map((it) => it.meta)).toEqual([{ v: 'new c' }, { v: 'new a' }]); + }); + + it('insertAndFetch() should only fetch the models that have an id (#2661)', async () => { + const result = await Tag.query(knex) + .insertAndFetch([ + { name: 'a', meta: { v: 'new a' } }, + { name: 'c', meta: { v: 'new c' } }, + ]) + .onConflict('name') + .ignore(); + + expect(result.map((it) => it.id)).toEqual([undefined, (await findTag('c')).id]); + expect(result.map((it) => it.meta)).toEqual([{ v: 'new a' }, { v: 'new c' }]); + }); + } + }, + ); +}; diff --git a/tests/integration/misc/#2544.js b/tests/integration/misc/issue2544.js similarity index 84% rename from tests/integration/misc/#2544.js rename to tests/integration/misc/issue2544.js index e2953b7ef..9501b2723 100644 --- a/tests/integration/misc/#2544.js +++ b/tests/integration/misc/issue2544.js @@ -1,14 +1,14 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('upsertGraph relate with a composite id that contains the foreign key #2544', () => { const { knex } = session; let Order; let OrderItem; let OrderNote; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('order_notes_2544') .dropTableIfExists('order_items_2544') @@ -29,14 +29,14 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema .dropTableIfExists('order_notes_2544') .dropTableIfExists('order_items_2544') .dropTableIfExists('orders_2544'); }); - before(() => { + beforeAll(() => { OrderItem = class OrderItem extends Model { static get tableName() { return 'order_items_2544'; @@ -105,10 +105,10 @@ module.exports = (session) => { { relate: true, insertMissing: true }, ); - expect(order.items.map((it) => it.toJSON())).to.eql([ + expect(order.items.map((it) => it.toJSON())).toEqual([ { order_id: 1, product_id: 2, quantity: 3 }, ]); - expect(await items()).to.eql([{ order_id: 1, product_id: 2, quantity: 3 }]); + expect(await items()).toEqual([{ order_id: 1, product_id: 2, quantity: 3 }]); }); it('should insert children of a new parent using insertGraph', async () => { @@ -117,7 +117,7 @@ module.exports = (session) => { { relate: true }, ); - expect(await items()).to.eql([{ order_id: 1, product_id: 2, quantity: 3 }]); + expect(await items()).toEqual([{ order_id: 1, product_id: 2, quantity: 3 }]); }); it('should replace children of an existing parent', async () => { @@ -132,7 +132,7 @@ module.exports = (session) => { { relate: true }, ); - expect(await items()).to.eql([{ order_id: 1, product_id: 2, quantity: 5 }]); + expect(await items()).toEqual([{ order_id: 1, product_id: 2, quantity: 5 }]); }); const notes = () => knex('order_notes_2544').select('order_id', 'text'); @@ -143,7 +143,7 @@ module.exports = (session) => { { relate: true }, ); - expect(await notes()).to.eql([{ order_id: 1, text: 'note' }]); + expect(await notes()).toEqual([{ order_id: 1, text: 'note' }]); }); it('should insert a HasOne child whose id is the foreign key using upsertGraph', async () => { @@ -152,8 +152,8 @@ module.exports = (session) => { { relate: true, insertMissing: true }, ); - expect(order.note.toJSON()).to.eql({ order_id: 1, text: 'note' }); - expect(await notes()).to.eql([{ order_id: 1, text: 'note' }]); + expect(order.note.toJSON()).toEqual({ order_id: 1, text: 'note' }); + expect(await notes()).toEqual([{ order_id: 1, text: 'note' }]); }); }); }; diff --git a/tests/integration/misc/#2667.js b/tests/integration/misc/issue2667.js similarity index 55% rename from tests/integration/misc/#2667.js rename to tests/integration/misc/issue2667.js index c793cb50f..f9247dd64 100644 --- a/tests/integration/misc/#2667.js +++ b/tests/integration/misc/issue2667.js @@ -1,9 +1,8 @@ -const { Model, ref } = require('../../../'); -const { expect } = require('chai'); -const { resetWarnings } = require('../../../lib/utils/warnings'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model, ref } from 'objection'; -module.exports = (session) => { - describe('warn when a relation owner join property holds an array #2667', () => { +export default (session) => { + describe('throw when a relation owner join property holds an array #2667', () => { const { knex } = session; class Tag extends Model { @@ -68,10 +67,7 @@ module.exports = (session) => { } } - let warnings; - let originalWarn; - - before(async () => { + beforeAll(async () => { Tag.knex(knex); Post.knex(knex); @@ -87,12 +83,12 @@ module.exports = (session) => { .createTable('issue2667Post', (t) => { t.integer('id').primary(); t.string('kind'); - t.text('tagIds'); + t.json('tagIds'); if (session.isPostgres()) { t.jsonb('data'); } else { - t.text('data'); + t.json('data'); } }) .createTable('issue2667PostTag', (t) => { @@ -112,82 +108,60 @@ module.exports = (session) => { ]); }); - after(async () => { + afterAll(async () => { await knex.schema .dropTableIfExists('issue2667PostTag') .dropTableIfExists('issue2667Post') .dropTableIfExists('issue2667Tag'); }); - beforeEach(() => { - resetWarnings(); - warnings = []; - originalWarn = console.warn; - console.warn = (message) => warnings.push(message); - }); + const arrayError = + 'Unable to fetch relation "tags" of Post: the join property "issue2667Post.tagIds" of some ' + + 'owner models holds an array, which is not supported. Use a BelongsToManyRelation or a ' + + 'ManyToManyRelation through a join table instead.'; - afterEach(() => { - console.warn = originalWarn; - resetWarnings(); + it('withGraphFetched() should throw if owner join properties hold arrays', async () => { + await expect(Post.query().withGraphFetched('tags')).rejects.toThrow(arrayError); }); - const expectedWarning = - '[objection] Fetching relation "tags" of Post: the join property "issue2667Post.tagIds" of ' + - 'some owner models holds an array, which is not supported, so no related models are found ' + - 'for them. Use a ManyToManyRelation through a join table instead.'; - - it('withGraphFetched() should warn once if owner join properties hold arrays', async () => { - const posts = await Post.query().withGraphFetched('tags').orderBy('id'); - - // Results are unchanged: array join properties aren't supported. - expect(posts.map((it) => it.tags)).to.eql([[], []]); - expect(warnings).to.eql([expectedWarning]); - - await Post.query().withGraphFetched('tags'); - expect(warnings).to.eql([expectedWarning]); + it('should throw for many-to-many relations', async () => { + await expect(Post.query().withGraphFetched('manyTags')).rejects.toThrow( + 'Unable to fetch relation "manyTags" of Post: the join property "issue2667Post.tagIds" ' + + 'of some owner models holds an array, which is not supported. Map the relation from a ' + + 'column that holds a single key instead.', + ); }); - it('should warn for many-to-many relations', async () => { - const posts = await Post.query().withGraphFetched('manyTags').orderBy('id'); + it('$fetchGraph() should throw if owner join properties hold arrays', async () => { + const post = await Post.query().findById(1); - expect(posts.map((it) => it.manyTags)).to.eql([[], []]); - expect(warnings).to.eql([ - '[objection] Fetching relation "manyTags" of Post: the join property ' + - '"issue2667Post.tagIds" of some owner models holds an array, which is not supported, ' + - 'so no related models are found for them. Map the relation from a column that holds a ' + - 'single key instead.', - ]); + await expect(post.$fetchGraph('tags')).rejects.toThrow(arrayError); }); - it('$fetchGraph() should warn once if owner join properties hold arrays', async () => { - const post = await Post.query().findById(1); - await post.$fetchGraph('tags'); + it('should throw for owners built in memory', async () => { + const post = Post.fromJson({ id: 1, tagIds: [1, 2] }); - expect(post.tags).to.eql([]); - expect(warnings).to.eql([expectedWarning]); + await expect(post.$fetchGraph('tags')).rejects.toThrow(arrayError); }); - it('$relatedQuery() should not warn and keep returning the related models', async () => { + it('$relatedQuery() should not throw and keep returning the related models', async () => { const post = await Post.query().findById(1); const tags = await post.$relatedQuery('tags').orderBy('id'); - expect(tags.map((it) => it.id)).to.eql([1, 2]); - expect(warnings).to.eql([]); + expect(tags.map((it) => it.id)).toEqual([1, 2]); }); - it('should not warn for composite keys', async () => { + it('should not throw for composite keys', async () => { const posts = await Post.query().withGraphFetched('compositeTags').orderBy('id'); - expect(posts.map((post) => post.compositeTags.map((it) => it.id).sort())).to.eql([[1], [3]]); - expect(warnings).to.eql([]); + expect(posts.map((post) => post.compositeTags.map((it) => it.id).sort())).toEqual([[1], [3]]); }); if (session.isPostgres()) { - it('should not warn for scalar JSON reference keys', async () => { + it('should not throw for scalar JSON reference keys', async () => { const posts = await Post.query().withGraphFetched('jsonRefTag').orderBy('id'); - expect(posts.map((it) => it.jsonRefTag.id)).to.eql([1, 3]); - expect(warnings).to.eql([]); + expect(posts.map((it) => it.jsonRefTag.id)).toEqual([1, 3]); }); } }); diff --git a/tests/integration/misc/#2748.js b/tests/integration/misc/issue2748.js similarity index 84% rename from tests/integration/misc/#2748.js rename to tests/integration/misc/issue2748.js index 6d9c24df1..eedd21c83 100644 --- a/tests/integration/misc/#2748.js +++ b/tests/integration/misc/issue2748.js @@ -1,13 +1,13 @@ -const { Model } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`withGraphJoined() should throw for models without a primary key #2748`, () => { let knex = session.knex; let Owner; let Item; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('issue2748_items') .dropTableIfExists('issue2748_owners') @@ -21,11 +21,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('issue2748_items').dropTableIfExists('issue2748_owners'); }); - before(() => { + beforeAll(() => { Owner = class Owner extends Model { static get tableName() { return 'issue2748_owners'; @@ -68,7 +68,7 @@ module.exports = (session) => { Item.knex(knex); }); - before(async () => { + beforeAll(async () => { await knex('issue2748_owners').insert([ { id: 1, name: 'o1' }, { id: 2, name: 'o2' }, @@ -90,9 +90,9 @@ module.exports = (session) => { error = err; } - expect(error).to.be.an.instanceOf(Error); - expect(error.message).to.contain(`model ${modelName}`); - expect(error.message).to.contain('withGraphFetched'); + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain(`model ${modelName}`); + expect(error.message).toContain('withGraphFetched'); }; it('should throw if a related table has no id column', () => { @@ -106,7 +106,7 @@ module.exports = (session) => { it('withGraphFetched() should still work', async () => { const owners = await Owner.query().withGraphFetched('items').orderBy('id'); - expect(owners.map((it) => it.items.length)).to.eql([2, 1]); + expect(owners.map((it) => it.items.length)).toEqual([2, 1]); }); }); }; diff --git a/tests/integration/misc/#292.js b/tests/integration/misc/issue292.js similarity index 94% rename from tests/integration/misc/#292.js rename to tests/integration/misc/issue292.js index 730b56c16..0a7cf3902 100644 --- a/tests/integration/misc/#292.js +++ b/tests/integration/misc/issue292.js @@ -1,8 +1,8 @@ -const expect = require('expect.js'); -const { Model } = require('../../../'); -const { sortBy } = require('../../../testUtils/testUtils'); +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Model } from 'objection'; +import { sortBy } from '../../../testUtils/testUtils.js'; -module.exports = (session) => { +export default (session) => { describe('Eagerly loaded empty relations seem to short-circuit conversion to internal structure #292', () => { class A extends Model { static get tableName() { @@ -133,7 +133,7 @@ module.exports = (session) => { .then((results) => { results[0].Bs[0].Ds = sortBy(results[0].Bs[0].Ds, 'id'); - expect(results).to.eql([ + expect(results).toEqual([ { id: 1, diff --git a/tests/integration/misc/#325.js b/tests/integration/misc/issue325.js similarity index 73% rename from tests/integration/misc/#325.js rename to tests/integration/misc/issue325.js index 0660d735f..02243469f 100644 --- a/tests/integration/misc/#325.js +++ b/tests/integration/misc/issue325.js @@ -1,11 +1,11 @@ -const expect = require('expect.js'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('Default values not set with .insertGraph() in 0.7.2 #325', () => { let TestModel; - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('default_values_note_set_test') .createTable('default_values_note_set_test', (table) => { @@ -15,11 +15,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema.dropTableIfExists('default_values_note_set_test'); }); - before(() => { + beforeAll(() => { TestModel = class TestModel extends Model { static get tableName() { return 'default_values_note_set_test'; @@ -48,13 +48,13 @@ module.exports = (session) => { return TestModel.query() .insert({ value1: 'hello' }) .then((model) => { - expect(model.value1).to.equal('hello'); - expect(model.value2).to.equal('bar'); + expect(model.value1).toBe('hello'); + expect(model.value2).toBe('bar'); return session.knex(TestModel.getTableName()); }) .then((rows) => { - expect(rows[0].value1).to.equal('hello'); - expect(rows[0].value2).to.equal('bar'); + expect(rows[0].value1).toBe('hello'); + expect(rows[0].value2).toBe('bar'); }); }); @@ -62,13 +62,13 @@ module.exports = (session) => { return TestModel.query() .insertGraph({ value1: 'hello' }) .then((model) => { - expect(model.value1).to.equal('hello'); - expect(model.value2).to.equal('bar'); + expect(model.value1).toBe('hello'); + expect(model.value2).toBe('bar'); return session.knex(TestModel.getTableName()); }) .then((rows) => { - expect(rows[0].value1).to.equal('hello'); - expect(rows[0].value2).to.equal('bar'); + expect(rows[0].value1).toBe('hello'); + expect(rows[0].value2).toBe('bar'); }); }); }); diff --git a/tests/integration/misc/#403.js b/tests/integration/misc/issue403.js similarity index 93% rename from tests/integration/misc/#403.js rename to tests/integration/misc/issue403.js index ddda2314c..c4782d5d1 100644 --- a/tests/integration/misc/#403.js +++ b/tests/integration/misc/issue403.js @@ -1,14 +1,14 @@ -const expect = require('expect.js'); -const utils = require('../../../lib/utils/knexUtils'); -const { Model, ref } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as utils from '../../../lib/utils/knexUtils.js'; +import { Model, ref } from 'objection'; -module.exports = (session) => { +export default (session) => { if (utils.isPostgres(session.knex)) { describe('Eagered grandparents disappear when selecting the pkey as an alias on eagered parents #403', () => { let knex = session.knex; let Page; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('page_relationship') .dropTableIfExists('page') @@ -22,11 +22,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('page_relationship').dropTableIfExists('page'); }); - before(() => { + beforeAll(() => { Page = class Page extends Model { static get tableName() { return 'page'; @@ -74,7 +74,7 @@ module.exports = (session) => { Page.knex(knex); }); - before(() => { + beforeAll(() => { return Page.query().insertGraph({ page_id: 1, object_data: { name: '1' }, @@ -126,7 +126,7 @@ module.exports = (session) => { }) .select('page_id as id', ref('object_data:name').as('name')) .then((pages) => { - expect(pages).to.eql({ + expect(pages).toEqual({ id: 1, name: '1', @@ -175,7 +175,7 @@ module.exports = (session) => { ) .orderBy('parents:parents.page_id') .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 4, name: '1_1_1' }, { id: 5, name: '1_1_2' }, { id: 6, name: '1_2_1' }, diff --git a/tests/integration/misc/#517.js b/tests/integration/misc/issue517.js similarity index 90% rename from tests/integration/misc/#517.js rename to tests/integration/misc/issue517.js index be744a4b6..1ee384581 100644 --- a/tests/integration/misc/#517.js +++ b/tests/integration/misc/issue517.js @@ -1,13 +1,13 @@ -const expect = require('expect.js'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('upsertGraph with compound key in relation #517', () => { let knex = session.knex; let Users; let Preferences; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('Users') .dropTableIfExists('Preferences') @@ -22,11 +22,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('Preferences').dropTableIfExists('Users'); }); - before(() => { + beforeAll(() => { Users = class Users extends Model { static get tableName() { return 'Users'; @@ -60,7 +60,7 @@ module.exports = (session) => { Preferences.knex(knex); }); - before(() => { + beforeAll(() => { return Users.query().insert({ id: 1, }); @@ -86,7 +86,7 @@ module.exports = (session) => { .modifyGraph('preferences', (qb) => qb.orderBy('category')); }) .then((users) => { - expect(users).to.eql([ + expect(users).toEqual([ { id: 1, diff --git a/tests/integration/misc/#712.js b/tests/integration/misc/issue712.js similarity index 85% rename from tests/integration/misc/#712.js rename to tests/integration/misc/issue712.js index 3fb0828ac..4d002eb40 100644 --- a/tests/integration/misc/#712.js +++ b/tests/integration/misc/issue712.js @@ -1,12 +1,12 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`modifiers that have no where or select statements don't work with joinRelated #712`, () => { let knex = session.knex; let Person; - before(async () => { + beforeAll(async () => { await knex.schema.dropTableIfExists('Person'); await session.createTable('Person', (table, { increments }) => { increments('id'); @@ -15,11 +15,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('Person'); }); - before(() => { + beforeAll(() => { Person = class Person extends Model { static get tableName() { return 'Person'; @@ -56,7 +56,7 @@ module.exports = (session) => { Person.knex(knex); }); - before(() => { + beforeAll(() => { return Person.query().insertGraph({ id: 1, name: 'parent', @@ -80,8 +80,8 @@ module.exports = (session) => { .joinRelated('children(notFirstChild)') .orderBy('childName') .then((people) => { - expect(people).to.have.length(1); - expect(people[0].childName).to.equal('child 2'); + expect(people).toHaveLength(1); + expect(people[0].childName).toBe('child 2'); }); }); }); diff --git a/tests/integration/misc/#733.js b/tests/integration/misc/issue733.js similarity index 89% rename from tests/integration/misc/#733.js rename to tests/integration/misc/issue733.js index f3406b02b..cfc3836c4 100644 --- a/tests/integration/misc/#733.js +++ b/tests/integration/misc/issue733.js @@ -1,12 +1,12 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`select in modifier + joinEager #733`, () => { let knex = session.knex; let Person; - before(async () => { + beforeAll(async () => { await knex.schema.dropTableIfExists('Person'); await session.createTable('Person', (table, { increments }) => { increments('id'); @@ -15,11 +15,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('Person'); }); - before(() => { + beforeAll(() => { Person = class Person extends Model { static get tableName() { return 'Person'; @@ -54,7 +54,7 @@ module.exports = (session) => { Person.knex(knex); }); - before(() => { + beforeAll(() => { return Person.query().insertGraph({ id: 1, name: 'parent', @@ -76,7 +76,7 @@ module.exports = (session) => { .where('Person.id', 1) .withGraphJoined('children(aliasedProps)') .then((result) => { - expect(result).to.containSubset([ + expect(result).toContainSubset([ { id: 1, name: 'parent', @@ -95,7 +95,7 @@ module.exports = (session) => { .where('Person.id', 1) .withGraphJoined('children(aliasedPropsAndSelectAll)') .then((result) => { - expect(result).to.containSubset([ + expect(result).toContainSubset([ { id: 1, name: 'parent', diff --git a/tests/integration/misc/#760.js b/tests/integration/misc/issue760.js similarity index 89% rename from tests/integration/misc/#760.js rename to tests/integration/misc/issue760.js index 8026c2cdc..dc483e7d9 100644 --- a/tests/integration/misc/#760.js +++ b/tests/integration/misc/issue760.js @@ -1,12 +1,12 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`orderBy extra property in relation modify #760`, () => { let knex = session.knex; let Person; - before(async () => { + beforeAll(async () => { await knex.schema.dropTableIfExists('Person').dropTableIfExists('PersonPerson'); await session.createTable('Person', (table, { increments }) => { increments('id'); @@ -20,11 +20,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('PersonPerson').dropTableIfExists('Person'); }); - before(() => { + beforeAll(() => { Person = class Person extends Model { static get tableName() { return 'Person'; @@ -96,7 +96,7 @@ module.exports = (session) => { .where('Person.id', 1) .withGraphFetched('[relatives, goodRelatives]') .then((result) => { - expect(result).to.containSubset([ + expect(result).toContainSubset([ { id: 1, name: 'parent', @@ -133,7 +133,7 @@ module.exports = (session) => { return Person.query().findById(1).withGraphFetched('relatives'); }) .then((result) => { - expect(result).to.containSubset({ + expect(result).toContainSubset({ id: 1, name: 'parent', relatives: [ @@ -163,7 +163,7 @@ module.exports = (session) => { return Person.query().findById(1).withGraphFetched('relatives'); }) .then((result) => { - expect(result).to.containSubset({ + expect(result).toContainSubset({ id: 1, name: 'parent', relatives: [ @@ -197,10 +197,13 @@ module.exports = (session) => { delete Person.beforeUpdate; } - expect(calls).to.have.deep.members([ - { relation: 'relatives', items: [1], inputItems: [2] }, - { relation: 'relatives', items: [1], inputItems: [3] }, - ]); + expect(calls).toHaveLength(2); + expect(calls).toEqual( + expect.arrayContaining([ + { relation: 'relatives', items: [1], inputItems: [2] }, + { relation: 'relatives', items: [1], inputItems: [3] }, + ]), + ); }); }); }; diff --git a/tests/integration/misc/#844.js b/tests/integration/misc/issue844.js similarity index 90% rename from tests/integration/misc/#844.js rename to tests/integration/misc/issue844.js index b0fdf77de..d067a91f6 100644 --- a/tests/integration/misc/#844.js +++ b/tests/integration/misc/issue844.js @@ -1,13 +1,13 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`Ambiguous column mapping for RelationJoinBuilder (no table ref) #844`, () => { let knex = session.knex; let Person; let Movie; - before(async () => { + beforeAll(async () => { await knex.schema .dropTableIfExists('Person_Movie') .dropTableIfExists('Movie') @@ -29,14 +29,14 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema .dropTableIfExists('Person_Movie') .dropTableIfExists('Movie') .dropTableIfExists('Person'); }); - before(() => { + beforeAll(() => { Person = class extends Model { static get tableName() { return 'Person'; @@ -91,7 +91,7 @@ module.exports = (session) => { }; }); - before(() => { + beforeAll(() => { return Movie.query(session.knex).insertGraph([ { id: 1, @@ -125,8 +125,8 @@ module.exports = (session) => { .select('Person.name', 'movies.name as movieName') .joinRelated('movies(onlyOldActors)') .then((results) => { - expect(results.length).to.equal(1); - expect(results[0].movieName).to.equal('movie 2'); + expect(results.length).toBe(1); + expect(results[0].movieName).toBe('movie 2'); }); }); }); diff --git a/tests/integration/misc/issue853.js b/tests/integration/misc/issue853.js new file mode 100644 index 000000000..186c9684e --- /dev/null +++ b/tests/integration/misc/issue853.js @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; + +export default (session) => { + describe('insertGraph() with relations through a key that the database generates #853', () => { + const { knex } = session; + + class Person extends Model { + static get tableName() { + return 'issue853_person'; + } + + static get relationMappings() { + return { + friends: { + relation: Model.ManyToManyRelation, + modelClass: Person, + join: { + from: 'issue853_person.uid', + through: { from: 'issue853_friend.aUid', to: 'issue853_friend.bUid' }, + to: 'issue853_person.uid', + }, + }, + children: { + relation: Model.HasManyRelation, + modelClass: Person, + join: { from: 'issue853_person.id', to: 'issue853_person.parentId' }, + }, + }; + } + } + + const generateUid = () => + session.isPostgres() + ? knex.raw('gen_random_uuid()::text') + : session.isMySql() + ? knex.raw('(uuid())') + : session.isMsSql() + ? knex.raw('newid()') + : knex.raw('(lower(hex(randomblob(16))))'); + + const dropTables = () => + knex.schema.dropTableIfExists('issue853_friend').dropTableIfExists('issue853_person'); + + beforeAll(async () => { + await dropTables(); + await knex.schema + .createTable('issue853_person', (table) => { + table.increments('id'); + table.string('name'); + table.integer('parentId'); + table.string('uid', 36).defaultTo(generateUid()); + }) + .createTable('issue853_friend', (table) => { + table.string('aUid', 36); + table.string('bUid', 36); + }); + }); + + afterAll(dropTables); + + it('should relate the inserted models by the generated key', async () => { + const person = await Person.query(knex).insertGraph({ + name: 'a', + friends: [{ name: 'b' }, { name: 'c' }], + }); + + const fetched = await Person.query(knex) + .findById(person.id) + .withGraphFetched('friends(orderByName)') + .modifiers({ orderByName: (query) => query.orderBy('name') }); + + expect(fetched.friends.map((friend) => friend.name)).toEqual(['b', 'c']); + expect(person.uid).toBe(fetched.uid); + }); + + it('should fetch the generated key next to a relation through the id', async () => { + const person = await Person.query(knex).insertGraph({ + name: 'd', + friends: [{ name: 'e' }], + children: [{ name: 'f' }], + }); + + const fetched = await Person.query(knex) + .findById(person.id) + .withGraphFetched('[friends, children]'); + + expect(fetched.friends.map((friend) => friend.name)).toEqual(['e']); + expect(fetched.children.map((child) => child.name)).toEqual(['f']); + expect(person.uid).toBe(fetched.uid); + }); + }); + + describe('upsertGraph() with relations that share a join table #853', () => { + const { knex } = session; + + const createLink = (linkType) => ({ + relation: Model.ManyToManyRelation, + modelClass: Node, + join: { + from: 'issue853_node.id', + through: { + from: 'issue853_link.sourceId', + to: 'issue853_link.targetId', + extra: ['linkType'], + beforeInsert: (link) => { + link.linkType = linkType; + }, + }, + to: 'issue853_node.id', + }, + filter: (query) => query.where('issue853_link.linkType', linkType), + }); + + class Node extends Model { + static get tableName() { + return 'issue853_node'; + } + + static get relationMappings() { + return { outgoing: createLink('outgoing'), incoming: createLink('incoming') }; + } + } + + const dropTables = () => + knex.schema.dropTableIfExists('issue853_link').dropTableIfExists('issue853_node'); + + beforeAll(async () => { + await dropTables(); + await knex.schema + .createTable('issue853_node', (table) => { + table.integer('id').primary(); + }) + .createTable('issue853_link', (table) => { + table.integer('sourceId'); + table.integer('targetId'); + table.string('linkType'); + table.primary(['sourceId', 'targetId', 'linkType']); + }); + + await knex('issue853_node').insert([{ id: 2 }, { id: 3 }]); + await knex('issue853_link').insert([ + { sourceId: 2, targetId: 3, linkType: 'outgoing' }, + { sourceId: 3, targetId: 2, linkType: 'incoming' }, + ]); + }); + + afterAll(dropTables); + + it('should keep the join rows of each relation', async () => { + await Node.query(knex).upsertGraph( + { id: 2, '#id': 'two', outgoing: [{ id: 3, incoming: [{ '#ref': 'two' }] }] }, + { allowRefs: true }, + ); + + expect(await knex('issue853_link').orderBy('sourceId')).toEqual([ + { sourceId: 2, targetId: 3, linkType: 'outgoing' }, + { sourceId: 3, targetId: 2, linkType: 'incoming' }, + ]); + }); + }); +}; diff --git a/tests/integration/misc/#909.js b/tests/integration/misc/issue909.js similarity index 84% rename from tests/integration/misc/#909.js rename to tests/integration/misc/issue909.js index 6572ecb8b..069ad96a5 100644 --- a/tests/integration/misc/#909.js +++ b/tests/integration/misc/issue909.js @@ -1,8 +1,8 @@ -const { randomUUID } = require('crypto'); -const { expect } = require('chai'); -const { Model, val, raw } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { Model, val, raw } from 'objection'; -module.exports = (session) => { +export default (session) => { if (!session.isPostgres()) { return; } @@ -11,7 +11,7 @@ module.exports = (session) => { let knex = session.knex; let Person; - before(() => { + beforeAll(() => { return knex.schema.dropTableIfExists('Person').createTable('Person', (table) => { table.increments('id').primary(); table.string('name'); @@ -19,11 +19,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('Person'); }); - before(() => { + beforeAll(() => { Person = class Person extends Model { static get tableName() { return 'Person'; @@ -47,7 +47,7 @@ module.exports = (session) => { return Person.query(); }) .then((people) => { - expect(people).to.containSubset([ + expect(people).toContainSubset([ { name: 'Margot', uuids, @@ -68,7 +68,7 @@ module.exports = (session) => { return Person.query(); }) .then((people) => { - expect(people).to.containSubset([ + expect(people).toContainSubset([ { name: 'Margot', uuids, @@ -89,7 +89,7 @@ module.exports = (session) => { return Person.query(); }) .then((people) => { - expect(people).to.containSubset([ + expect(people).toContainSubset([ { name: 'Margot', uuids, diff --git a/tests/integration/misc/manyToManyBinaryIds.js b/tests/integration/misc/manyToManyBinaryIds.js new file mode 100644 index 000000000..6597c9e36 --- /dev/null +++ b/tests/integration/misc/manyToManyBinaryIds.js @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; + +export default (session) => { + describe('many-to-many patch with join table extras of rows with binary ids (#287)', () => { + const { knex } = session; + const binaryId = (byte) => Buffer.from([...new Array(15).fill(0), byte]); + + let Owner; + let Item; + + beforeAll(async () => { + await knex.schema + .dropTableIfExists('binary_ids_owners_items') + .dropTableIfExists('binary_ids_items') + .dropTableIfExists('binary_ids_owners') + .createTable('binary_ids_owners', (table) => { + table.integer('id').primary(); + }) + .createTable('binary_ids_items', (table) => { + table.binary('id', 16).primary(); + table.string('name'); + }) + .createTable('binary_ids_owners_items', (table) => { + table.integer('ownerId'); + table.binary('itemId', 16); + table.string('extra'); + }); + + Item = class Item extends Model { + static get tableName() { + return 'binary_ids_items'; + } + }; + + Owner = class Owner extends Model { + static get tableName() { + return 'binary_ids_owners'; + } + + static get relationMappings() { + return { + items: { + relation: Model.ManyToManyRelation, + modelClass: Item, + join: { + from: 'binary_ids_owners.id', + through: { + from: 'binary_ids_owners_items.ownerId', + to: 'binary_ids_owners_items.itemId', + extra: ['extra'], + }, + to: 'binary_ids_items.id', + }, + }, + }; + } + }; + + Owner.knex(knex); + Item.knex(knex); + + await knex('binary_ids_owners').insert({ id: 1 }); + await knex('binary_ids_items').insert([ + { id: binaryId(1), name: 'a' }, + { id: binaryId(2), name: 'a' }, + ]); + await knex('binary_ids_owners_items').insert([ + { ownerId: 1, itemId: binaryId(1), extra: 'x' }, + { ownerId: 1, itemId: binaryId(2), extra: 'x' }, + ]); + }); + + afterAll(() => { + return knex.schema + .dropTableIfExists('binary_ids_owners_items') + .dropTableIfExists('binary_ids_items') + .dropTableIfExists('binary_ids_owners'); + }); + + it('should patch and find the related rows by their binary ids', async () => { + let foundNames = null; + Item.afterUpdate = async ({ asFindQuery }) => { + foundNames = (await asFindQuery()).map((item) => item.name); + }; + + const owner = await Owner.query().findById(1); + const numUpdated = await owner + .$relatedQuery('items') + .patch({ name: 'b', extra: 'y' }) + .where('binary_ids_owners_items.extra', 'x'); + + expect(numUpdated).toBe(2); + expect(foundNames).toEqual(['b', 'b']); + expect(await knex('binary_ids_items').pluck('name')).toEqual(['b', 'b']); + expect(await knex('binary_ids_owners_items').pluck('extra')).toEqual(['y', 'y']); + }); + }); +}; diff --git a/tests/integration/misc/modelWithLengthProperty.js b/tests/integration/misc/modelWithLengthProperty.js index 6b1f0e658..9a3d9fca3 100644 --- a/tests/integration/misc/modelWithLengthProperty.js +++ b/tests/integration/misc/modelWithLengthProperty.js @@ -1,11 +1,11 @@ -const expect = require('expect.js'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('model with `length` property', () => { let TestModel; - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('model_with_length_test') .createTable('model_with_length_test', (table) => { @@ -14,11 +14,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema.dropTableIfExists('model_with_length_test'); }); - before(() => { + beforeAll(() => { TestModel = class TestModel extends Model { static get tableName() { return 'model_with_length_test'; @@ -32,12 +32,12 @@ module.exports = (session) => { return TestModel.query() .insert({ length: 10 }) .then((model) => { - expect(model).to.eql({ id: 1, length: 10 }); + expect(model).toEqual({ id: 1, length: 10 }); return session.knex(TestModel.getTableName()); }) .then((rows) => { - expect(rows.length).to.equal(1); - expect(rows[0]).to.eql({ id: 1, length: 10 }); + expect(rows.length).toBe(1); + expect(rows[0]).toEqual({ id: 1, length: 10 }); }); }); }); diff --git a/tests/integration/misc/multipleResultsWithOneToOneRelation.js b/tests/integration/misc/multipleResultsWithOneToOneRelation.js index d88754f7c..38cb09771 100644 --- a/tests/integration/misc/multipleResultsWithOneToOneRelation.js +++ b/tests/integration/misc/multipleResultsWithOneToOneRelation.js @@ -1,6 +1,6 @@ -const expect = require('expect.js'); +import { describe, it, expect, beforeEach } from 'vitest'; -module.exports = (session) => { +export default (session) => { describe('multiple results with a one-to-one relation', () => { beforeEach(() => { // This tests insertGraph. @@ -31,7 +31,7 @@ module.exports = (session) => { .whereIn('id', [1, 3]) .withGraphFetched('model1Relation1') .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -69,7 +69,7 @@ module.exports = (session) => { .whereIn('id', [2, 4]) .withGraphFetched('model1Relation1Inverse') .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 2, model1Id: null, diff --git a/tests/integration/misc/mysqlBinaryColumns.js b/tests/integration/misc/mysqlBinaryColumns.js index 02970392d..5e8ac02cd 100644 --- a/tests/integration/misc/mysqlBinaryColumns.js +++ b/tests/integration/misc/mysqlBinaryColumns.js @@ -1,12 +1,12 @@ -const expect = require('expect.js'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { if (session.isMySql()) { describe('mysql binary columns', () => { let TestModel; - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('mysql_binary_test') .createTable('mysql_binary_test', (table) => { @@ -15,11 +15,11 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema.dropTableIfExists('mysql_binary_test'); }); - before(() => { + beforeAll(() => { TestModel = class TestModel extends Model { static get tableName() { return 'mysql_binary_test'; @@ -50,11 +50,11 @@ module.exports = (session) => { return TestModel.query() .insert({ binary: buffer() }) .then((ret) => { - expect(bufferEquals(buffer(), ret.binary)).to.equal(true); + expect(bufferEquals(buffer(), ret.binary)).toBe(true); return session.knex(TestModel.getTableName()); }) .then((rows) => { - expect(bufferEquals(buffer(), rows[0].binary)).to.equal(true); + expect(bufferEquals(buffer(), rows[0].binary)).toBe(true); }); }); }); diff --git a/tests/integration/misc/nonMutatingRelatedQuery.js b/tests/integration/misc/nonMutatingRelatedQuery.js index 18c8d6211..282a9c02a 100644 --- a/tests/integration/misc/nonMutatingRelatedQuery.js +++ b/tests/integration/misc/nonMutatingRelatedQuery.js @@ -1,6 +1,6 @@ -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; -module.exports = (session) => { +export default (session) => { describe('non-mutating related query', () => { class ModelOne extends session.unboundModels.Model1 { static get relatedFindQueryMutates() { @@ -22,7 +22,7 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { ModelOne = ModelOne.bindKnex(session.knex); ModelTwo = ModelTwo.bindKnex(session.knex); }); @@ -63,7 +63,7 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation1').then(() => model); }) .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 1, model1Id: 2, model1Prop1: 'root', @@ -79,7 +79,7 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation2').then(() => model); }) .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 1, model1Id: 2, model1Prop1: 'root', @@ -95,7 +95,7 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation3').then(() => model); }) .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 1, model1Id: 2, model1Prop1: 'root', @@ -116,7 +116,7 @@ module.exports = (session) => { .then(() => model); }) .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 1, model1Id: 10, model1Prop1: 'root', @@ -135,7 +135,7 @@ module.exports = (session) => { .then(() => model); }) .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 1, model1Id: 2, model1Prop1: 'root', @@ -154,7 +154,7 @@ module.exports = (session) => { .then(() => model); }) .then((model) => { - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ id: 1, model1Id: 2, model1Prop1: 'root', diff --git a/tests/integration/misc/queryPropertiesOnInstances.js b/tests/integration/misc/queryPropertiesOnInstances.js index 532804f2e..e5cdb3b5d 100644 --- a/tests/integration/misc/queryPropertiesOnInstances.js +++ b/tests/integration/misc/queryPropertiesOnInstances.js @@ -1,7 +1,7 @@ -const { expect } = require('chai'); -const { Model, ValidationError, raw } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model, ValidationError, raw } from 'objection'; -module.exports = (session) => { +export default (session) => { const { knex } = session; describe('query properties of model instances #202', () => { @@ -30,7 +30,7 @@ module.exports = (session) => { let TestPost; - before(() => { + beforeAll(() => { return knex.schema.dropTableIfExists('Post').createTable('Post', (table) => { // SQL Server can't update identity columns, and the tests patch whole instances: table.integer('id').primary(); @@ -38,13 +38,13 @@ module.exports = (session) => { table.integer('views'); table.integer('parentId'); - if (session.isPostgres()) { + if (!session.isMsSql()) { table.jsonb('data'); } }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('Post'); }); @@ -58,20 +58,20 @@ module.exports = (session) => { it('should write a value assigned after a write with a query property', async () => { const { id } = await TestPost.query().insert({ id: 1, name: 'a', views: 0 }); const post = await TestPost.query().patchAndFetchById(id, { views: raw('views + 1') }); - expect(post.views).to.equal(1); + expect(post.views).toBe(1); post.views = 100; await post.$query().patch(); - expect(await fetchViews(id)).to.equal(100); + expect(await fetchViews(id)).toBe(100); }); - if (session.isPostgres()) { + if (!session.isMsSql()) { it('should write a json column assigned after a write with a field expression', async () => { const { id } = await TestPost.query().insert({ id: 1, name: 'a', data: { a: 1 } }); const post = await TestPost.query().patchAndFetchById(id, { 'data:a': raw('2') }); - expect(post.data).to.eql({ a: 2 }); + expect(post.data).toEqual({ a: 2 }); post.data = { a: 3 }; await post.$query().patch(); - expect((await TestPost.query().findById(id)).data).to.eql({ a: 3 }); + expect((await TestPost.query().findById(id)).data).toEqual({ a: 3 }); }); const expectJsonColumnError = async (promise) => { @@ -83,8 +83,8 @@ module.exports = (session) => { error = err; } - expect(error).to.be.an.instanceOf(ValidationError); - expect(error.message).to.equal( + expect(error).toBeInstanceOf(ValidationError); + expect(error.message).toBe( "data:b: can't be combined with a value for the json column 'data'", ); }; @@ -93,7 +93,7 @@ module.exports = (session) => { await TestPost.query().insert({ id: 1, name: 'a', data: { a: 1 } }); const post = TestPost.fromJson({ id: 1, data: { a: 2 }, 'data:b': raw('2') }); await expectJsonColumnError(TestPost.query().patch(post).where('id', 1)); - expect((await TestPost.query().findById(1)).data).to.eql({ a: 1 }); + expect((await TestPost.query().findById(1)).data).toEqual({ a: 1 }); }); it('should fail to write a field expression given to an instance with a value for its json column', async () => { @@ -101,7 +101,7 @@ module.exports = (session) => { const post = await TestPost.query().findById(1); post.$setJson({ 'data:b': raw('2') }); await expectJsonColumnError(post.$query().patch()); - expect((await TestPost.query().findById(1)).data).to.eql({ a: 1 }); + expect((await TestPost.query().findById(1)).data).toEqual({ a: 1 }); }); } @@ -110,7 +110,7 @@ module.exports = (session) => { const post = await TestPost.query().patchAndFetchById(id, { views: raw('views + 1') }); post.$setJson({ views: 200 }); await post.$query().patch(); - expect(await fetchViews(id)).to.equal(200); + expect(await fetchViews(id)).toBe(200); }); it('should not run the query property again in a later write', async () => { @@ -118,14 +118,14 @@ module.exports = (session) => { const post = await TestPost.query().patchAndFetchById(id, { views: raw('views + 1') }); post.name = 'b'; await post.$query().patch(); - expect(await fetchViews(id)).to.equal(1); + expect(await fetchViews(id)).toBe(1); }); it('should write a value assigned to an inserted instance', async () => { const post = await TestPost.query().insert({ id: 1, name: 'a', views: raw('10') }); post.views = 1; await TestPost.query().patch(post).where('id', post.id); - expect(await fetchViews(post.id)).to.equal(1); + expect(await fetchViews(post.id)).toBe(1); }); it('should write a value assigned to a clone', async () => { @@ -133,38 +133,38 @@ module.exports = (session) => { const clone = post.$clone(); clone.views = 3; const { id } = await TestPost.query().insert(clone); - expect(await fetchViews(id)).to.equal(3); + expect(await fetchViews(id)).toBe(3); }); it('should keep the query properties of an unchanged clone', async () => { const clone = TestPost.fromJson({ id: 1, name: 'a', views: raw('10') }).$clone(); const { id } = await TestPost.query().insert(clone); - expect(await fetchViews(id)).to.equal(10); + expect(await fetchViews(id)).toBe(10); }); it('should write a query property instead of the schema default', async () => { const { id } = await TestPost.query().insert({ id: 1, name: 'a', views: raw('10') }); - expect(await fetchViews(id)).to.equal(10); + expect(await fetchViews(id)).toBe(10); }); it('should not set the schema default on the model for a query property', () => { const post = TestPost.fromJson({ id: 1, name: 'a', views: raw('10') }); - expect(post.views).to.equal(undefined); - expect(post.toJSON()).to.eql({ id: 1, name: 'a' }); + expect(post.views).toBe(undefined); + expect(post.toJSON()).toEqual({ id: 1, name: 'a' }); }); it('should write a query property set with $setJson() over a value', async () => { const post = TestPost.fromJson({ id: 1, name: 'a', views: 5 }); post.$setJson({ views: raw('10') }); const { id } = await TestPost.query().insert(post); - expect(await fetchViews(id)).to.equal(10); + expect(await fetchViews(id)).toBe(10); }); it('should keep the query properties of earlier $setJson() calls', async () => { const post = TestPost.fromJson({ id: 1, views: raw('10') }); post.$setJson({ name: raw('?', ['a']) }); const { id } = await TestPost.query().insert(post); - expect(await TestPost.query().findById(id)).to.containSubset({ name: 'a', views: 10 }); + expect(await TestPost.query().findById(id)).toMatchObject({ name: 'a', views: 10 }); }); it('should write a query property instead of the schema default in upsertGraph()', async () => { @@ -173,7 +173,7 @@ module.exports = (session) => { { id, name: 'b', views: raw('views + 1') }, { update: true }, ); - expect(await fetchViews(id)).to.equal(6); + expect(await fetchViews(id)).toBe(6); }); it('should write query properties over values set in hooks', async () => { @@ -184,7 +184,7 @@ module.exports = (session) => { } const { id } = await HookPost.query().insert({ id: 1, name: 'a', views: raw('10') }); - expect(await fetchViews(id)).to.equal(10); + expect(await fetchViews(id)).toBe(10); }); it('should not write the values set in insert hooks over query properties in later writes', async () => { @@ -197,7 +197,7 @@ module.exports = (session) => { const post = await HookPost.query().insert({ id: 1, name: 'a', views: raw('10') }); post.name = 'b'; await post.$query().patch(); - expect(await fetchViews(post.id)).to.equal(10); + expect(await fetchViews(post.id)).toBe(10); }); it('should not write the values set in update hooks over query properties in later writes', async () => { @@ -212,7 +212,7 @@ module.exports = (session) => { await post.$query().patch(); post.name = 'b'; await post.$query().patch(); - expect(await fetchViews(post.id)).to.equal(10); + expect(await fetchViews(post.id)).toBe(10); }); it('should write the foreign key set by a relation over a query property', async () => { @@ -221,7 +221,7 @@ module.exports = (session) => { const child = await parent .$relatedQuery('children') .insert({ id: 2, name: 'c', parentId: raw('9') }); - expect((await TestPost.query().findById(child.id)).parentId).to.equal(1); + expect((await TestPost.query().findById(child.id)).parentId).toBe(1); }); it('should run the query properties each time the same builder runs', async () => { @@ -231,7 +231,7 @@ module.exports = (session) => { .findById(id); await builder; await builder.clone(); - expect(await fetchViews(id)).to.equal(2); + expect(await fetchViews(id)).toBe(2); }); }); }; diff --git a/tests/integration/misc/queryPropsInJsonAttributes.js b/tests/integration/misc/queryPropsInJsonAttributes.js index df306594e..cbeb06ad0 100644 --- a/tests/integration/misc/queryPropsInJsonAttributes.js +++ b/tests/integration/misc/queryPropsInJsonAttributes.js @@ -1,7 +1,7 @@ -const { Model, raw } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model, raw } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('query properties set on json attributes in $beforeInsert() and $beforeUpdate()', () => { const { knex } = session; @@ -29,16 +29,16 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('queryPropsInJsonAttributes') .createTable('queryPropsInJsonAttributes', (table) => { table.integer('id').primary(); - table.text('meta'); + table.json('meta'); }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('queryPropsInJsonAttributes'); }); @@ -49,21 +49,21 @@ module.exports = (session) => { it('insert() should write the query property', async () => { await Item.query(knex).insert({ id: 1 }); const item = await Item.query(knex).findById(1); - expect(item.meta).to.eql({ hook: 'insert' }); + expect(item.meta).toEqual({ hook: 'insert' }); }); it('patch() should write the query property', async () => { await Item.query(knex).insert({ id: 1 }); await Item.query(knex).patch({}).where('id', 1); const item = await Item.query(knex).findById(1); - expect(item.meta).to.eql({ hook: 'update' }); + expect(item.meta).toEqual({ hook: 'update' }); }); it('$query().patch() should write the query property', async () => { const item = await Item.query(knex).insert({ id: 1 }); await item.$query(knex).patch(); const { meta } = await Item.query(knex).findById(1); - expect(meta).to.eql({ hook: 'update' }); + expect(meta).toEqual({ hook: 'update' }); }); }); }; diff --git a/tests/integration/misc/refAttack.js b/tests/integration/misc/refAttack.js index 575062ca2..8dd9d26fc 100644 --- a/tests/integration/misc/refAttack.js +++ b/tests/integration/misc/refAttack.js @@ -1,7 +1,7 @@ -const { Model } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { const { knex } = session; describe('#ref attack', () => { @@ -30,7 +30,7 @@ module.exports = (session) => { } } - before(async () => { + beforeAll(async () => { await knex.schema.dropTableIfExists('users').dropTableIfExists('roles'); await session.createTable('users', (table, { increments }) => { increments('id'); @@ -45,7 +45,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('users').dropTableIfExists('roles'); }); @@ -104,10 +104,10 @@ module.exports = (session) => { const user3 = await User.query(knex).findById(3).withGraphFetched('roles'); - expect(user2.firstName).to.equal('dork 2'); - expect(user2.roles[0].name).to.equal(null); - expect(user3.firstName).to.equal(null); - expect(user3.roles[0].name).to.equal(null); + expect(user2.firstName).toBe('dork 2'); + expect(user2.roles[0].name).toBeNull(); + expect(user3.firstName).toBeNull(); + expect(user3.roles[0].name).toBeNull(); }); }); }; diff --git a/tests/integration/misc/relatedQueryErrors.js b/tests/integration/misc/relatedQueryErrors.js index c3e681890..e96962b92 100644 --- a/tests/integration/misc/relatedQueryErrors.js +++ b/tests/integration/misc/relatedQueryErrors.js @@ -1,7 +1,7 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('model relatedQueries fail when they lack a proper target', () => { let knex = session.knex; @@ -29,7 +29,7 @@ module.exports = (session) => { } } - before(async () => { + beforeAll(async () => { const knex = session.knex; await knex.schema.dropTableIfExists('posts'); @@ -47,7 +47,7 @@ module.exports = (session) => { }); }); - after(async () => { + afterAll(async () => { await knex.schema.dropTableIfExists('posts'); await knex.schema.dropTableIfExists('users'); }); @@ -56,7 +56,7 @@ module.exports = (session) => { try { await User.relatedQuery('posts', knex).insert({ content: 'my post content' }); } catch (e) { - expect(e.message).to.equal( + expect(e.message).toBe( 'query method `for` ommitted outside a subquery, can not figure out relation target', ); } @@ -65,7 +65,7 @@ module.exports = (session) => { try { await User.relatedQuery('posts', knex).where({ content: 'my post content' }); } catch (e) { - expect(e.message).to.equal( + expect(e.message).toBe( 'query method `for` ommitted outside a subquery, can not figure out relation target', ); } diff --git a/tests/integration/misc/relatedQueryReturning.js b/tests/integration/misc/relatedQueryReturning.js new file mode 100644 index 000000000..f068dc705 --- /dev/null +++ b/tests/integration/misc/relatedQueryReturning.js @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +export default (session) => { + const { Model1, Model2 } = session.models; + const hasReturning = session.isPostgres() || session.isMsSql() || session.isSqlite(); + + describe('writes through to-one relation queries', () => { + let owner; + let inverseOwner; + let throughOwner; + let ownerWithout; + + beforeEach(async () => { + await session.populate([ + { + id: 1, + model1Prop1: 'owner', + model1Relation1: { id: 2, model1Prop1: 'parent' }, + model1Relation2: [ + { + idCol: 1, + model2Prop1: 'through', + model2Relation2: { id: 3, model1Prop1: 'related' }, + }, + ], + }, + { id: 4, model1Prop1: 'without' }, + ]); + owner = await Model1.query().findById(1); + inverseOwner = await Model1.query().findById(2); + throughOwner = await Model2.query().findById(1); + ownerWithout = await Model1.query().findById(4); + }); + + // A second row that has the same owner as the first one: + const addSecondRow = () => + session.knex('Model1').insert({ id: 5, model1Id: 2, model1Prop1: 'second' }); + + it('asFindQuery() in static hooks finds all rows that the write changes', async () => { + await addSecondRow(); + let found; + Model1.beforeUpdate = async ({ asFindQuery }) => { + found = await asFindQuery(); + }; + try { + const result = await inverseOwner + .$relatedQuery('model1Relation1Inverse') + .patch({ model1Prop1: 'patched' }); + expect(result).toBe(2); + expect(found.map((it) => it.id).sort()).toEqual([1, 5]); + } finally { + delete Model1.beforeUpdate; + } + }); + + if (hasReturning) { + const relations = [ + ['belongs to one', () => owner, 'model1Relation1', 2], + ['has one', () => inverseOwner, 'model1Relation1Inverse', 1], + ['has one through', () => throughOwner, 'model2Relation2', 3], + ]; + + for (const [title, getOwner, relationName, id] of relations) { + describe(title, () => { + it('patch() resolves to one model', async () => { + const result = await getOwner() + .$relatedQuery(relationName) + .patch({ model1Prop1: 'patched' }) + .returning('*'); + expect(result).toBeInstanceOf(Model1); + expect(result).toMatchObject({ id, model1Prop1: 'patched' }); + }); + + it('update() resolves to one model', async () => { + const result = await getOwner() + .$relatedQuery(relationName) + .update({ model1Prop1: 'updated' }) + .returning('*'); + expect(result).toBeInstanceOf(Model1); + expect(result).toMatchObject({ id, model1Prop1: 'updated' }); + }); + + // knex has no `returning` for deletes on SQLite. + if (!session.isSqlite()) { + it('delete() resolves to one model', async () => { + const result = await getOwner().$relatedQuery(relationName).delete().returning('*'); + expect(result).toBeInstanceOf(Model1); + expect(result).toMatchObject({ id }); + }); + } + + it('returning() before the write resolves to one model', async () => { + const result = await getOwner() + .$relatedQuery(relationName) + .returning('*') + .patch({ model1Prop1: 'patched' }); + expect(result).toMatchObject({ id, model1Prop1: 'patched' }); + }); + + it('the static relatedQuery() resolves to an array', async () => { + const OwnerClass = getOwner().constructor; + const result = await OwnerClass.relatedQuery(relationName) + .for(getOwner()) + .patch({ model1Prop1: 'patched' }) + .returning('*'); + expect(result).toEqual([expect.objectContaining({ id, model1Prop1: 'patched' })]); + }); + }); + } + + it('resolves to all rows for several related rows, like the find', async () => { + await addSecondRow(); + const result = await inverseOwner + .$relatedQuery('model1Relation1Inverse') + .patch({ model1Prop1: 'patched' }) + .returning('*'); + expect(result.map((it) => it.id).sort()).toEqual([1, 5]); + }); + + it('static afterUpdate() hooks get the one model', async () => { + let hookResult; + Model1.afterUpdate = ({ result }) => { + hookResult = result; + }; + try { + await owner + .$relatedQuery('model1Relation1') + .patch({ model1Prop1: 'patched' }) + .returning('*'); + expect(hookResult).toBeInstanceOf(Model1); + expect(hookResult).toMatchObject({ id: 2, model1Prop1: 'patched' }); + } finally { + delete Model1.afterUpdate; + } + }); + + it('resolves to undefined without a related row', async () => { + const result = await ownerWithout + .$relatedQuery('model1Relation1') + .patch({ model1Prop1: 'patched' }) + .returning('*'); + expect(result).toBeUndefined(); + }); + + it('resolves to the number of rows without returning()', async () => { + const result = await owner + .$relatedQuery('model1Relation1') + .patch({ model1Prop1: 'patched' }); + expect(result).toBe(1); + }); + } + }); +}; diff --git a/tests/integration/misc/relationHooks.js b/tests/integration/misc/relationHooks.js index a37a93c43..5aa6618bf 100644 --- a/tests/integration/misc/relationHooks.js +++ b/tests/integration/misc/relationHooks.js @@ -1,7 +1,7 @@ -const { expect } = require('chai'); -const { Model, snakeCaseMappers } = require('../../../'); +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { Model, snakeCaseMappers } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('relation hooks', () => { describe('beforeInsert', () => { class Model1 extends Model { @@ -136,7 +136,7 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { Model1.knex(session.knex); Model2.knex(session.knex); }); @@ -169,7 +169,7 @@ module.exports = (session) => { return session.knex(Model1.getTableName()).where({ model1Prop1: 'new' }).first(); }) .then((row) => { - expect(row.model1Prop2).to.equal(42); + expect(row.model1Prop2).toBe(42); }); }); @@ -182,7 +182,7 @@ module.exports = (session) => { return session.knex(Model2.getTableName()).where({ model2_prop1: 'new' }).first(); }) .then((row) => { - expect(row.model2_prop2).to.equal(100); + expect(row.model2_prop2).toBe(100); }); }); @@ -198,12 +198,12 @@ module.exports = (session) => { return session.knex(Model2.getTableName()).where({ model2_prop1: 'new' }).first(); }) .then((row) => { - expect(row.model2_prop2).to.equal(7); + expect(row.model2_prop2).toBe(7); return session.knex('Model1Model2'); }) .then((rows) => { - expect(rows.length).to.equal(1); - expect(rows[0].extra2).to.equal('Hello'); + expect(rows.length).toBe(1); + expect(rows[0].extra2).toBe('Hello'); }); }); @@ -219,8 +219,8 @@ module.exports = (session) => { return session.knex('Model1Model2'); }) .then((rows) => { - expect(rows.length).to.equal(1); - expect(rows[0].extra2).to.equal('Extra'); + expect(rows.length).toBe(1); + expect(rows[0].extra2).toBe('Extra'); }); }); @@ -262,7 +262,7 @@ module.exports = (session) => { .findOne({ model1Prop1: 'parent' }) .withGraphFetched('[model1Relation1, model1Relation2, model1Relation3]') .then((model) => { - expect(model).to.containSubset({ + expect(model).toContainSubset({ model1Prop1: 'parent', model1Relation1: { @@ -359,7 +359,7 @@ module.exports = (session) => { .findOne({ model1Prop1: 'parent' }) .withGraphFetched('[model1Relation1, model1Relation2, model1Relation3]') .then((model) => { - expect(model).to.containSubset({ + expect(model).toContainSubset({ model1Prop1: 'parent', model1Relation1: { @@ -415,8 +415,8 @@ module.exports = (session) => { .insert({ model1Prop1: 'new' }) .context({ owners }); - expect(owners).to.have.length(1); - expect(owners[0].owner).to.equal(root); + expect(owners).toHaveLength(1); + expect(owners[0].owner).toBe(root); }); it('$relatedQuery().insert() has many relation', async () => { @@ -425,8 +425,8 @@ module.exports = (session) => { .insert({ model2Prop1: 'new' }) .context({ owners }); - expect(owners).to.have.length(1); - expect(owners[0].owner).to.equal(root); + expect(owners).toHaveLength(1); + expect(owners[0].owner).toBe(root); }); it('$relatedQuery().insert() many to many relation', async () => { @@ -435,8 +435,8 @@ module.exports = (session) => { .insert({ model2Prop1: 'new' }) .context({ owners }); - expect(owners).to.have.length(1); - expect(owners[0].owner).to.equal(root); + expect(owners).toHaveLength(1); + expect(owners[0].owner).toBe(root); }); it('relatedQuery().for(model).insert()', async () => { @@ -445,8 +445,8 @@ module.exports = (session) => { .insert({ model2Prop1: 'new' }) .context({ owners }); - expect(owners).to.have.length(1); - expect(owners[0].owner).to.equal(root); + expect(owners).toHaveLength(1); + expect(owners[0].owner).toBe(root); }); it('relatedQuery().for(id).insert() passes undefined', async () => { @@ -455,8 +455,8 @@ module.exports = (session) => { .insert({ model2Prop1: 'new' }) .context({ owners }); - expect(owners).to.have.length(1); - expect(owners[0].owner).to.equal(undefined); + expect(owners).toHaveLength(1); + expect(owners[0].owner).toBeUndefined(); }); it('relatedQuery().for([model1, model2]).insert() passes undefined', async () => { @@ -467,8 +467,8 @@ module.exports = (session) => { .insert({ model1Prop1: 'new' }) .context({ owners }); - expect(owners).to.have.length(1); - expect(owners[0].owner).to.equal(undefined); + expect(owners).toHaveLength(1); + expect(owners[0].owner).toBeUndefined(); }); it('$relatedQuery().insertGraph()', async () => { @@ -477,8 +477,8 @@ module.exports = (session) => { .insertGraph({ model2Prop1: 'new' }) .context({ owners }); - expect(owners).to.have.length(1); - expect(owners[0].owner).to.equal(root); + expect(owners).toHaveLength(1); + expect(owners[0].owner).toBe(root); }); it('insertGraph()', async () => { @@ -493,14 +493,14 @@ module.exports = (session) => { const ownerOf = (relation) => owners.find((it) => it.relation === relation); - expect(owners).to.have.length(3); - expect(ownerOf('model1Relation1').owner).to.equal(parent); - expect(ownerOf('model1Relation2').owner).to.equal(parent); - expect(ownerOf('model1Relation3').owner).to.equal(parent); + expect(owners).toHaveLength(3); + expect(ownerOf('model1Relation1').owner).toBe(parent); + expect(ownerOf('model1Relation2').owner).toBe(parent); + expect(ownerOf('model1Relation3').owner).toBe(parent); // The owner of a belongs to one relation is inserted after the related model. - expect(ownerOf('model1Relation1').ownerId).to.equal(undefined); + expect(ownerOf('model1Relation1').ownerId).toBeUndefined(); // The owner of a has many relation is inserted before the related models. - expect(ownerOf('model1Relation2').ownerId).to.equal(parent.id); + expect(ownerOf('model1Relation2').ownerId).toBe(parent.id); }); it('upsertGraph()', async () => { @@ -511,9 +511,9 @@ module.exports = (session) => { model1Relation2: [{ model2Prop1: 'child' }], }); - expect(owners).to.have.length(1); - expect(owners[0].owner.id).to.equal(root.id); - expect(owners[0].ownerId).to.equal(root.id); + expect(owners).toHaveLength(1); + expect(owners[0].owner.id).toBe(root.id); + expect(owners[0].ownerId).toBe(root.id); }); }); }); diff --git a/tests/integration/misc/relationKeys.js b/tests/integration/misc/relationKeys.js index 646436ac0..0ef1a472f 100644 --- a/tests/integration/misc/relationKeys.js +++ b/tests/integration/misc/relationKeys.js @@ -1,7 +1,7 @@ -const { Model } = require('../../../'); -const { expect } = require('chai'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('withGraphJoined() and withGraphFetched() should tell all keys apart (#222, #223)', () => { const { knex } = session; const timestampType = session.isMySql() @@ -19,7 +19,7 @@ module.exports = (session) => { let Item; let Folder; - before(() => { + beforeAll(() => { return knex.schema .dropTableIfExists('relation_keys_pets') .dropTableIfExists('relation_keys_persons') @@ -84,7 +84,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema .dropTableIfExists('relation_keys_pets') .dropTableIfExists('relation_keys_persons') @@ -98,7 +98,7 @@ module.exports = (session) => { .dropTableIfExists('relation_keys_folders'); }); - before(() => { + beforeAll(() => { class Pet extends Model { static get tableName() { return 'relation_keys_pets'; @@ -231,7 +231,7 @@ module.exports = (session) => { Folder.knex(knex); }); - before(async () => { + beforeAll(async () => { await knex('relation_keys_persons').insert([ { id: binaryId(0x80), name: 'p1' }, { id: binaryId(0x81), name: 'p2' }, @@ -281,7 +281,7 @@ module.exports = (session) => { [method]('pets') .orderBy('relation_keys_persons.name'); - expect(persons.map(({ name, pets }) => [name, pets.map(({ name }) => name)])).to.eql([ + expect(persons.map(({ name, pets }) => [name, pets.map(({ name }) => name)])).toEqual([ ['p1', ['pet1']], ['p2', ['pet2']], ]); @@ -293,7 +293,7 @@ module.exports = (session) => { .orderBy('relation_keys_readings.value'); const texts = readings.map(({ value, notes }) => [value, notes.map(({ text }) => text)]); - expect(texts).to.eql([ + expect(texts).toEqual([ [1, ['n1']], [2, []], [3, ['n3']], @@ -310,7 +310,7 @@ module.exports = (session) => { labels.map(({ name }) => name), ]); - expect(names).to.eql([ + expect(names).toEqual([ [1, ['part1'], ['label1']], [2, ['part2'], ['label2']], ]); @@ -320,7 +320,7 @@ module.exports = (session) => { const folders = await Folder.query()[method]('files'); const ids = folders.map(({ id, files }) => [id, files.map(({ id }) => id)]); - expect(ids.sort()).to.eql([ + expect(ids.sort()).toEqual([ ['a', [1]], ['a.files.k_1', []], ['a/files/k_1', []], @@ -335,10 +335,10 @@ module.exports = (session) => { { id: 'a.files.k_1', name: 'changed' }, ]); - expect(await knex('relation_keys_folders').where('id', 'a.files.k_1')).to.eql([ + expect(await knex('relation_keys_folders').where('id', 'a.files.k_1')).toEqual([ { id: 'a.files.k_1', name: 'changed' }, ]); - expect(await knex('relation_keys_files')).to.eql([{ id: 1, folderId: 'a', name: 'file1' }]); + expect(await knex('relation_keys_files')).toEqual([{ id: 1, folderId: 'a', name: 'file1' }]); }); it('fetchGraph() with dates in composite ids', async () => { @@ -349,7 +349,7 @@ module.exports = (session) => { ); const texts = readings.map(({ value, notes }) => [value, notes.map(({ text }) => text)]); - expect(texts).to.eql([ + expect(texts).toEqual([ [1, ['n1']], [2, []], [3, ['n3']], diff --git a/tests/integration/misc/sqliteBatchInsert.js b/tests/integration/misc/sqliteBatchInsert.js new file mode 100644 index 000000000..8e1f9d8d9 --- /dev/null +++ b/tests/integration/misc/sqliteBatchInsert.js @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import { Model } from 'objection'; + +export default (session) => { + describe.skipIf(!session.isSqlite())('insert() of several models on SQLite', () => { + const { knex } = session; + + class Item extends Model { + static get tableName() { + return 'batch_insert_item'; + } + } + + class Pair extends Model { + static get tableName() { + return 'batch_insert_pair'; + } + + static get idColumn() { + return ['a', 'b']; + } + } + + const dropTables = () => + knex.schema.dropTableIfExists('batch_insert_item').dropTableIfExists('batch_insert_pair'); + + beforeAll(async () => { + await dropTables(); + await knex.schema + .createTable('batch_insert_item', (table) => { + table.increments('id'); + table.string('name').unique(); + table.text('data'); + }) + .createTable('batch_insert_pair', (table) => { + table.integer('a'); + table.integer('b'); + table.string('name'); + table.primary(['a', 'b']); + }); + }); + + beforeEach(async () => { + await knex('batch_insert_item').delete(); + await knex('batch_insert_pair').delete(); + }); + + afterAll(dropTables); + + it('should store objects and arrays as JSON, in one row and in several', async () => { + await Item.query(knex).insert({ name: 'a', data: [1, 2] }); + await Item.query(knex).insert([ + { name: 'b', data: { b: 1 } }, + { name: 'c', data: [3] }, + ]); + + expect(await knex('batch_insert_item').select('name', 'data').orderBy('name')).toEqual([ + { name: 'a', data: '[1,2]' }, + { name: 'b', data: '{"b":1}' }, + { name: 'c', data: '[3]' }, + ]); + }); + + it('should return the ids of the inserted rows with onConflict().ignore()', async () => { + await knex('batch_insert_item').insert({ name: 'a' }); + + const [a, b] = await Item.query(knex) + .insert([{ name: 'a' }, { name: 'b' }]) + .onConflict('name') + .ignore(); + + expect(a.id).toBeUndefined(); + expect(b.id).toBe((await knex('batch_insert_item').where('name', 'b').first()).id); + }); + + it('should insert models with composite ids', async () => { + const pairs = await Pair.query(knex).insert([ + { a: 1, b: 2, name: 'x' }, + { a: 2, b: 1, name: 'y' }, + ]); + + expect(pairs.map((pair) => pair.$id())).toEqual([ + [1, 2], + [2, 1], + ]); + expect(await knex('batch_insert_pair').orderBy('a')).toHaveLength(2); + }); + + it('should not return anything for models with ids', () => { + const sql = Item.query(knex) + .insert([ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + ]) + .toKnexQuery() + .toString(); + + expect(sql).not.toMatch(/returning/); + }); + }); +}; diff --git a/tests/integration/misc/starSelectWithIdentifierMapping.js b/tests/integration/misc/starSelectWithIdentifierMapping.js index 02953ccbb..8a7f14067 100644 --- a/tests/integration/misc/starSelectWithIdentifierMapping.js +++ b/tests/integration/misc/starSelectWithIdentifierMapping.js @@ -1,14 +1,14 @@ -const Knex = require('knex'); -const { expect } = require('chai'); -const { Model, knexSnakeCaseMappers } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import Knex from 'knex'; +import { Model, knexSnakeCaseMappers } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('table.* selections with identifier mapping and withGraphFetched() #2288', () => { let knex; let BusinessUnit; let Field; - before(async () => { + beforeAll(async () => { await session.knex.schema .dropTableIfExists('data_extension_field') .dropTableIfExists('data_extension') @@ -29,7 +29,7 @@ module.exports = (session) => { }); }); - after(async () => { + afterAll(async () => { await session.knex.schema .dropTableIfExists('data_extension_field') .dropTableIfExists('data_extension') @@ -38,7 +38,7 @@ module.exports = (session) => { await knex.destroy(); }); - before(() => { + beforeAll(() => { knex = Knex({ ...session.opt.knexConfig, ...knexSnakeCaseMappers() }); BusinessUnit = class BusinessUnit extends Model { @@ -77,7 +77,7 @@ module.exports = (session) => { describe(`tableName: '${tableName}', select('${selection}')`, () => { let DataExtension; - before(() => { + beforeAll(() => { DataExtension = class DataExtension extends Model { static get tableName() { return tableName; @@ -112,10 +112,10 @@ module.exports = (session) => { .select(selection) .withGraphFetched('[businessUnit, fields]'); - expect(result.id).to.equal(1); - expect(result.businessUnitId).to.equal(1); - expect(result.businessUnit.name).to.equal('Unit'); - expect(result.fields.map((it) => it.name)).to.eql(['Field']); + expect(result.id).toBe(1); + expect(result.businessUnitId).toBe(1); + expect(result.businessUnit.name).toBe('Unit'); + expect(result.fields.map((it) => it.name)).toEqual(['Field']); }); it('should not select the relation columns again', () => { @@ -125,8 +125,8 @@ module.exports = (session) => { .toKnexQuery() .toQuery(); - expect(sql).to.not.match(/business_unit_id/); - expect(sql).to.not.match(/"?data_extension"?\."?id"?/); + expect(sql).not.toMatch(/business_unit_id/); + expect(sql).not.toMatch(/"?data_extension"?\."?id"?/); }); }); } @@ -134,7 +134,7 @@ module.exports = (session) => { describe('column selections', () => { let DataExtension; - before(() => { + beforeAll(() => { DataExtension = class DataExtension extends Model { static get tableName() { return 'dataExtension'; @@ -164,15 +164,15 @@ module.exports = (session) => { .select(selection) .withGraphFetched('businessUnit'); - expect(result.businessUnitId).to.equal(1); - expect(result.businessUnit.name).to.equal('Unit'); + expect(result.businessUnitId).toBe(1); + expect(result.businessUnit.name).toBe('Unit'); }); it(`should not select the relation column of select('${selection}') again`, () => { const sql = (query) => query.toKnexQuery().toQuery(); const query = DataExtension.query(knex).select(selection); - expect(sql(query.clone().withGraphFetched('businessUnit'))).to.equal(sql(query)); + expect(sql(query.clone().withGraphFetched('businessUnit'))).toBe(sql(query)); }); } }); diff --git a/tests/integration/misc/tableMetadata.js b/tests/integration/misc/tableMetadata.js index fa2412002..92f6ec062 100644 --- a/tests/integration/misc/tableMetadata.js +++ b/tests/integration/misc/tableMetadata.js @@ -1,8 +1,8 @@ -const expect = require('expect.js'); -const { Model } = require('../../../'); -const mockKnexFactory = require('../../../testUtils/mockKnex'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; +import mockKnexFactory from '../../../testUtils/mockKnex.js'; -module.exports = (session) => { +export default (session) => { describe('tableMetadata', () => { let knex; let queries = []; @@ -10,7 +10,7 @@ module.exports = (session) => { let UnboundTable1; let OverriddenTable1; - before(() => { + beforeAll(() => { return session.knex.schema.dropTableIfExists('table1').createTable('table1', (table) => { table.increments('id').primary(); table.integer('relId'); @@ -18,14 +18,14 @@ module.exports = (session) => { }); }); - before(() => { + beforeAll(() => { knex = mockKnexFactory(session.knex, function (mock, oldImpl, args) { queries.push(this.toString()); return oldImpl.apply(this, args); }); }); - after(() => { + afterAll(() => { return Promise.all([session.knex.schema.dropTableIfExists('table1')]); }); @@ -86,96 +86,96 @@ module.exports = (session) => { ]) .then((metadatas) => { // Only one query should have been generated. - expect(queries).to.have.length(1); + expect(queries).toHaveLength(1); metadatas.forEach((metadata) => { - expect(metadata.columns).to.eql(['id', 'relId', 'value']); - expect(metadata === metadatas[0]).to.equal(true); + expect(metadata.columns).toEqual(['id', 'relId', 'value']); + expect(metadata === metadatas[0]).toBe(true); }); - expect(Table1.tableMetadata()).to.eql({ + expect(Table1.tableMetadata()).toEqual({ columns: ['id', 'relId', 'value'], }); return Table1.fetchTableMetadata(); }) .then((metadata) => { - expect(metadata.columns).to.eql(['id', 'relId', 'value']); + expect(metadata.columns).toEqual(['id', 'relId', 'value']); // Cache should be used the second time. - expect(queries).to.have.length(1); + expect(queries).toHaveLength(1); return Table1.fetchTableMetadata({ force: true }); }) .then((metadata) => { - expect(metadata.columns).to.eql(['id', 'relId', 'value']); + expect(metadata.columns).toEqual(['id', 'relId', 'value']); // Cache should be ignored if `force = true`. - expect(queries).to.have.length(2); + expect(queries).toHaveLength(2); }); }); it('should accept knex instance as an argument', () => { return UnboundTable1.fetchTableMetadata({ knex }) .then((metadata) => { - expect(metadata.columns).to.eql(['id', 'relId', 'value']); + expect(metadata.columns).toEqual(['id', 'relId', 'value']); // Only one query should have been generated. - expect(queries).to.have.length(1); + expect(queries).toHaveLength(1); return Table1.fetchTableMetadata({ knex }); }) .then((metadata) => { - expect(metadata.columns).to.eql(['id', 'relId', 'value']); + expect(metadata.columns).toEqual(['id', 'relId', 'value']); // Cache should be used the second time. - expect(queries).to.have.length(1); + expect(queries).toHaveLength(1); return Table1.fetchTableMetadata({ knex, force: true }); }) .then((metadata) => { - expect(metadata.columns).to.eql(['id', 'relId', 'value']); + expect(metadata.columns).toEqual(['id', 'relId', 'value']); // Cache should be ignored if `force = true`. - expect(queries).to.have.length(2); + expect(queries).toHaveLength(2); }); }); it('should accept knex instance as an argument', () => { return UnboundTable1.fetchTableMetadata({ knex }) .then((metadata) => { - expect(metadata.columns).to.eql(['id', 'relId', 'value']); + expect(metadata.columns).toEqual(['id', 'relId', 'value']); // Only one query should have been generated. - expect(queries).to.have.length(1); + expect(queries).toHaveLength(1); return Table1.fetchTableMetadata({ knex }); }) .then((metadata) => { - expect(metadata.columns).to.eql(['id', 'relId', 'value']); + expect(metadata.columns).toEqual(['id', 'relId', 'value']); // Cache should be used the second time. - expect(queries).to.have.length(1); + expect(queries).toHaveLength(1); return Table1.fetchTableMetadata({ knex, force: true }); }) .then((metadata) => { - expect(metadata.columns).to.eql(['id', 'relId', 'value']); + expect(metadata.columns).toEqual(['id', 'relId', 'value']); // Cache should be ignored if `force = true`. - expect(queries).to.have.length(2); + expect(queries).toHaveLength(2); }); }); it('fetchTableMetadata should use tableMetadata function if overridden', () => { return OverriddenTable1.fetchTableMetadata() .then((metadata) => { - expect(metadata.columns).to.eql(['id', 'relId', 'value']); - expect(queries).to.have.length(0); + expect(metadata.columns).toEqual(['id', 'relId', 'value']); + expect(queries).toHaveLength(0); return OverriddenTable1.fetchTableMetadata(); }) .then((metadata) => { - expect(metadata.columns).to.eql(['id', 'relId', 'value']); - expect(queries).to.have.length(0); + expect(metadata.columns).toEqual(['id', 'relId', 'value']); + expect(queries).toHaveLength(0); }); }); it('joinEager should work with overridden tableMetadata', () => { const metadata = OverriddenTable1.tableMetadata(); - expect(metadata).to.eql({ + expect(metadata).toEqual({ columns: ['id', 'relId', 'value'], }); @@ -200,12 +200,12 @@ module.exports = (session) => { ) .then((res) => { if (session.isPostgres()) { - expect(queries[queries.length - 1]).to.eql( + expect(queries[queries.length - 1]).toEqual( `select "table1"."id" as "id", "table1"."relId" as "relId", "table1"."value" as "value", "rel"."id" as "rel:id", "rel"."relId" as "rel:relId", "rel"."value" as "rel:value", "rel:rel"."id" as "rel:rel:id", "rel:rel"."relId" as "rel:rel:relId", "rel:rel"."value" as "rel:rel:value" from "table1" left join "table1" as "rel" on "rel"."id" = "table1"."relId" left join "table1" as "rel:rel" on "rel:rel"."id" = "rel"."relId" where "table1"."value" = '1'`, ); } - expect(res).to.eql([ + expect(res).toEqual([ { value: '1', rel: { diff --git a/tests/integration/misc/unhandledRejectionErrors.js b/tests/integration/misc/unhandledRejectionErrors.js index eda93a6b9..b80ea3eda 100644 --- a/tests/integration/misc/unhandledRejectionErrors.js +++ b/tests/integration/misc/unhandledRejectionErrors.js @@ -1,6 +1,6 @@ -const expect = require('expect.js'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; -module.exports = (session) => { +export default (session) => { // Tests that various queries that start multiple queries behind the scenes // don't cause unhandled rejection errors if one of the queries fail. describe('unhandler rejection errors', () => { @@ -12,12 +12,12 @@ module.exports = (session) => { unhandledErrors.push(err); }; - before(() => { - session.addUnhandledRejectionHandler(unhandledRejectionHandler); + beforeAll(() => { + process.on('unhandledRejection', unhandledRejectionHandler); }); - after(() => { - session.removeUnhandledRejectionHandler(unhandledRejectionHandler); + afterAll(() => { + process.off('unhandledRejection', unhandledRejectionHandler); }); beforeEach(() => { @@ -55,23 +55,9 @@ module.exports = (session) => { ]); }); - it('handles each unhandled rejection once', async () => { - Promise.reject(new Error('unhandled')); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(unhandledErrors).to.have.length(1); - expect(unhandledErrors[0].message).to.equal('unhandled'); - }); - - it('range', (done) => { - Model1.query() - .table('doesnt_exist') - .range(1, 2) - .then(() => done(new Error('should not get here'))) - .catch((err) => { - expect(unhandledErrors).to.be.empty(); - done(); - }) - .catch(done); + it('range', async () => { + await expect(Model1.query().table('doesnt_exist').range(1, 2)).rejects.toThrow(); + expect(unhandledErrors).toHaveLength(0); }); }); }; diff --git a/tests/integration/misc/upsertGraphAmbiguousRows.js b/tests/integration/misc/upsertGraphAmbiguousRows.js new file mode 100644 index 000000000..b0f08530b --- /dev/null +++ b/tests/integration/misc/upsertGraphAmbiguousRows.js @@ -0,0 +1,889 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import { Model, ValidationError } from 'objection'; + +export default (session) => { + describe('insertGraph() and upsertGraph() with rows that the graph places in conflicting ways', () => { + const { knex } = session; + + class Toy extends Model { + static get tableName() { + return 'ambiguous_toy'; + } + } + + class Pet extends Model { + static get tableName() { + return 'ambiguous_pet'; + } + + static get relationMappings() { + return { + owner: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: 'ambiguous_pet.ownerId', to: 'ambiguous_person.id' }, + }, + ownerByCode: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: 'ambiguous_pet.ownerCode', to: 'ambiguous_person.code' }, + }, + toys: { + relation: Model.HasManyRelation, + modelClass: Toy, + join: { from: 'ambiguous_pet.id', to: 'ambiguous_toy.petId' }, + }, + }; + } + } + + class Profile extends Model { + static get tableName() { + return 'ambiguous_profile'; + } + + static get relationMappings() { + return { + person: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: 'ambiguous_profile.personId', to: 'ambiguous_person.id' }, + }, + }; + } + } + + class Movie extends Model { + static get tableName() { + return 'ambiguous_movie'; + } + + static get relationMappings() { + return { + actors: { + relation: Model.ManyToManyRelation, + modelClass: Person, + join: { + from: 'ambiguous_movie.id', + through: { + from: 'ambiguous_person_movie.movieId', + to: 'ambiguous_person_movie.personId', + extra: ['role'], + }, + to: 'ambiguous_person.id', + }, + }, + }; + } + } + + class Person extends Model { + static get tableName() { + return 'ambiguous_person'; + } + + static get relationMappings() { + return { + parent: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: 'ambiguous_person.parentId', to: 'ambiguous_person.id' }, + }, + children: { + relation: Model.HasManyRelation, + modelClass: Person, + join: { from: 'ambiguous_person.id', to: 'ambiguous_person.parentId' }, + }, + boss: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: 'ambiguous_person.bossId', to: 'ambiguous_person.id' }, + }, + petsByCode: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: 'ambiguous_person.code', to: 'ambiguous_pet.ownerCode' }, + }, + cats: { + relation: Model.HasManyRelation, + modelClass: Pet, + filter: (query) => query.where('label', 'cat'), + join: { from: 'ambiguous_person.id', to: 'ambiguous_pet.ownerId' }, + }, + dogs: { + relation: Model.HasManyRelation, + modelClass: Pet, + filter: (query) => query.where('label', 'dog'), + join: { from: 'ambiguous_person.id', to: 'ambiguous_pet.ownerId' }, + }, + favoriteCat: { + relation: Model.HasOneRelation, + modelClass: Pet, + filter: (query) => query.where('label', 'cat'), + join: { from: 'ambiguous_person.id', to: 'ambiguous_pet.ownerId' }, + }, + pets: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: 'ambiguous_person.id', to: 'ambiguous_pet.ownerId' }, + }, + profile: { + relation: Model.HasOneRelation, + modelClass: Profile, + join: { from: 'ambiguous_person.id', to: 'ambiguous_profile.personId' }, + }, + movies: { + relation: Model.ManyToManyRelation, + modelClass: Movie, + join: { + from: 'ambiguous_person.id', + through: { + from: 'ambiguous_person_movie.personId', + to: 'ambiguous_person_movie.movieId', + extra: ['role'], + }, + to: 'ambiguous_movie.id', + }, + }, + }; + } + } + + class Item extends Model { + static get tableName() { + return 'ambiguous_item'; + } + } + + class Owner extends Model { + static get tableName() { + return 'ambiguous_owner'; + } + + static get idColumn() { + return ['a', 'b']; + } + + static get relationMappings() { + return { + items: { + relation: Model.HasManyRelation, + modelClass: Item, + join: { + from: ['ambiguous_owner.a', 'ambiguous_owner.b'], + to: ['ambiguous_item.oa', 'ambiguous_item.ob'], + }, + }, + }; + } + } + + const tables = { + owner: 'ambiguous_owner', + item: 'ambiguous_item', + person: 'ambiguous_person', + pet: 'ambiguous_pet', + toy: 'ambiguous_toy', + profile: 'ambiguous_profile', + movie: 'ambiguous_movie', + person_movie: 'ambiguous_person_movie', + }; + + beforeAll(async () => { + for (const name of Object.values(tables)) { + await knex.schema.dropTableIfExists(name); + } + + await knex.schema + .createTable(tables.person, (table) => { + table.integer('id').primary(); + table.string('label'); + table.integer('parentId'); + table.integer('bossId'); + table.string('code'); + }) + .createTable(tables.pet, (table) => { + table.integer('id').primary(); + table.string('label'); + table.integer('ownerId'); + table.string('ownerCode'); + }) + .createTable(tables.owner, (table) => { + table.integer('a'); + table.integer('b'); + table.primary(['a', 'b']); + }) + .createTable(tables.item, (table) => { + table.integer('id').primary(); + table.integer('oa'); + table.integer('ob'); + }) + .createTable(tables.toy, (table) => { + table.integer('id').primary(); + table.string('label'); + table.integer('petId'); + }) + .createTable(tables.profile, (table) => { + table.integer('id').primary(); + table.string('label'); + table.integer('personId').unique(); + }) + .createTable(tables.movie, (table) => { + table.integer('id').primary(); + table.string('label'); + }) + .createTable(tables.person_movie, (table) => { + table.integer('personId'); + table.integer('movieId'); + table.string('role'); + }); + + Person.knex(knex); + Owner.knex(knex); + Profile.knex(knex); + }); + + beforeEach(async () => { + for (const name of Object.values(tables)) { + await knex(name).delete(); + } + }); + + afterAll(async () => { + for (const name of Object.values(tables)) { + await knex.schema.dropTableIfExists(name); + } + }); + + async function insertRows(rows) { + for (const [table, tableRows] of Object.entries(rows)) { + await knex(tables[table]).insert(tableRows); + } + } + + async function selectRows() { + const rows = {}; + + for (const [table, name] of Object.entries(tables)) { + rows[table] = await knex(name).select(); + } + + return rows; + } + + // Cases of the differential test against 3.x, where the versions write + // different rows, and neither is defined: + const cases = [ + { + seed: 25, + rows: { + person: [{ id: 2 }, { id: 3 }, { id: 4 }], + pet: [{ id: 3, ownerId: 2 }], + movie: [{ id: 3 }], + person_movie: [{ personId: 3, movieId: 3 }], + }, + graph: [ + { + id: 3, + boss: { + id: 2, + children: [ + { id: 4, parent: null }, + { label: 'person-new2', movies: [{ id: 3 }] }, + ], + }, + movies: [], + }, + ], + options: { allowRefs: true, relate: true, noInsert: true }, + }, + { + seed: 66, + rows: { person: [{ id: 1 }, { id: 2 }] }, + graph: { + id: 2, + children: [{ id: 1, parent: { label: 'person-new2' } }], + movies: [{ id: 1 }], + }, + options: { allowRefs: true, relate: true }, + }, + { + seed: 217, + rows: { person: [{ id: 1 }, { id: 2 }], pet: [{ id: 1, ownerId: 1 }] }, + graph: [ + { + label: 'person-new2', + children: [ + { + label: 'person-new3', + children: [ + { id: 1, pets: [{ '#ref': 'u6' }] }, + { id: 2, parent: null }, + ], + }, + ], + pets: [{ id: 1, '#id': 'u6' }], + }, + ], + options: { allowRefs: true, relate: true }, + }, + { + seed: 219, + rows: { person: [{ id: 2 }], pet: [{ id: 1, ownerId: 1 }] }, + graph: [ + { + id: 2, + parent: { + label: 'person-new12', + pets: [{ id: 1, owner: { '#ref': 'u8' } }, { label: 'pet-new15' }], + }, + }, + { label: 'person-new19', children: [{ label: 'person-new20', '#id': 'u8' }] }, + ], + options: { allowRefs: true, relate: true }, + }, + { + seed: 243, + rows: { + person: [{ id: 1 }, { id: 2, parentId: 1 }], + person_movie: [{ personId: 1, movieId: 3 }], + }, + graph: [ + { + label: 'person-new1', + children: [ + { label: 'person-new2', children: [{ id: 2, parent: { label: 'person-new3' } }] }, + ], + }, + ], + options: { allowRefs: true, relate: true }, + }, + { + seed: 550, + rows: { + person: [{ id: 1 }, { id: 2 }], + pet: [ + { id: 3, ownerId: 2 }, + { id: 4, ownerId: 2 }, + ], + profile: [{ id: 2, personId: 2 }], + person_movie: [{ personId: 1, movieId: 1 }], + }, + graph: [{ id: 1, pets: [{ id: 4, owner: { label: 'person-new2' } }] }], + options: { allowRefs: true, relate: true }, + }, + { + seed: 578, + rows: { + person: [{ id: 1 }, { id: 3 }], + pet: [{ id: 1, ownerId: 1 }], + toy: [{ id: 1, petId: 1 }], + }, + graph: [{ id: 1, pets: [{ id: 1, owner: { id: 3, pets: [] } }] }], + options: { allowRefs: true, relate: true }, + }, + { + seed: 799, + rows: { person: [{ id: 5 }], pet: [{ id: 2, ownerId: 2 }], toy: [{ id: 2, petId: 2 }] }, + graph: { label: 'person-new1', pets: [{ id: 2, owner: { id: 5, pets: [] } }] }, + options: { allowRefs: true, relate: true }, + }, + { + seed: 803, + rows: { + person: [{ id: 1 }, { id: 2 }, { id: 3, parentId: 2 }], + profile: [{ id: 1, personId: 1 }], + person_movie: [{ personId: 2, movieId: 3 }], + }, + graph: [ + { + id: 1, + children: [{ id: 3, parent: { label: 'person-new5' } }], + pets: [{ label: 'pet-new8' }], + }, + ], + options: { allowRefs: true, relate: true }, + }, + { + seed: 1105, + rows: { person: [{ id: 2 }], profile: [{ id: 1, personId: 1 }] }, + graph: [ + { + label: 'person-new1', + children: [{ id: 2, parent: { label: 'person-new2' } }], + profile: { id: 1 }, + }, + ], + options: { allowRefs: true, relate: true }, + }, + { + seed: 1192, + rows: { person: [{ id: 1 }, { id: 2 }], pet: [{ id: 2, ownerId: 1 }] }, + graph: { + id: 1, + pets: [{ id: 2, toys: [{ '#ref': 'u1' }, { label: 'toy-new1', '#id': 'u1' }] }], + boss: { id: 2, pets: [{ label: 'pet-new2', toys: [{ '#ref': 'u1' }] }] }, + }, + options: { allowRefs: true, relate: true }, + }, + { + seed: 1342, + rows: { person: [{ id: 2 }], pet: [{ id: 1, ownerId: 2 }] }, + graph: { label: 'person-new1', pets: [{ id: 1, owner: { id: 2, pets: [] } }] }, + options: { allowRefs: true, relate: true, unrelate: true }, + }, + { + seed: 1361, + rows: { person: [{ id: 1 }, { id: 2 }, { id: 3 }], profile: [{ id: 1, personId: 1 }] }, + graph: [{ id: 2, children: [{ id: 1, children: [{ id: 3, parent: null }] }] }], + options: { allowRefs: true, relate: true }, + }, + { + seed: 180, + rows: { person: [{ id: 4 }], pet: [{ id: 4, ownerId: 4 }] }, + graph: [{ label: 'person-new1', pets: [{ id: 4, owner: { id: 4, '#unrelate': true } }] }], + options: { allowRefs: true, allowMarkers: true, relate: true }, + }, + { + seed: 5051, + rows: { person: [{ id: 1 }, { id: 2, parentId: 1 }], pet: [{ id: 1, ownerId: 1 }] }, + graph: [ + { + label: 'person-new1', + children: [ + { + label: 'person-new2', + children: [{ id: 2, parent: { label: 'person-new3' } }], + }, + ], + }, + ], + options: { allowRefs: true, relate: true }, + }, + { + seed: 5056, + rows: { + person: [{ id: 3 }, { id: 5, parentId: 3 }], + pet: [{ id: 3, ownerId: 3 }], + profile: [{ id: 2, personId: 3 }], + }, + graph: { label: 'person-new1', children: [{ id: 5, parent: { label: 'person-new7' } }] }, + options: { allowRefs: true, relate: true }, + }, + { + seed: 5057, + rows: { pet: [{ id: 3, ownerId: 4 }] }, + graph: { + label: 'person-new1', + pets: [{ id: 3, owner: { label: 'person-new13' } }, { label: 'pet-new19' }], + }, + options: { allowRefs: true, relate: true }, + }, + { + seed: 5062, + rows: { person: [{ id: 2 }], pet: [{ id: 1, ownerId: 2 }] }, + graph: [ + { + label: 'person-new6', + pets: [{ id: 1, owner: { '#dbRef': 1 } }, { label: 'pet-new8' }], + }, + ], + options: { allowRefs: true, relate: true }, + }, + { + seed: 5195, + rows: { person: [{ id: 2 }, { id: 4 }], pet: [{ id: 1, ownerId: 2 }] }, + graph: [{ id: 2, children: [{ id: 4, parent: null }] }], + options: { allowRefs: true, relate: true }, + }, + { + seed: 'b3-third-owner', + rows: { person: [{ id: 1 }, { id: 2 }], pet: [{ id: 4, ownerId: 2 }] }, + graph: { id: 1, pets: [{ id: 4, owner: { label: 'new-owner' } }] }, + options: { allowRefs: true, relate: true }, + }, + ]; + + for (const { seed, rows, graph, options } of cases) { + it(`should reject the graph before any write (${seed})`, async () => { + await insertRows(rows); + const before = await selectRows(); + + const err = await Person.query() + .upsertGraph(graph, options) + .catch((err) => err); + + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('InvalidGraph'); + expect(await selectRows()).toEqual(before); + }); + } + + // The cases of the differential test, in a small graph: + for (const [title, graph] of [ + [ + 'under two owners', + [ + { id: 1, pets: [{ id: 1 }] }, + { id: 3, pets: [{ id: 1 }] }, + ], + ], + [ + 'under an owner and its inverse to another model', + { id: 1, pets: [{ id: 1, owner: { id: 3 } }] }, + ], + ['with an inverse to a new model', { id: 1, pets: [{ id: 1, owner: { label: 'new' } }] }], + [ + 'with conflicting values', + [ + { id: 2, pets: [{ id: 1, label: 'x' }] }, + { id: 2, pets: [{ id: 1, label: 'y' }] }, + ], + ], + ]) { + it(`should reject a row ${title}`, async () => { + await insertRows({ + person: [{ id: 1 }, { id: 2 }, { id: 3 }], + pet: [{ id: 1, ownerId: 2 }], + }); + + const err = await Person.query() + .upsertGraph(graph, { relate: true }) + .catch((err) => err); + + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('InvalidGraph'); + expect(await knex(tables.pet).select('id', 'label', 'ownerId')).toEqual([ + { id: 1, label: null, ownerId: 2 }, + ]); + }); + } + + it('should reject rows that one place removes and another one keeps', async () => { + await insertRows({ person: [{ id: 1 }, { id: 2 }], pet: [{ id: 3, ownerId: 1 }] }); + + for (const [graph, message] of [ + [ + [ + { id: 1, pets: [{ id: 3, '#delete': true }] }, + { id: 1, pets: [{ id: 3, label: 'x' }] }, + ], + "the graph gives #delete of one Pet two values, at '[0].pets[0]' and at '[1].pets[0]'", + ], + [ + [ + { id: 1, pets: [{ id: 3, '#delete': true }] }, + { id: 2, pets: [{ id: 3 }] }, + ], + "the graph gives #delete of one Pet two values, at '[0].pets[0]' and at '[1].pets[0]'", + ], + [ + [ + { id: 1, pets: [{ id: 3, '#unrelate': true }] }, + { id: 2, pets: [{ id: 3 }] }, + ], + "the graph gives ownerId of one Pet two values, at '[0].pets[0]' and at '[1].pets[0]'", + ], + ]) { + await expect( + Person.query().upsertGraph(graph, { relate: true, allowMarkers: true }), + ).rejects.toThrow(message); + expect(await knex(tables.pet).select('id', 'ownerId')).toEqual([{ id: 3, ownerId: 1 }]); + } + }); + + it('should reject a many-to-many pair that one place unrelates and another one keeps (#405)', async () => { + await insertRows({ + person: [{ id: 1 }], + movie: [{ id: 3 }], + person_movie: [{ personId: 1, movieId: 3, role: 'x' }], + }); + + const unrelated = { id: 3, '#id': 'm', '#unrelate': true, role: 'a' }; + for (const graph of [ + { id: 1, movies: [unrelated, { '#ref': 'm', role: 'b' }] }, + { id: 1, movies: [{ '#ref': 'm', role: 'b' }, unrelated] }, + [ + { id: 1, movies: [{ id: 3, '#unrelate': true }] }, + { id: 1, movies: [{ id: 3 }] }, + ], + [ + { id: 1, movies: [{ id: 3 }] }, + { id: 1, movies: [{ id: 3, '#unrelate': true }] }, + ], + // The inverse relation places the same pair: + { id: 1, movies: [{ id: 3, '#unrelate': true, actors: [{ id: 1 }] }] }, + ]) { + await expect( + Person.query().upsertGraph(graph, { relate: true, allowRefs: true, allowMarkers: true }), + ).rejects.toThrow( + /the graph gives #unrelate of one join row of (movies|actors) two values/, + ); + } + + expect(await knex(tables.person_movie).select('personId', 'movieId', 'role')).toEqual([ + { personId: 1, movieId: 3, role: 'x' }, + ]); + }); + + it('should reject a row that insertGraph() places in conflicting ways', async () => { + await expect( + Person.query().insertGraph({ + id: 1, + label: 'a', + pets: [{ id: 1, owner: { id: 2, label: 'b' } }], + }), + ).rejects.toThrow( + "the graph gives ownerId of one Pet two values, at 'pets[0]' and at 'pets[0].owner'", + ); + expect(await knex(tables.person)).toEqual([]); + }); + + it('should reject a foreign key prop that another place gives a relation key', async () => { + await insertRows({ person: [{ id: 1 }, { id: 2 }, { id: 5 }] }); + + for (const graph of [ + [ + { id: 1, children: [{ id: 2 }] }, + { id: 2, parentId: 5 }, + ], + [ + { id: 2, parentId: 5 }, + { id: 1, children: [{ id: 2 }] }, + ], + [ + { id: 2, parent: { id: 1 } }, + { id: 2, parentId: 5 }, + ], + ]) { + await expect(Person.query().upsertGraph(graph, { relate: true })).rejects.toThrow( + /the graph gives parentId of one Person two values/, + ); + } + + expect(await knex(tables.person).where('id', 2).first()).toMatchObject({ parentId: null }); + }); + + it('should let a relation set the foreign key that the same model gives', async () => { + await insertRows({ person: [{ id: 1 }, { id: 2 }, { id: 5 }] }); + + await Person.query().upsertGraph({ id: 2, parentId: 5, parent: { id: 1 } }, { relate: true }); + + expect(await knex(tables.person).where('id', 2).first()).toMatchObject({ parentId: 1 }); + }); + + it('should reject two rows in the slot of a HasOneRelation', async () => { + await insertRows({ person: [{ id: 1 }], profile: [{ id: 5 }, { id: 6 }] }); + + for (const graph of [ + [ + { id: 1, profile: { id: 5 } }, + { id: 1, profile: { id: 6 } }, + ], + [ + { id: 1, profile: { id: 6 } }, + { id: 1, profile: { id: 5 } }, + ], + [ + { id: 1, profile: null }, + { id: 1, profile: { id: 5 } }, + ], + ]) { + await expect(Person.query().upsertGraph(graph, { relate: true })).rejects.toThrow( + /the graph puts two Profile rows in profile of one Person/, + ); + } + + // Rows that the graph gives the key of the slot without the relation: + for (const graph of [ + [ + { id: 5, personId: 1 }, + { id: 6, person: { id: 1, profile: { id: 6 } } }, + ], + { id: 5, person: { id: 1, profile: { id: 6 } } }, + { id: 5, person: { id: 1, profile: null } }, + ]) { + await expect(Profile.query().upsertGraph(graph, { relate: true })).rejects.toThrow( + /the graph puts two Profile rows in profile of one Person/, + ); + } + + expect(await knex(tables.profile).select('id', 'personId')).toEqual([ + { id: 5, personId: null }, + { id: 6, personId: null }, + ]); + }); + + it('should accept a key that the graph gives as a string and a number', async () => { + await insertRows({ person: [{ id: 1 }, { id: 2 }] }); + + await Person.query().upsertGraph( + [ + { id: 1, children: [{ id: 2 }] }, + { id: 2, parentId: '1' }, + ], + { relate: true }, + ); + + expect(await knex(tables.person).where('id', 2).first()).toMatchObject({ parentId: 1 }); + }); + + it('should accept rows of the same key outside the slot of a relation that filters its rows', async () => { + await insertRows({ + person: [{ id: 1 }], + pet: [ + { id: 1, label: 'cat', ownerId: 1 }, + { id: 2, label: 'dog', ownerId: 1 }, + ], + }); + + await Person.query().upsertGraph({ id: 1, favoriteCat: { id: 1 }, dogs: [{ id: 2 }] }); + + expect(await knex(tables.pet).select('id', 'ownerId')).toEqual([ + { id: 1, ownerId: 1 }, + { id: 2, ownerId: 1 }, + ]); + }); + + it('should reject one row with two values for a prop', async () => { + await insertRows({ person: [{ id: 3 }], pet: [{ id: 2, ownerId: 3 }] }); + + await expect( + Person.query().upsertGraph([ + { id: 3, pets: [{ id: 2, label: 'a' }] }, + { id: 3, pets: [{ id: 2, label: 'b' }] }, + ]), + ).rejects.toThrow( + "the graph gives label of one Pet two values, at '[0].pets[0].label' and at '[1].pets[0].label'", + ); + }); + + // A row that the graph keeps in one place isn't removed from another one, + // so these graphs have a defined outcome: + it('should keep a row that one place of its owner lists and another leaves out', async () => { + await insertRows({ person: [{ id: 2 }, { id: 3 }], pet: [{ id: 2, ownerId: 3 }] }); + + for (const graph of [ + [ + { id: 3, pets: [{ id: 2 }] }, + { id: 3, pets: [] }, + ], + [ + { id: 3, pets: [] }, + { id: 3, pets: [{ id: 2 }] }, + ], + ]) { + await Person.query().upsertGraph(graph); + expect(await knex(tables.pet).select('id', 'ownerId')).toEqual([{ id: 2, ownerId: 3 }]); + } + }); + + it('should keep a row that a model places under a parent whose children leave it out', async () => { + await insertRows({ person: [{ id: 1 }, { id: 3 }] }); + + await Person.query().upsertGraph( + { id: 3, parent: { id: 1, children: [] } }, + { relate: true }, + ); + + expect(await knex(tables.person).select('id', 'parentId').orderBy('id')).toEqual([ + { id: 1, parentId: null }, + { id: 3, parentId: 1 }, + ]); + }); + + it('should reject a row under two owners through the same key', async () => { + await insertRows({ person: [{ id: 1 }, { id: 2 }], pet: [{ id: 3, ownerId: 1 }] }); + + await expect( + Person.query().upsertGraph( + [ + { id: 1, pets: [{ id: 3 }] }, + { id: 2, pets: [{ id: 3 }] }, + ], + { relate: true }, + ), + ).rejects.toThrow( + "the graph gives ownerId of one Pet two values, at '[0].pets[0]' and at '[1].pets[0]'", + ); + }); + + it('should accept owners with composite keys that share a column', async () => { + await insertRows({ + owner: [ + { a: 1, b: 1 }, + { a: 1, b: 2 }, + ], + item: [ + { id: 1, oa: 1, ob: 1 }, + { id: 2, oa: 1, ob: 2 }, + ], + }); + + await Owner.query().upsertGraph([ + { a: 1, b: 1, items: [{ id: 1 }] }, + { a: 1, b: 2, items: [{ id: 2 }] }, + ]); + + expect(await knex(tables.item).orderBy('id')).toEqual([ + { id: 1, oa: 1, ob: 1 }, + { id: 2, oa: 1, ob: 2 }, + ]); + }); + + it('should accept a row placed the same way twice through a key that is not the id', async () => { + await insertRows({ person: [{ id: 1, code: 'x' }], pet: [{ id: 1, ownerCode: 'x' }] }); + + await Person.query().upsertGraph( + { id: 1, code: 'x', petsByCode: [{ id: 1, ownerByCode: { id: 1 } }] }, + { relate: true }, + ); + + expect(await knex(tables.pet).select('id', 'ownerCode')).toEqual([{ id: 1, ownerCode: 'x' }]); + }); + + it('should accept rows of relations that filter the rows of the same key', async () => { + await insertRows({ + person: [{ id: 1 }], + pet: [ + { id: 1, label: 'cat', ownerId: 1 }, + { id: 2, label: 'dog', ownerId: 1 }, + ], + }); + + await Person.query().upsertGraph({ id: 1, cats: [{ id: 1 }], dogs: [{ id: 2 }] }); + await Person.query().upsertGraph({ id: 1, cats: [{ id: 1 }], dogs: [] }); + + expect(await knex(tables.pet).select('id')).toEqual([{ id: 1 }]); + }); + + it('should accept a row placed the same way twice', async () => { + await insertRows({ person: [{ id: 1 }, { id: 2 }], pet: [{ id: 3, ownerId: 2 }] }); + + await Person.query().upsertGraph( + { id: 1, pets: [{ id: 3, owner: { id: 1, pets: [{ id: 3, label: 'x' }] } }] }, + { relate: true }, + ); + + expect(await knex(tables.pet).select('id', 'label', 'ownerId')).toEqual([ + { id: 3, label: 'x', ownerId: 1 }, + ]); + }); + + it('should accept a row in places that write different columns', async () => { + await insertRows({ person: [{ id: 1 }, { id: 2 }] }); + + await Person.query().upsertGraph( + { id: 1, children: [{ id: 2 }], boss: { id: 2 } }, + { relate: true }, + ); + + expect( + await knex(tables.person).select('id', 'label', 'parentId', 'bossId').orderBy('id'), + ).toEqual([ + { id: 1, label: null, parentId: null, bossId: 2 }, + { id: 2, label: null, parentId: 1, bossId: null }, + ]); + }); + }); +}; diff --git a/tests/integration/misc/upsertGraphBelongsToOneDelete.js b/tests/integration/misc/upsertGraphBelongsToOneDelete.js index e7566f1bf..45afd3f6d 100644 --- a/tests/integration/misc/upsertGraphBelongsToOneDelete.js +++ b/tests/integration/misc/upsertGraphBelongsToOneDelete.js @@ -1,7 +1,7 @@ -const { expect } = require('chai'); -const { Model, ForeignKeyViolationError } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { Model, DBError, ValidationError } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('upsertGraph() deleting the row of a BelongsToOneRelation', () => { const { knex } = session; @@ -27,23 +27,6 @@ module.exports = (session) => { } } - // Owners whose schema allows `null` for the reference get it set to `null` - // before the referenced row is deleted. - function createNullableOwner(petIdSchema) { - return class NullableOwner extends Owner { - static get jsonSchema() { - return { - type: 'object', - properties: { - petId: petIdSchema, - }, - }; - } - }; - } - - const NullableOwner = createNullableOwner({ type: ['integer', 'null'] }); - function createTables(createPetId) { return knex.schema .dropTableIfExists('bt1_delete_owner') @@ -63,59 +46,69 @@ module.exports = (session) => { it('should point the owner to an inserted row and delete the old one', async () => { await Owner.query().upsertGraph({ id: 1, pet: { id: 2 } }, { insertMissing: true }); - expect(await knex('bt1_delete_owner')).to.eql([{ id: 1, petId: 2 }]); - expect(await knex('bt1_delete_pet')).to.eql([{ id: 2 }]); + expect(await knex('bt1_delete_owner')).toEqual([{ id: 1, petId: 2 }]); + expect(await knex('bt1_delete_pet')).toEqual([{ id: 2 }]); }); it('should point the owner to a related row and delete the old one', async () => { await knex('bt1_delete_pet').insert({ id: 2 }); await Owner.query().upsertGraph({ id: 1, pet: { id: 2 } }, { relate: true }); - expect(await knex('bt1_delete_owner')).to.eql([{ id: 1, petId: 2 }]); - expect(await knex('bt1_delete_pet')).to.eql([{ id: 2 }]); + expect(await knex('bt1_delete_owner')).toEqual([{ id: 1, petId: 2 }]); + expect(await knex('bt1_delete_pet')).toEqual([{ id: 2 }]); }); it('should keep the row when noInsert skips the replacement', async () => { await Owner.query().upsertGraph({ id: 1, pet: {} }, { noInsert: ['pet'] }); - expect(await knex('bt1_delete_owner')).to.eql([{ id: 1, petId: 1 }]); - expect(await knex('bt1_delete_pet')).to.eql([{ id: 1 }]); + expect(await knex('bt1_delete_owner')).toEqual([{ id: 1, petId: 1 }]); + expect(await knex('bt1_delete_pet')).toEqual([{ id: 1 }]); }); } + // Removing the row sets the owner's reference to null before deleting the + // row, like `unrelate()`, whatever the owner's schema says. function itShouldRemoveTheReferenceBeforeTheRow() { - it('should set a nullable reference to null before deleting the row', async () => { - await NullableOwner.query().upsertGraph({ id: 1, pet: null }); + it('should set the reference to null before deleting the row', async () => { + await Owner.query().upsertGraph({ id: 1, pet: null }); - expect(await knex('bt1_delete_owner')).to.eql([{ id: 1, petId: null }]); - expect(await knex('bt1_delete_pet')).to.eql([]); + expect(await knex('bt1_delete_owner')).toEqual([{ id: 1, petId: null }]); + expect(await knex('bt1_delete_pet')).toEqual([]); }); - it('should set a nullable reference to null before deleting a #delete row', async () => { - await NullableOwner.query().upsertGraph( + it('should set the reference to null before deleting a #delete row', async () => { + await Owner.query().upsertGraph( { id: 1, pet: { id: 1, '#delete': true } }, { allowMarkers: true }, ); - expect(await knex('bt1_delete_owner')).to.eql([{ id: 1, petId: null }]); - expect(await knex('bt1_delete_pet')).to.eql([]); + expect(await knex('bt1_delete_owner')).toEqual([{ id: 1, petId: null }]); + expect(await knex('bt1_delete_pet')).toEqual([]); }); - for (const petIdSchema of [ - { anyOf: [{ type: 'integer' }, { type: 'null' }] }, - { oneOf: [{ type: 'integer' }, { type: 'null' }] }, - { type: 'integer', nullable: true }, - ]) { - it(`should set a reference to null with schema ${JSON.stringify(petIdSchema)}`, async () => { - await createNullableOwner(petIdSchema).query().upsertGraph({ id: 1, pet: null }); + it("should validate the owner's reference and keep the row", async () => { + class ValidatedOwner extends Owner { + static get jsonSchema() { + return { + type: 'object', + properties: { + petId: { type: 'integer' }, + }, + }; + } + } - expect(await knex('bt1_delete_owner')).to.eql([{ id: 1, petId: null }]); - expect(await knex('bt1_delete_pet')).to.eql([]); - }); - } + const error = await ValidatedOwner.query() + .upsertGraph({ id: 1, pet: null }) + .catch((err) => err); + + expect(error).toBeInstanceOf(ValidationError); + expect(await knex('bt1_delete_owner')).toEqual([{ id: 1, petId: 1 }]); + expect(await knex('bt1_delete_pet')).toEqual([{ id: 1 }]); + }); } - before(() => { + beforeAll(() => { Owner.knex(knex); }); @@ -129,92 +122,60 @@ module.exports = (session) => { await knex('bt1_delete_pet').delete(); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('bt1_delete_owner').dropTableIfExists('bt1_delete_pet'); }); describe('with ON DELETE SET NULL', () => { - before(() => { + beforeAll(() => { return createTables((table) => { table.integer('petId').references('bt1_delete_pet.id').onDelete('SET NULL'); }); }); - it("should leave the owner's reference to the database, without validating it", async () => { - // The owner isn't patched, so its schema doesn't have to allow `null`. - class ValidatedOwner extends Owner { - static get jsonSchema() { - return { - type: 'object', - properties: { - petId: { type: 'integer' }, - }, - }; - } - } - - await ValidatedOwner.query().upsertGraph({ id: 1, pet: null }); - - expect(await knex('bt1_delete_owner')).to.eql([{ id: 1, petId: null }]); - expect(await knex('bt1_delete_pet')).to.eql([]); - }); - itShouldReplaceTheRow(); itShouldRemoveTheReferenceBeforeTheRow(); }); describe('with ON DELETE CASCADE', () => { - before(() => { + beforeAll(() => { return createTables((table) => { table.integer('petId').references('bt1_delete_pet.id').onDelete('CASCADE'); }); }); - it('should leave the owner to the database without a nullable schema', async () => { - await Owner.query().upsertGraph({ id: 1, pet: null }); - - expect(await knex('bt1_delete_owner')).to.eql([]); - expect(await knex('bt1_delete_pet')).to.eql([]); - }); - itShouldReplaceTheRow(); itShouldRemoveTheReferenceBeforeTheRow(); }); describe('with a NOT NULL reference and ON DELETE CASCADE', () => { - before(() => { + beforeAll(() => { return createTables((table) => { table.integer('petId').notNullable().references('bt1_delete_pet.id').onDelete('CASCADE'); }); }); - it('should leave the owner to the database', async () => { - await Owner.query().upsertGraph({ id: 1, pet: null }); + it('should fail to set the reference to null and keep the rows', async () => { + const error = await Owner.query() + .upsertGraph({ id: 1, pet: null }) + .catch((err) => err); - expect(await knex('bt1_delete_owner')).to.eql([]); - expect(await knex('bt1_delete_pet')).to.eql([]); + // MySQL without strict mode writes 0 instead, which violates the foreign key. + expect(error).toBeInstanceOf(DBError); + expect(await knex('bt1_delete_owner')).toEqual([{ id: 1, petId: 1 }]); + expect(await knex('bt1_delete_pet')).toEqual([{ id: 1 }]); }); itShouldReplaceTheRow(); }); describe('with a reference without ON DELETE action', () => { - before(() => { + beforeAll(() => { return createTables((table) => { table.integer('petId').references('bt1_delete_pet.id'); }); }); - it('should fail to delete the referenced row without a nullable schema', async () => { - const error = await Owner.query() - .upsertGraph({ id: 1, pet: null }) - .catch((err) => err); - - expect(error).to.be.an.instanceof(ForeignKeyViolationError); - expect(await knex('bt1_delete_owner')).to.eql([{ id: 1, petId: 1 }]); - expect(await knex('bt1_delete_pet')).to.eql([{ id: 1 }]); - }); - itShouldReplaceTheRow(); itShouldRemoveTheReferenceBeforeTheRow(); }); diff --git a/tests/integration/misc/upsertGraphDeletedRows.js b/tests/integration/misc/upsertGraphDeletedRows.js new file mode 100644 index 000000000..2fdec0ac9 --- /dev/null +++ b/tests/integration/misc/upsertGraphDeletedRows.js @@ -0,0 +1,259 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model, NotFoundError } from 'objection'; + +export default (session) => { + describe('upsertGraph() writing rows that an earlier write deleted', () => { + const { knex } = session; + + class Node extends Model { + static get tableName() { + return 'deleted_rows_node'; + } + + static get relationMappings() { + return { + children: { + relation: Model.HasManyRelation, + modelClass: Node, + join: { from: 'deleted_rows_node.id', to: 'deleted_rows_node.parentId' }, + }, + }; + } + } + + class Pet extends Model { + static get tableName() { + return 'deleted_rows_pet'; + } + + // A property that isn't written to the database: + $formatDatabaseJson(json) { + const { nickname, ...rest } = super.$formatDatabaseJson(json); + return rest; + } + } + + let onBeforeUpdateMovie = null; + + class Movie extends Model { + static get tableName() { + return 'deleted_rows_movie'; + } + + static async beforeUpdate() { + await onBeforeUpdateMovie?.(); + } + + // A property that isn't written to the database: + $formatDatabaseJson(json) { + const { nickname, ...rest } = super.$formatDatabaseJson(json); + return rest; + } + } + + class Person extends Model { + static get tableName() { + return 'deleted_rows_person'; + } + + static get relationMappings() { + return { + pets: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: 'deleted_rows_person.id', to: 'deleted_rows_pet.ownerId' }, + }, + movies: { + relation: Model.ManyToManyRelation, + modelClass: Movie, + join: { + from: 'deleted_rows_person.id', + through: { + from: 'deleted_rows_person_movie.personId', + to: 'deleted_rows_person_movie.movieId', + extra: ['role'], + }, + to: 'deleted_rows_movie.id', + }, + }, + }; + } + } + + class Org extends Model { + static get tableName() { + return 'deleted_rows_org'; + } + + static get relationMappings() { + return { + people: { + relation: Model.HasManyRelation, + modelClass: Person, + join: { from: 'deleted_rows_org.id', to: 'deleted_rows_person.orgId' }, + }, + }; + } + } + + function dropTables() { + return knex.schema + .dropTableIfExists('deleted_rows_node') + .dropTableIfExists('deleted_rows_person_movie') + .dropTableIfExists('deleted_rows_movie') + .dropTableIfExists('deleted_rows_pet') + .dropTableIfExists('deleted_rows_person') + .dropTableIfExists('deleted_rows_org'); + } + + beforeAll(async () => { + await dropTables(); + await knex.schema + .createTable('deleted_rows_org', (table) => { + table.integer('id').primary(); + }) + .createTable('deleted_rows_person', (table) => { + table.integer('id').primary(); + table.integer('orgId').references('deleted_rows_org.id').onDelete('CASCADE'); + }) + .createTable('deleted_rows_pet', (table) => { + table.integer('id').primary(); + table.integer('ownerId').references('deleted_rows_person.id').onDelete('CASCADE'); + }) + .createTable('deleted_rows_movie', (table) => { + table.integer('id').primary(); + }) + .createTable('deleted_rows_person_movie', (table) => { + table.integer('personId'); + table.integer('movieId'); + table.string('role'); + }); + + // SQL Server rejects cascading actions on self-references. + if (!session.isMsSql()) { + await knex.schema.createTable('deleted_rows_node', (table) => { + table.integer('id').primary(); + table.integer('parentId').references('deleted_rows_node.id').onDelete('CASCADE'); + }); + } + + for (const modelClass of [Node, Pet, Movie, Person, Org]) { + modelClass.knex(knex); + } + }); + + beforeEach(async () => { + onBeforeUpdateMovie = null; + await knex('deleted_rows_person_movie').delete(); + await knex('deleted_rows_movie').delete(); + await knex('deleted_rows_pet').delete(); + await knex('deleted_rows_person').delete(); + await knex('deleted_rows_org').delete(); + await knex('deleted_rows_org').insert({ id: 1 }); + await knex('deleted_rows_person').insert([ + { id: 1, orgId: 1 }, + { id: 2, orgId: 1 }, + ]); + await knex('deleted_rows_pet').insert({ id: 2, ownerId: 2 }); + }); + + afterAll(dropTables); + + // Relates below new rows run after the deletes. + function upsertPetBelowNewPerson(trx) { + return Org.query(trx).upsertGraph( + { id: 1, people: [{ id: 1 }, { id: 3, pets: [{ id: 2 }] }] }, + { relate: true, insertMissing: true }, + ); + } + + async function expectNothingWritten() { + expect(await knex('deleted_rows_person').orderBy('id')).toEqual([ + { id: 1, orgId: 1 }, + { id: 2, orgId: 1 }, + ]); + expect(await knex('deleted_rows_pet')).toEqual([{ id: 2, ownerId: 2 }]); + } + + it('should throw when relating a row that a delete cascaded to', async () => { + const err = await Org.transaction((trx) => upsertPetBelowNewPerson(trx)).catch((err) => err); + + expect(err).toBeInstanceOf(NotFoundError); + expect(err.message).toBe( + 'model (id=2) was not found when updating it. An earlier write of the graph or a concurrent query may have deleted it', + ); + expect(err.data.dataPath).toEqual(['people', 1, 'pets', 0]); + await expectNothingWritten(); + }); + + it('should throw when updating the extras of a join row that a concurrent query deleted', async () => { + await knex('deleted_rows_movie').insert({ id: 1 }); + await knex('deleted_rows_person_movie').insert({ personId: 1, movieId: 1, role: 'a' }); + onBeforeUpdateMovie = () => knex('deleted_rows_person_movie').delete(); + + await expect( + Person.query().upsertGraph({ id: 1, movies: [{ id: 1, role: 'b' }] }), + ).rejects.toThrow(NotFoundError); + }); + + it('should not throw for a patch that writes no column', async () => { + const person = await Person.query().upsertGraph({ + id: 2, + pets: [{ id: 2, nickname: 'Rex' }], + }); + + expect(person.pets[0].nickname).toBe('Rex'); + await expectNothingWritten(); + }); + + it('should not throw for a patch through a relation that writes no column', async () => { + await knex('deleted_rows_movie').insert({ id: 1 }); + await knex('deleted_rows_person_movie').insert({ personId: 1, movieId: 1, role: 'a' }); + + const person = await Person.query().upsertGraph({ + id: 1, + movies: [{ id: 1, nickname: 'M' }], + }); + + expect(person.movies[0].nickname).toBe('M'); + expect(await knex('deleted_rows_person_movie')).toEqual([ + { personId: 1, movieId: 1, role: 'a' }, + ]); + }); + + it.skipIf(!session.isPostgres())('should throw with returning(*)', async () => { + await expect( + Org.transaction((trx) => upsertPetBelowNewPerson(trx).returning('*')), + ).rejects.toThrow(NotFoundError); + + await expectNothingWritten(); + }); + + it.skipIf(session.isMsSql())( + 'should throw when relating a row that a delete cascaded to in a tree', + async () => { + await knex('deleted_rows_node').delete(); + await knex('deleted_rows_node').insert([ + { id: 1, parentId: null }, + { id: 2, parentId: 1 }, + { id: 3, parentId: 2 }, + ]); + + await expect( + Node.transaction((trx) => + Node.query(trx).upsertGraph( + { id: 1, children: [{ id: 4, children: [{ id: 3 }] }] }, + { relate: true, insertMissing: true }, + ), + ), + ).rejects.toThrow(NotFoundError); + + expect(await knex('deleted_rows_node').orderBy('id')).toEqual([ + { id: 1, parentId: null }, + { id: 2, parentId: 1 }, + { id: 3, parentId: 2 }, + ]); + }, + ); + }); +}; diff --git a/tests/integration/misc/upsertGraphEdgeCases.js b/tests/integration/misc/upsertGraphEdgeCases.js new file mode 100644 index 000000000..fe5ad70c1 --- /dev/null +++ b/tests/integration/misc/upsertGraphEdgeCases.js @@ -0,0 +1,982 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import { Model } from 'objection'; + +export default (session) => { + const collectQueries = async (callback) => { + const queries = []; + const onQuery = (query) => queries.push(query); + session.knex.on('query', onQuery); + + try { + await callback(); + } finally { + session.knex.removeListener('query', onQuery); + } + + return queries; + }; + + describe('upsertGraph() edge cases of loading, pruning and writing', () => { + const { knex } = session; + + class Toy extends Model { + static get tableName() { + return 'graph_edge_toy'; + } + } + + class Pet extends Model { + static get tableName() { + return 'graph_edge_pet'; + } + + static get relationMappings() { + return { + favoriteToy: { + relation: Model.BelongsToOneRelation, + modelClass: Toy, + join: { from: 'graph_edge_pet.favoriteToyId', to: 'graph_edge_toy.id' }, + }, + owner: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: 'graph_edge_pet.ownerId', to: 'graph_edge_person.id' }, + }, + toys: { + relation: Model.HasManyRelation, + modelClass: Toy, + join: { from: 'graph_edge_pet.id', to: 'graph_edge_toy.petId' }, + }, + }; + } + } + + class Person extends Model { + static get tableName() { + return 'graph_edge_person'; + } + + static get jsonAttributes() { + return ['idolIds']; + } + + static get relationMappings() { + return { + children: { + relation: Model.HasManyRelation, + modelClass: Person, + join: { from: 'graph_edge_person.id', to: 'graph_edge_person.parentId' }, + }, + boss: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: 'graph_edge_person.bossId', to: 'graph_edge_person.id' }, + }, + friends: { + relation: Model.ManyToManyRelation, + modelClass: Person, + join: { + from: 'graph_edge_person.id', + through: { from: 'graph_edge_friend.aId', to: 'graph_edge_friend.bId' }, + to: 'graph_edge_person.id', + }, + }, + idols: { + relation: Model.BelongsToManyRelation, + modelClass: Person, + join: { from: 'graph_edge_person.idolIds', to: 'graph_edge_person.id' }, + }, + pets: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: 'graph_edge_person.id', to: 'graph_edge_pet.ownerId' }, + }, + petsByCode: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: 'graph_edge_person.code', to: 'graph_edge_pet.ownerCode' }, + }, + }; + } + } + + const tables = ['graph_edge_friend', 'graph_edge_toy', 'graph_edge_pet', 'graph_edge_person']; + + const dropTables = async () => { + for (const table of tables) { + await knex.schema.dropTableIfExists(table); + } + }; + + beforeAll(async () => { + await dropTables(); + await knex.schema + .createTable('graph_edge_person', (table) => { + table.increments('id'); + table.string('name'); + table.integer('parentId'); + table.integer('bossId'); + table.string('code'); + // The pg drivers only parse JSON columns. + if (session.isPostgres()) { + table.jsonb('idolIds'); + } else { + table.text('idolIds'); + } + }) + .createTable('graph_edge_pet', (table) => { + table.increments('id'); + table.string('name'); + table.string('kind').notNullable().defaultTo('plain'); + table.integer('ownerId'); + table.string('ownerCode'); + table.integer('favoriteToyId'); + table.unique(['ownerId', 'name']); + }) + .createTable('graph_edge_toy', (table) => { + table.increments('id'); + table.string('name'); + table.integer('petId'); + }) + .createTable('graph_edge_friend', (table) => { + table.integer('aId'); + table.integer('bId'); + }); + }); + + // Truncating resets the ids, which the rows get in the order of insertion: + // persons 1 to 3, pets 1 and 2 of person 2 and pet 3 of person 3, toys 1 and + // 2 of pet 1 and toys 3 and 4 of pet 3. + beforeEach(async () => { + for (const table of tables) { + await knex(table).truncate(); + } + + for (const name of ['root', 'friend', 'other']) { + await knex('graph_edge_person').insert({ name, code: name }); + } + + for (const [name, ownerId] of [ + ['p1', 2], + ['p2', 2], + ['p3', 3], + ]) { + await knex('graph_edge_pet').insert({ name, ownerId }); + } + + for (const [name, petId] of [ + ['t1', 1], + ['t2', 1], + ['t3', 3], + ['t4', 3], + ]) { + await knex('graph_edge_toy').insert({ name, petId }); + } + }); + + afterAll(dropTables); + + const toyIds = async () => (await knex('graph_edge_toy').orderBy('id')).map((toy) => toy.id); + + describe('rows below models that are pruned', () => { + it('should not remove the rows below a related model that noRelate skips', async () => { + await Person.query(knex).upsertGraph( + { id: 1, friends: [{ id: 2, pets: [{ id: 1, toys: [] }] }] }, + { relate: true, noRelate: ['friends'] }, + ); + + expect(await toyIds()).toEqual([1, 2, 3, 4]); + }); + + it('should not remove the rows below a new model that noInsert skips', async () => { + await Person.query(knex).upsertGraph( + { id: 1, children: [{ name: 'new', pets: [{ id: 1, toys: [] }] }] }, + { relate: true, noInsert: ['children'] }, + ); + + expect(await toyIds()).toEqual([1, 2, 3, 4]); + }); + + it('should not remove the rows below a model marked with #unrelate', async () => { + await Person.query(knex).upsertGraph( + { id: 1, friends: [{ id: 2, '#unrelate': true, pets: [{ id: 1, toys: [] }] }] }, + { allowMarkers: true, relate: true }, + ); + + expect(await toyIds()).toEqual([1, 2, 3, 4]); + }); + }); + + it('should not load the models below a model that noRelate skips', async () => { + await Person.query(knex).upsertGraph( + { id: 1, friends: [{ id: 2, children: [{ id: 999, children: [] }] }] }, + { relate: true, noRelate: ['friends'] }, + ); + + expect(await knex('graph_edge_friend')).toEqual([]); + }); + + it('should throw before writing for a #dbRef to a missing model with relations', async () => { + const err = await Person.query(knex) + .upsertGraph({ id: 1, friends: [{ '#dbRef': 999, children: [{ name: 'nc' }] }] }) + .catch((err) => err); + + expect(err).toBeInstanceOf(Person.NotFoundError); + expect(await knex('graph_edge_friend')).toEqual([]); + expect(await knex('graph_edge_person').where('name', 'nc')).toEqual([]); + }); + + it('should keep the markers of the input when the upsert fails', async () => { + const input = Person.fromJson({ + id: 1, + friends: [{ id: 2, '#unrelate': true }], + children: [{ id: 999 }], + }); + + await expect( + Person.query(knex).upsertGraph(input, { allowMarkers: true }), + ).rejects.toBeInstanceOf(Person.NotFoundError); + expect(input.friends[0]['#unrelate']).toBe(true); + }); + + describe('#ref models with relations', () => { + it('should insert the models below a #ref to a new model', async () => { + const root = await Person.query(knex).upsertGraph( + { + id: 1, + children: [{ '#id': 'child', name: 'new' }], + friends: [{ '#ref': 'child', pets: [{ name: 'refpet' }] }], + }, + { relate: true, allowRefs: true }, + ); + + const [child] = root.children; + expect(await knex('graph_edge_pet').where('name', 'refpet')).toEqual([ + { + id: expect.any(Number), + name: 'refpet', + kind: 'plain', + ownerId: child.id, + ownerCode: null, + favoriteToyId: null, + }, + ]); + }); + + it('should upsert the models below a #ref to an existing model', async () => { + await Person.query(knex).upsertGraph( + { + id: 1, + children: [{ '#id': 'child', id: 2 }], + friends: [{ '#ref': 'child', pets: [{ id: 2, name: 'renamed' }] }], + }, + { relate: true, allowRefs: true }, + ); + + expect(await knex('graph_edge_pet').where('ownerId', 2).orderBy('id')).toEqual([ + { + id: 2, + name: 'renamed', + kind: 'plain', + ownerId: 2, + ownerCode: null, + favoriteToyId: null, + }, + ]); + }); + + it('should return the props of the model that an already related #ref refers to', async () => { + await knex('graph_edge_friend').insert({ aId: 1, bId: 2 }); + + const root = await Person.query(knex).upsertGraph( + { + id: 1, + children: [{ '#id': 'child', id: 2, name: 'x2' }], + friends: [{ '#ref': 'child' }], + }, + { relate: true, allowRefs: true }, + ); + + expect(root.friends[0]).toMatchObject({ id: 2, name: 'x2' }); + expect(await knex('graph_edge_friend')).toEqual([{ aId: 1, bId: 2 }]); + }); + + it('should give a #ref related into a HasManyRelation the key of a parent inserted later (#311)', async () => { + const [, b] = await Person.query(knex).upsertGraph( + [ + { name: 'A', boss: { '#id': 'n', name: 'N' } }, + { + name: 'B', + boss: { name: 'Q', boss: { name: 'Q2' } }, + children: [{ '#ref': 'n' }], + }, + ], + { allowRefs: true, relate: true }, + ); + + expect((await knex('graph_edge_person').where('name', 'N').first()).parentId).toBe(b.id); + }); + + // `insertMissing` loads the rows of the models with ids, and the chain of + // children inserts `x` after the owners of the `#ref` models: + const insertedLate = { + name: 'k1', + children: [{ name: 'k2', children: [{ name: 'X', '#id': 'x' }] }], + }; + + for (const [title, places, getName] of [ + [ + 'the place of its model', + [{ name: 'o', pets: [{ id: 1, '#id': 'p', name: 'of #ref{x.name}' }, { '#ref': 'p' }] }], + async () => (await knex('graph_edge_pet').where('id', 1).first()).name, + ], + [ + 'another place', + [ + { name: 'o', children: [{ id: 1, '#id': 'p', name: 'of #ref{x.name}' }] }, + { name: 'b', boss: { '#ref': 'p' } }, + ], + async () => (await knex('graph_edge_person').where('id', 1).first()).name, + ], + [ + 'a place that keeps a key in its row', + [ + { name: 'f', friends: [{ id: 1, '#id': 'p', name: 'of #ref{x.name}' }] }, + { name: 'o', children: [{ '#ref': 'p' }] }, + ], + async () => (await knex('graph_edge_person').where('id', 1).first()).name, + ], + ]) { + it(`should write the row of a #ref in ${title} with its resolved values`, async () => { + await Person.query(knex).upsertGraph( + { name: 'r', children: [insertedLate, ...places] }, + { allowRefs: true, relate: true, insertMissing: true }, + ); + + expect(await getName()).toBe('of X'); + }); + } + + for (const [title, graph] of [ + [ + 'as a BelongsToOne', + [ + { id: 1, children: [{ id: 2, '#id': 'b', name: 'B2' }] }, + { id: 3, boss: { '#ref': 'b' } }, + ], + ], + [ + 'in a many-to-many relation', + [ + { id: 1, children: [{ id: 2, '#id': 'b', name: 'B2' }] }, + { id: 3, friends: [{ '#ref': 'b' }] }, + ], + ], + ]) { + it(`should write the row of a #ref ${title} that its path allows, where the path of its model doesn't`, async () => { + await knex('graph_edge_person').where('id', 2).update({ parentId: 1 }); + + await Person.query(knex).upsertGraph(graph, { + allowRefs: true, + relate: true, + noUpdate: ['children'], + }); + + expect((await knex('graph_edge_person').where('id', 2).first()).name).toBe('B2'); + }); + } + + it("should write the row of a #ref in a many-to-many relation it's already in that its path allows", async () => { + await knex('graph_edge_person').where('id', 2).update({ parentId: 1 }); + await knex('graph_edge_friend').insert({ aId: 3, bId: 2 }); + + await Person.query(knex).upsertGraph( + { + id: 1, + children: [ + { id: 2, '#id': 'b', name: 'B2' }, + { id: 3, friends: [{ '#ref': 'b' }] }, + ], + }, + { allowRefs: true, relate: true, noUpdate: ['children'] }, + ); + + expect((await knex('graph_edge_person').where('id', 2).first()).name).toBe('B2'); + }); + + it('should insert a new model once when a #ref repeats its place', async () => { + const updatedNames = []; + + class HookedPet extends Pet { + $beforeUpdate() { + updatedNames.push(this.name); + } + } + + class HookedPerson extends Person { + static get relationMappings() { + const { pets } = super.relationMappings; + return { pets: { ...pets, modelClass: HookedPet } }; + } + } + + await HookedPerson.query(knex).upsertGraph( + { name: 'o', pets: [{ '#id': 'p', name: 'new' }, { '#ref': 'p' }] }, + { allowRefs: true }, + ); + + expect(updatedNames).toEqual([]); + expect(await knex('graph_edge_pet').where('name', 'new')).toHaveLength(1); + }); + + it('should keep a new model that a moved row refers to as its BelongsToOne model', async () => { + await Person.query(knex).upsertGraph( + { name: 'new', '#id': 'a', pets: [{ id: 3, owner: { '#ref': 'a' } }] }, + { allowRefs: true, relate: true }, + ); + + const newPerson = await knex('graph_edge_person').where('name', 'new').first(); + expect(newPerson).toBeDefined(); + expect(await knex('graph_edge_pet').where('id', 3).first()).toMatchObject({ + ownerId: newPerson.id, + }); + }); + + it('should break a cycle through a BelongsToOne key instead of an array of keys', async () => { + const [a, b] = await Person.query(knex).upsertGraph( + [ + { '#id': 'a', name: 'A', idols: [{ '#ref': 'b' }] }, + { '#id': 'b', name: 'B', boss: { '#ref': 'a' } }, + ], + { allowRefs: true }, + ); + + expect((await Person.query(knex).findById(a.id)).idolIds).toEqual([b.id]); + expect((await Person.query(knex).findById(b.id)).bossId).toBe(a.id); + }); + }); + + it('should load and remove the rows below a related model once', async () => { + const queries = await collectQueries(() => + Person.query(knex).upsertGraph( + { id: 1, friends: [{ id: 3, pets: [{ id: 3, toys: [{ id: 3 }] }] }] }, + { relate: true }, + ), + ); + + const deletes = queries.filter(({ sql }) => /^delete/i.test(sql)); + expect(deletes).toHaveLength(1); + expect(deletes[0].bindings.filter((value) => value === 4)).toHaveLength(1); + expect(await toyIds()).toEqual([1, 2, 3]); + }); + + it('should keep a root whose new boss is the model it was the boss of', async () => { + await knex('graph_edge_person').where('id', 1).update({ bossId: 2 }); + + await Person.query(knex).upsertGraph( + { id: 2, boss: { id: 1, boss: { id: 3 } } }, + { relate: true }, + ); + + expect(await knex('graph_edge_person').orderBy('id').select('id', 'bossId')).toEqual([ + { id: 1, bossId: 3 }, + { id: 2, bossId: 1 }, + { id: 3, bossId: null }, + ]); + }); + + it('should read the markers of a model in every place it appears', async () => { + const friend = Person.fromJson({ '#dbRef': 2 }); + + await Person.query(knex).upsertGraph({ id: 1, friends: [friend], children: [friend] }); + + expect((await knex('graph_edge_person')).map((person) => person.id).sort()).toEqual([ + 1, 2, 3, + ]); + expect((await knex('graph_edge_person').where('id', 2).first()).parentId).toBe(1); + }); + + it('should insert the column defaults of rows that leave out columns', async () => { + await Person.query(knex).insertGraph({ + name: 'new', + pets: [{ name: 'a', kind: 'cat' }, { name: 'b' }], + }); + + expect( + await knex('graph_edge_pet').whereIn('name', ['a', 'b']).orderBy('name').select('kind'), + ).toEqual([{ kind: 'cat' }, { kind: 'plain' }]); + }); + + it('should insert the column defaults of rows that a beforeInsert hook leaves out', async () => { + class HookedPet extends Pet { + $beforeInsert() { + if (this.name === 'a') { + this.kind = 'cat'; + } + } + } + + await HookedPet.query(knex).insertGraph([{ name: 'a' }, { name: 'b' }]); + + expect( + await knex('graph_edge_pet').whereIn('name', ['a', 'b']).orderBy('name').select('kind'), + ).toEqual([{ kind: 'cat' }, { kind: 'plain' }]); + }); + + it('should move rows with their other changes, which unique keys may need', async () => { + await knex('graph_edge_pet').where('id', 3).update({ name: 'p1' }); + + await Person.query(knex).upsertGraph( + { id: 3, pets: [{ id: 3 }, { id: 1, name: 'z' }] }, + { relate: true }, + ); + + expect(await knex('graph_edge_pet').where('id', 1).first()).toMatchObject({ + name: 'z', + ownerId: 3, + }); + }); + + it('should move rows that wait for inserts with their other changes', async () => { + await knex('graph_edge_pet').where('id', 3).update({ name: 'p1' }); + + await Person.query(knex).upsertGraph( + { id: 3, pets: [{ id: 3 }, { id: 1, name: 'z', favoriteToy: { name: 'new toy' } }] }, + { relate: true }, + ); + + const toy = await knex('graph_edge_toy').where('name', 'new toy').first(); + expect(await knex('graph_edge_pet').where('id', 1).first()).toMatchObject({ + name: 'z', + ownerId: 3, + favoriteToyId: toy.id, + }); + }); + + describe('hooks of moved rows', () => { + const parsedNames = []; + const updatedIds = []; + + class HookedPet extends Pet { + $parseJson(json, options) { + parsedNames.push(json.name); + return super.$parseJson(json, options); + } + + $beforeUpdate() { + updatedIds.push(this.id); + } + } + + class HookedPerson extends Person { + static get relationMappings() { + const { pets } = super.relationMappings; + return { pets: { ...pets, modelClass: HookedPet } }; + } + } + + beforeEach(() => { + parsedNames.length = 0; + updatedIds.length = 0; + }); + + it('should run them once for rows that wait for inserts', async () => { + await HookedPerson.query(knex).upsertGraph( + { + id: 3, + pets: [{ id: 3 }, { id: 1, name: 'for #ref{n.name}' }, { '#id': 'n', name: 'new' }], + }, + { relate: true, allowRefs: true }, + ); + + expect(updatedIds).toEqual([1]); + }); + + // The relations of moved rows are upserted after the move, so the row is + // written once for the move, and once more for the key of its new toy: + it('should run them for the move and for the relations of the row, with their ids', async () => { + await HookedPerson.query(knex).upsertGraph( + { id: 3, pets: [{ id: 3 }, { id: 1, name: 'z', favoriteToy: { name: 'new toy' } }] }, + { relate: true }, + ); + + expect(updatedIds).toEqual([1, 1]); + // The input is parsed once: + expect(parsedNames).toEqual([undefined, 'z']); + }); + }); + + it('should resolve the references of moved rows to models that are not inserted', async () => { + await Person.query(knex).upsertGraph( + { + id: 3, + pets: [ + { '#id': 'p', id: 3, name: 'p3' }, + { id: 1, name: 'next to #ref{p.name}' }, + ], + }, + { relate: true, allowRefs: true }, + ); + + expect((await knex('graph_edge_pet').where('id', 1).first()).name).toBe('next to p3'); + }); + + it('should run the update hooks of moved rows once', async () => { + const calls = []; + + class HookedPet extends Pet { + $beforeUpdate() { + calls.push(this.ownerId); + } + } + + class HookedPerson extends Person { + static get relationMappings() { + const { pets } = super.relationMappings; + return { pets: { ...pets, modelClass: HookedPet } }; + } + } + + await HookedPerson.query(knex).upsertGraph( + { id: 3, pets: [{ id: 3 }, { id: 1 }] }, + { relate: true, noDelete: true }, + ); + + expect(calls).toEqual([3]); + }); + + it('should write the keys and references of moved rows that wait for inserts', async () => { + await knex('graph_edge_person').where('id', 3).update({ parentId: 1, bossId: 2 }); + + await Person.query(knex).upsertGraph( + { + id: 2, + children: [ + { + id: 3, + name: 'is #ref{x.name}', + boss: { name: 'new boss' }, + idols: [{ name: 'new idol' }], + }, + { '#id': 'x', name: 'X' }, + ], + }, + { relate: true, noDelete: true, allowRefs: true }, + ); + + const ids = Object.fromEntries( + (await knex('graph_edge_person')).map((person) => [person.name, person.id]), + ); + expect(await Person.query(knex).findById(3)).toMatchObject({ + name: 'is X', + parentId: 2, + bossId: ids['new boss'], + idolIds: [ids['new idol']], + }); + }); + + it('should write the keys of moved rows that wait for inserts with noUpdate', async () => { + await knex('graph_edge_person').where('id', 3).update({ parentId: 1, bossId: 2 }); + + await Person.query(knex).upsertGraph( + { id: 2, children: [{ id: 3, boss: { name: 'new boss' } }] }, + { relate: true, noDelete: true, noUpdate: true }, + ); + + const boss = await knex('graph_edge_person').where('name', 'new boss').first(); + expect(await knex('graph_edge_person').where('id', 3).first()).toMatchObject({ + parentId: 2, + bossId: boss.id, + }); + }); + + it('should not write the references of moved rows before they are resolved', async () => { + await Person.query(knex).upsertGraph( + { + id: 3, + pets: [ + { id: 3, toys: [{ '#id': 'toy', name: 'new toy' }] }, + { id: 1, favoriteToyId: '#ref{toy.id}' }, + ], + }, + { relate: true, allowRefs: true }, + ); + + const toy = await knex('graph_edge_toy').where('name', 'new toy').first(); + expect(await knex('graph_edge_pet').where('id', 1).first()).toMatchObject({ + ownerId: 3, + favoriteToyId: toy.id, + }); + }); + + describe('#ref{} to existing models (#356)', () => { + it('should read the values that the graph gives them', async () => { + await Person.query(knex).upsertGraph( + [ + { '#id': 'a', id: 1, boss: { name: 'new boss' } }, + { id: 2, name: 'boss of a: #ref{a.bossId}' }, + ], + { allowRefs: true }, + ); + + const boss = await knex('graph_edge_person').where('name', 'new boss').first(); + expect((await knex('graph_edge_person').where('id', 2).first()).name).toBe( + `boss of a: ${boss.id}`, + ); + }); + + it('should not wait for the values that the graph leaves alone', async () => { + await Person.query(knex).upsertGraph( + [ + { '#id': 'a', id: 1, name: 'root', boss: { '#ref': 'new' } }, + { '#id': 'new', name: 'new boss of #ref{a.name}' }, + ], + { allowRefs: true }, + ); + + const boss = await knex('graph_edge_person').where('name', 'new boss of root').first(); + expect((await knex('graph_edge_person').where('id', 1).first()).bossId).toBe(boss.id); + }); + + it('should keep the keys of BelongsToOne rows that the graph ignores', async () => { + class RequiredBossPerson extends Person { + static get jsonSchema() { + return { type: 'object', required: ['bossId'] }; + } + } + + await knex('graph_edge_person').where('id', 2).update({ bossId: 1 }); + + for (const [boss, options] of [ + [{ id: 3 }, { relate: true, noRelate: true }], + [{ name: 'new' }, { noInsert: true }], + ]) { + const result = await RequiredBossPerson.query(knex).upsertGraph( + { id: 2, name: 'renamed', boss }, + { update: true, ...options }, + ); + + expect(result.bossId).toBe(1); + expect((await knex('graph_edge_person').where('id', 2).first()).bossId).toBe(1); + } + }); + + it('should relate a moved model to itself through a #ref', async () => { + await Person.query(knex).upsertGraph( + [ + { + name: 'new', + children: [{ id: 3, children: [{ id: 2, '#id': 'a', boss: { '#ref': 'a' } }] }], + }, + ], + { allowRefs: true, relate: true }, + ); + + const newPerson = await knex('graph_edge_person').where('name', 'new').first(); + expect(await knex('graph_edge_person').whereIn('id', [2, 3]).orderBy('id')).toMatchObject([ + { id: 2, parentId: 3, bossId: 2 }, + { id: 3, parentId: newPerson.id }, + ]); + }); + + it('should point an existing model to a new model below it through a #ref', async () => { + await Person.query(knex).upsertGraph( + { + id: 3, + boss: { + id: 1, + boss: { id: 2, children: [{ name: 'new', '#id': 'a' }], boss: { '#ref': 'a' } }, + }, + }, + { allowRefs: true, relate: true }, + ); + + const newPerson = await knex('graph_edge_person').where('name', 'new').first(); + expect(newPerson.parentId).toBe(2); + expect( + await knex('graph_edge_person').whereIn('id', [1, 2, 3]).orderBy('id'), + ).toMatchObject([ + { id: 1, bossId: 2 }, + { id: 2, bossId: newPerson.id }, + { id: 3, bossId: 1 }, + ]); + }); + + it('should write nothing for a cycle that it rejects', async () => { + await expect( + Person.query(knex).upsertGraph( + { + id: 2, + pets: [], + children: [ + { '#id': 'a', name: 'a of #ref{b.name}' }, + { '#id': 'b', name: 'b of #ref{a.name}' }, + ], + }, + { allowRefs: true }, + ), + ).rejects.toThrow('the object graph contains cyclic references'); + + expect(await knex('graph_edge_pet').where('ownerId', 2).pluck('name')).toEqual([ + 'p1', + 'p2', + ]); + }); + + it('should read them in moved rows', async () => { + await Person.query(knex).upsertGraph( + [ + { '#id': 'a', id: 2, boss: { name: 'new boss' } }, + { id: 1, children: [{ id: 3, name: 'boss of a: #ref{a.bossId}' }] }, + ], + { allowRefs: true, relate: true, noDelete: true }, + ); + + const boss = await knex('graph_edge_person').where('name', 'new boss').first(); + expect(await knex('graph_edge_person').where('id', 3).first()).toMatchObject({ + parentId: 1, + name: `boss of a: ${boss.id}`, + }); + }); + }); + + it('should give new models the keys of related parents that the graph leaves out', async () => { + await Person.query(knex).upsertGraph( + { id: 1, children: [{ id: 2, petsByCode: [{ name: 'new' }] }] }, + { relate: true, noDelete: true }, + ); + + expect((await knex('graph_edge_pet').where('name', 'new').first()).ownerCode).toBe('friend'); + }); + + it('should give new models the keys of existing parents that the graph leaves out', async () => { + await Pet.query(knex).upsertGraph({ id: 1, owner: { id: 2, petsByCode: [{ name: 'new' }] } }); + + expect((await knex('graph_edge_pet').where('name', 'new').first()).ownerCode).toBe('friend'); + }); + + // Spreading the models into the arguments of a call overflows the stack. + it.skipIf(!session.isSqlite())( + 'should insert more models than a call takes arguments', + async () => { + const count = 200000; + // One transaction instead of a commit per insert, which is slow on + // the SQLite file of the tests: + await Toy.transaction(knex, (trx) => + Toy.query(trx).insertGraph(Array.from({ length: count }, () => ({ name: 'toy' }))), + ); + + expect(await knex('graph_edge_toy').where('name', 'toy').count('* as count')).toEqual([ + { count }, + ]); + }, + ); + }); + + describe('upsertGraph() patches that bind many values', () => { + const { knex } = session; + const columns = Array.from({ length: 80 }, (_, i) => `c${i}`); + + class Wide extends Model { + static get tableName() { + return 'graph_edge_wide'; + } + } + + beforeAll(async () => { + await knex.schema.dropTableIfExists('graph_edge_wide'); + await knex.schema.createTable('graph_edge_wide', (table) => { + table.integer('id').primary(); + + for (const column of columns) { + table.integer(column); + } + }); + }); + + afterAll(() => knex.schema.dropTableIfExists('graph_edge_wide')); + + it('should stay under the number of values that a query can bind', async () => { + const row = (id, value) => ({ + id, + ...Object.fromEntries(columns.map((column) => [column, value])), + }); + const ids = Array.from({ length: 500 }, (_, i) => i + 1); + + for (let i = 0; i < ids.length; i += 10) { + await knex('graph_edge_wide').insert(ids.slice(i, i + 10).map((id) => row(id, 0))); + } + + const queries = await collectQueries(() => + Wide.query(knex).upsertGraph(ids.map((id) => row(id, id))), + ); + // SQLite builds before 3.32 bind 999 values at most: + const maxBindings = session.isSqlite() + ? 999 + : session.isMsSql() + ? 2100 + : session.isPostgres() + ? 65535 + : Infinity; + + expect(queries.every(({ bindings }) => bindings.length <= maxBindings)).toBe(true); + + const rows = await knex('graph_edge_wide').orderBy('id'); + expect(rows.every((row) => columns.every((column) => row[column] === row.id))).toBe(true); + }); + }); + + describe.skipIf(!session.isPostgres())('upsertGraph() patches that a trigger skips', () => { + const { knex } = session; + + class Item extends Model { + static get tableName() { + return 'graph_edge_item'; + } + } + + beforeAll(async () => { + await knex.schema.dropTableIfExists('graph_edge_item'); + await knex.schema.createTable('graph_edge_item', (table) => { + table.integer('id').primary(); + table.string('name'); + }); + await knex.raw(` + create or replace function graph_edge_skip() returns trigger as $$ + begin + if new.name = 'skip' then + return null; + end if; + return new; + end; + $$ language plpgsql + `); + await knex.raw(` + create trigger graph_edge_skip before update on graph_edge_item + for each row execute function graph_edge_skip() + `); + }); + + afterAll(async () => { + await knex.schema.dropTableIfExists('graph_edge_item'); + await knex.raw('drop function if exists graph_edge_skip'); + }); + + it('should not throw for rows that exist', async () => { + await knex('graph_edge_item').insert([ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + ]); + + await Item.query(knex).upsertGraph([ + { id: 1, name: 'skip' }, + { id: 2, name: 'c' }, + ]); + + expect(await knex('graph_edge_item').orderBy('id')).toEqual([ + { id: 1, name: 'a' }, + { id: 2, name: 'c' }, + ]); + }); + }); +}; diff --git a/tests/integration/misc/upsertGraphGenerated.js b/tests/integration/misc/upsertGraphGenerated.js new file mode 100644 index 000000000..c3c7e06ac --- /dev/null +++ b/tests/integration/misc/upsertGraphGenerated.js @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; +import { + createGraphCase, + createGraphModels, + createGraphTables, + dropGraphTables, + dumpGraphTables, + findKeptGraphRows, + seedGraphTables, +} from '../../../testUtils/graphCases.js'; + +// The errors that generated graphs may cause, as they're random, and the cases +// that allow them: +const expectedErrors = [ + { type: 'InvalidGraph', when: ({ isConflicting }) => isConflicting }, + // 3.x fails on these graphs too: + { message: /points to a model that the graph doesn't write before it/ }, + { message: /the object graph contains cyclic references/ }, + { + name: 'NotFoundError', + when: ({ options }) => options.relate !== true || options.insertMissing !== true, + }, + { + message: /cannot relate HasManyRelation or HasOneRelation using insertGraph/, + when: ({ method }) => method === 'insertGraph', + }, + // Options that keep rows or insert rows with their ids can make the graph + // collide with unique keys: + { + name: 'UniqueViolationError', + when: ({ options }) => options.insertMissing || options.noDelete || options.noUnrelate, + }, + // TODO: Remove once #398 is fixed: The row of a HasOneRelation slot that + // only moves out in a later pass collides with the new row of the slot. + { name: 'UniqueViolationError', message: /graph_gen_profile/ }, + // SQL Server doesn't take the ids that `insertMissing` inserts in identity + // columns: + { + message: /IDENTITY_INSERT is set to OFF|not allowed as explicit identity values/, + when: ({ options }) => options.insertMissing, + }, +]; + +// Write generated graphs, and check what has to hold for any graph: A failed +// write leaves the rows as they were, the rows that the graph keeps or inserts +// aren't deleted, no query runs after the write, and writing the same graph +// again gives the same rows. Set OBJECTION_TEST_GRAPH_CASES for more cases. +export default (session) => { + describe('upsertGraph() and insertGraph() of generated graphs', () => { + const { knex } = session; + const caseCount = Number(process.env.OBJECTION_TEST_GRAPH_CASES || 100); + const { Person } = createGraphModels(Model); + + beforeAll(() => createGraphTables(knex)); + afterAll(() => dropGraphTables(knex)); + + const write = async (testCase) => { + await seedGraphTables(knex, testCase.rows); + const seededRows = await dumpGraphTables(knex); + const lateQueries = []; + let isWritten = false; + let error = null; + + const onQuery = (query) => { + if (isWritten) { + lateQueries.push(query.sql); + } + }; + + knex.on('query', onQuery); + + try { + await knex.transaction((trx) => + Person.query(trx)[testCase.method](structuredClone(testCase.graph), { + ...testCase.options, + }), + ); + } catch (err) { + error = err; + } + + isWritten = true; + // Give queries that are still running the time to start: + await new Promise((resolve) => setTimeout(resolve, 10)); + knex.removeListener('query', onQuery); + + return { error, lateQueries, seededRows, rows: await dumpGraphTables(knex) }; + }; + + for (let seed = 1; seed <= caseCount; seed++) { + it(`should write generated graph ${seed}`, async () => { + const testCase = createGraphCase(seed); + const context = `graph ${seed}: ${JSON.stringify(testCase)}`; + + const result = await write(testCase); + const { error, rows } = result; + + expect(result.lateQueries, context).toEqual([]); + // Graphs built to conflict have to be rejected as such. `insertGraph()` + // rejects relates into HasMany relations before it compares places: + if (testCase.isConflicting) { + const isRejected = + error?.type === 'InvalidGraph' || + (testCase.method === 'insertGraph' && + /cannot relate HasManyRelation/.test(error?.message)); + expect(isRejected, `${error?.message}, ${context}`).toBe(true); + } + + if (error) { + const isExpected = expectedErrors.some( + ({ name, type, message, when }) => + (!name || error.name === name) && + (!type || error.type === type) && + (!message || message.test(error.message)) && + (!when || when(testCase)), + ); + expect(isExpected, `${error.name}: ${error.message}, ${context}`).toBe(true); + expect(rows, context).toEqual(result.seededRows); + } else if (testCase.method === 'upsertGraph') { + // These options ignore places of rows, which may then be removed from + // the places that the graph leaves them out of: + const { noRelate, noInsert, insertMissing } = testCase.options; + + if (!noRelate && !noInsert && !insertMissing) { + const deletedRows = findKeptGraphRows(testCase).filter(([table, label]) => + rows[table].every((row) => JSON.parse(row).label !== label), + ); + expect(deletedRows, context).toEqual([]); + } + + for (const [table, label, values] of testCase.expectedValues) { + const row = rows[table] + .map((row) => JSON.parse(row)) + .find((row) => row.label === label); + expect(row, context).toMatchObject(values); + } + } + + // Of parallel writes that fail, either may throw first: + const again = await write(testCase); + expect(again.error?.name, context).toBe(error?.name); + expect(again.rows, context).toEqual(rows); + }); + } + }); +}; diff --git a/tests/integration/misc/upsertGraphMovedRows.js b/tests/integration/misc/upsertGraphMovedRows.js index 0921dabee..b15b8154e 100644 --- a/tests/integration/misc/upsertGraphMovedRows.js +++ b/tests/integration/misc/upsertGraphMovedRows.js @@ -1,7 +1,7 @@ -const { expect } = require('chai'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('upsertGraph() moving rows to another owner with a NOT NULL foreign key (#214)', () => { const { knex } = session; @@ -27,7 +27,7 @@ module.exports = (session) => { } } - before(async () => { + beforeAll(async () => { await knex.schema .dropTableIfExists('moved_rows_pet') .dropTableIfExists('moved_rows_owner') @@ -52,7 +52,7 @@ module.exports = (session) => { await knex('moved_rows_owner').delete(); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('moved_rows_pet').dropTableIfExists('moved_rows_owner'); }); @@ -65,7 +65,239 @@ module.exports = (session) => { { relate: true }, ); - expect(await knex('moved_rows_pet')).to.eql([{ id: 1, ownerId: 2 }]); + expect(await knex('moved_rows_pet')).toEqual([{ id: 1, ownerId: 2 }]); + }); + + it('should move the row to a new owner without unrelating it first', async () => { + await Owner.query().upsertGraph( + [ + { id: 1, pets: [] }, + { id: 3, pets: [{ id: 1 }] }, + ], + { relate: true, unrelate: true, insertMissing: true }, + ); + + expect(await knex('moved_rows_pet')).toEqual([{ id: 1, ownerId: 3 }]); + }); + }); + + describe('upsertGraph() changing keys that are part of ids', () => { + const { knex } = session; + + class Tag extends Model { + static get tableName() { + return 'moved_ids_tag'; + } + } + + class Collar extends Model { + static get tableName() { + return 'moved_ids_collar'; + } + + static get idColumn() { + return ['ownerId', 'name']; + } + + static get relationMappings() { + return { + owner: { + relation: Model.BelongsToOneRelation, + modelClass: Owner, + join: { from: 'moved_ids_collar.ownerId', to: 'moved_ids_owner.id' }, + }, + tags: { + relation: Model.HasManyRelation, + modelClass: Tag, + join: { + from: ['moved_ids_collar.ownerId', 'moved_ids_collar.name'], + to: ['moved_ids_tag.collarOwnerId', 'moved_ids_tag.collarName'], + }, + }, + }; + } + } + + class Badge extends Model { + static get tableName() { + return 'moved_ids_badge'; + } + + static get idColumn() { + return ['ownerCode', 'name']; + } + } + + class Owner extends Model { + static get tableName() { + return 'moved_ids_owner'; + } + + static get relationMappings() { + return { + collars: { + relation: Model.HasManyRelation, + modelClass: Collar, + join: { from: 'moved_ids_owner.id', to: 'moved_ids_collar.ownerId' }, + }, + badges: { + relation: Model.HasManyRelation, + modelClass: Badge, + join: { from: 'moved_ids_owner.code', to: 'moved_ids_badge.ownerCode' }, + }, + }; + } + } + + const tables = ['moved_ids_tag', 'moved_ids_badge', 'moved_ids_collar', 'moved_ids_owner']; + + const dropTables = async () => { + for (const table of tables) { + await knex.schema.dropTableIfExists(table); + } + }; + + beforeAll(async () => { + await dropTables(); + await knex.schema + .createTable('moved_ids_owner', (table) => { + table.integer('id').primary(); + table.string('code'); + }) + .createTable('moved_ids_collar', (table) => { + table.integer('ownerId'); + table.string('name'); + table.primary(['ownerId', 'name']); + }) + .createTable('moved_ids_badge', (table) => { + table.string('ownerCode'); + table.string('name'); + table.primary(['ownerCode', 'name']); + }) + .createTable('moved_ids_tag', (table) => { + table.integer('id').primary(); + table.string('label'); + table.integer('collarOwnerId'); + table.string('collarName'); + }); + + Owner.knex(knex); + }); + + beforeEach(async () => { + await knex('moved_ids_owner').insert([ + { id: 1, code: 'x' }, + { id: 2, code: 'y' }, + ]); + await knex('moved_ids_collar').insert([ + { ownerId: 1, name: 'a' }, + { ownerId: 2, name: 'b' }, + ]); + await knex('moved_ids_badge').insert({ ownerCode: 'x', name: 'a' }); + await knex('moved_ids_tag').insert({ id: 1, label: 't', collarOwnerId: 1, collarName: 'a' }); + }); + + afterEach(async () => { + for (const table of tables) { + await knex(table).delete(); + } + }); + + afterAll(dropTables); + + async function expectUnchanged() { + expect(await knex('moved_ids_collar').orderBy('ownerId')).toEqual([ + { ownerId: 1, name: 'a' }, + { ownerId: 2, name: 'b' }, + ]); + expect(await knex('moved_ids_tag')).toEqual([ + { id: 1, label: 't', collarOwnerId: 1, collarName: 'a' }, + ]); + } + + it('should reject a move that would change the id of the row', async () => { + for (const collar of [ + { ownerId: 1, name: 'a' }, + { ownerId: 1, name: 'a', tags: [{ id: 1, label: 'changed' }] }, + ]) { + await expect( + Owner.query().upsertGraph( + { id: 2, collars: [{ ownerId: 2, name: 'b' }, collar] }, + { relate: true }, + ), + ).rejects.toThrow( + "the graph changes the key of the Collar at 'collars[1]' through collars, which is part of its id", + ); + await expectUnchanged(); + } + }); + + it('should reject a relate of an owner that would change the id of the row', async () => { + for (const owner of [{ id: 2 }, { id: 3, code: 'z' }]) { + await expect( + Collar.query(knex).upsertGraph( + { ownerId: 1, name: 'a', owner }, + { relate: true, insertMissing: true }, + ), + ).rejects.toThrow( + 'the graph changes the key of the Collar at the root through owner, which is part of its id', + ); + await expectUnchanged(); + } + }); + + it('should reject clearing the owner of a row whose id contains the foreign key', async () => { + for (const owner of [null, { id: 1, '#unrelate': true }, { id: 1, '#delete': true }]) { + await expect( + Collar.query(knex).upsertGraph( + { ownerId: 1, name: 'a', owner }, + { relate: true, allowMarkers: true }, + ), + ).rejects.toThrow( + 'the graph changes the key of the Collar at the root through owner, which is part of its id', + ); + await expectUnchanged(); + } + }); + + it('should accept marking an owner that is not the owner of the row', async () => { + await Collar.query(knex).upsertGraph( + { ownerId: 1, name: 'a', owner: { id: 2, '#unrelate': true } }, + { relate: true, allowMarkers: true }, + ); + await expectUnchanged(); + }); + + it('should accept a row at its place whose id contains the foreign key', async () => { + await Owner.query().upsertGraph( + { id: 1, collars: [{ ownerId: 1, name: 'a', tags: [{ id: 1, label: 'changed' }] }] }, + { relate: true }, + ); + + expect(await knex('moved_ids_tag')).toEqual([ + { id: 1, label: 'changed', collarOwnerId: 1, collarName: 'a' }, + ]); + }); + + it('should accept a #ref to a new row of the same owner', async () => { + await Owner.query().upsertGraph( + { id: 1, collars: [{ ownerId: 1, name: 'a' }, { '#id': 'c', name: 'c' }, { '#ref': 'c' }] }, + { relate: true, allowRefs: true }, + ); + + expect(await knex('moved_ids_collar').where('ownerId', 1).orderBy('name')).toEqual([ + { ownerId: 1, name: 'a' }, + { ownerId: 1, name: 'c' }, + ]); + }); + + it('should accept a #ref to a row of the same owner through a key that is not its id', async () => { + await Owner.query().upsertGraph( + { id: 1, badges: [{ '#id': 'b', ownerCode: 'x', name: 'a' }, { '#ref': 'b' }] }, + { relate: true, allowRefs: true }, + ); + + expect(await knex('moved_ids_badge')).toEqual([{ ownerCode: 'x', name: 'a' }]); }); }); }; diff --git a/tests/integration/misc/upsertGraphMovedRowsKept.js b/tests/integration/misc/upsertGraphMovedRowsKept.js new file mode 100644 index 000000000..480c376d1 --- /dev/null +++ b/tests/integration/misc/upsertGraphMovedRowsKept.js @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import { Model } from 'objection'; + +export default (session) => { + describe('upsertGraph() rows below moved rows that the graph keeps elsewhere', () => { + const { knex } = session; + + class Movie extends Model { + static get tableName() { + return 'graph_kept_movie'; + } + } + + class Person extends Model { + static get tableName() { + return 'graph_kept_person'; + } + + static get relationMappings() { + return { + children: { + relation: Model.HasManyRelation, + modelClass: Person, + join: { from: 'graph_kept_person.id', to: 'graph_kept_person.parentId' }, + }, + movies: { + relation: Model.ManyToManyRelation, + modelClass: Movie, + join: { + from: 'graph_kept_person.id', + through: { + from: 'graph_kept_person_movie.personId', + to: 'graph_kept_person_movie.movieId', + }, + to: 'graph_kept_movie.id', + }, + }, + }; + } + } + + const tables = ['graph_kept_person_movie', 'graph_kept_movie', 'graph_kept_person']; + + const dropTables = async () => { + for (const table of tables) { + await knex.schema.dropTableIfExists(table); + } + }; + + beforeAll(async () => { + await dropTables(); + await knex.schema + .createTable('graph_kept_person', (table) => { + table.increments('id'); + table.string('name'); + table.integer('parentId'); + }) + .createTable('graph_kept_movie', (table) => { + table.increments('id'); + table.string('title'); + }) + .createTable('graph_kept_person_movie', (table) => { + table.integer('personId'); + table.integer('movieId'); + }); + }); + + // Truncating resets the ids: persons 1 and 2, and movie 1 of person 1. + beforeEach(async () => { + for (const table of tables) { + await knex(table).truncate(); + } + + for (const name of ['a', 'b']) { + await knex('graph_kept_person').insert({ name }); + } + + await knex('graph_kept_movie').insert({ title: 'movie' }); + await knex('graph_kept_person_movie').insert({ personId: 1, movieId: 1 }); + }); + + afterAll(dropTables); + + it('should keep a many-to-many row that moves from a related model to another owner', async () => { + await Person.query(knex).upsertGraph( + { + name: 'new', + children: [ + { id: 1, movies: [] }, + { name: 'other', movies: [{ id: 1 }] }, + ], + }, + { relate: true }, + ); + + const other = await knex('graph_kept_person').where('name', 'other').first(); + expect(await knex('graph_kept_movie').pluck('id')).toEqual([1]); + expect(await knex('graph_kept_person_movie')).toEqual([{ personId: other.id, movieId: 1 }]); + }); + + it('should keep a root that the subgraph of a moved row removes', async () => { + await knex('graph_kept_person').where('id', 2).update({ parentId: 1 }); + + await Person.query(knex).upsertGraph( + [{ name: 'new', children: [{ id: 1, children: [] }] }, { id: 2 }], + { relate: true }, + ); + + expect(await knex('graph_kept_person').where('id', 2).first()).toMatchObject({ + id: 2, + parentId: null, + }); + }); + }); +}; diff --git a/tests/integration/misc/upsertGraphMoves.js b/tests/integration/misc/upsertGraphMoves.js new file mode 100644 index 000000000..0ac686487 --- /dev/null +++ b/tests/integration/misc/upsertGraphMoves.js @@ -0,0 +1,950 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model, NotFoundError, UniqueViolationError } from 'objection'; +import { insertRowsWithIds } from '../../../testUtils/testUtils.js'; + +// How upsertGraph() writes rows that it moves to other owners, and the rows +// around them, also where that fails today as documented. +export default (session) => { + describe('upsertGraph() moving rows', () => { + describe('rows of HasManyRelations', () => { + const { knex } = session; + + class Category extends Model { + static get tableName() { + return 'graph_move_category'; + } + } + + class Toy extends Model { + static get tableName() { + return 'graph_move_toy'; + } + } + + class Pet extends Model { + static get tableName() { + return 'graph_move_pet'; + } + + static get relationMappings() { + return { + owner: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: 'graph_move_pet.ownerId', to: 'graph_move_person.id' }, + }, + siblings: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: 'graph_move_pet.ownerId', to: 'graph_move_pet.ownerId' }, + }, + ownerFriends: { + relation: Model.ManyToManyRelation, + modelClass: Person, + join: { + from: 'graph_move_pet.ownerId', + through: { + from: 'graph_move_friendship.personId', + to: 'graph_move_friendship.friendId', + }, + to: 'graph_move_person.id', + }, + }, + toys: { + relation: Model.HasManyRelation, + modelClass: Toy, + join: { from: 'graph_move_pet.id', to: 'graph_move_toy.petId' }, + }, + category: { + relation: Model.BelongsToOneRelation, + modelClass: Category, + join: { from: 'graph_move_pet.categoryId', to: 'graph_move_category.id' }, + }, + }; + } + } + + class Person extends Model { + static get tableName() { + return 'graph_move_person'; + } + + static get relationMappings() { + return { + pets: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: 'graph_move_person.id', to: 'graph_move_pet.ownerId' }, + }, + children: { + relation: Model.HasManyRelation, + modelClass: Person, + join: { from: 'graph_move_person.id', to: 'graph_move_person.parentId' }, + }, + parent: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: 'graph_move_person.parentId', to: 'graph_move_person.id' }, + }, + favoritePet: { + relation: Model.BelongsToOneRelation, + modelClass: Pet, + join: { from: 'graph_move_person.favoritePetId', to: 'graph_move_pet.id' }, + }, + likedPets: { + relation: Model.ManyToManyRelation, + modelClass: Pet, + join: { + from: 'graph_move_person.id', + through: { + from: 'graph_move_like.personId', + to: 'graph_move_like.petId', + extra: ['note'], + }, + to: 'graph_move_pet.id', + }, + }, + }; + } + } + + // Models that require a name, for the paths that update() writes: + class NamedPet extends Pet { + static get jsonSchema() { + return { type: 'object', required: ['name'], properties: { name: { type: 'string' } } }; + } + } + + class NamedPetPerson extends Person { + static get relationMappings() { + const { pets, favoritePet, ...mappings } = super.relationMappings; + return { + ...mappings, + pets: { ...pets, modelClass: NamedPet }, + favoritePet: { ...favoritePet, modelClass: NamedPet }, + }; + } + } + + const tables = [ + 'graph_move_like', + 'graph_move_friendship', + 'graph_move_toy', + 'graph_move_pet', + 'graph_move_person', + 'graph_move_category', + ]; + + const dropTables = async () => { + for (const table of tables) { + await knex.schema.dropTableIfExists(table); + } + }; + + // `ownerOnDelete` is the action of the foreign key of the pets' owners, + // null for no foreign key. + async function createTables({ ownerOnDelete = 'CASCADE', requiredOwner = false } = {}) { + await dropTables(); + await knex.schema + .createTable('graph_move_category', (table) => { + table.increments('id'); + table.string('name'); + }) + .createTable('graph_move_person', (table) => { + table.increments('id'); + table.string('name'); + table.integer('parentId'); + table.integer('favoritePetId'); + }) + .createTable('graph_move_pet', (table) => { + table.increments('id'); + table.string('name'); + const ownerId = table.integer('ownerId').unsigned(); + if (ownerOnDelete) { + ownerId.references('graph_move_person.id').onDelete(ownerOnDelete); + } + if (requiredOwner) { + ownerId.notNullable(); + } + table.integer('categoryId').unsigned().references('graph_move_category.id'); + table.unique(['ownerId', 'name']); + }) + .createTable('graph_move_toy', (table) => { + table.increments('id'); + table.string('name'); + table.integer('petId').unsigned().references('graph_move_pet.id').onDelete('CASCADE'); + }) + .createTable('graph_move_friendship', (table) => { + table.integer('personId'); + table.integer('friendId'); + }) + .createTable('graph_move_like', (table) => { + table.integer('personId'); + table.integer('petId'); + table.string('note'); + }); + } + + // Person 1, a child of person 99, owns pets 10 and 12, person 2 pet 11 + // and person 3 pet 13. The graphs insert person 100 and pet 14. + async function insertRows() { + await insertRowsWithIds(knex, 'graph_move_category', [ + { id: 5, name: 'cats' }, + { id: 6, name: 'dogs' }, + ]); + await insertRowsWithIds(knex, 'graph_move_person', [ + { id: 1, name: 'p1', parentId: 99 }, + { id: 2, name: 'p2' }, + { id: 3, name: 'p3' }, + { id: 99, name: 'root' }, + ]); + await insertRowsWithIds(knex, 'graph_move_pet', [ + { id: 10, name: 'Fluffy', ownerId: 1, categoryId: 5 }, + { id: 11, name: 'Rex', ownerId: 2 }, + { id: 12, name: 'Tom', ownerId: 1 }, + { id: 13, name: 'Kit', ownerId: 3 }, + ]); + await insertRowsWithIds(knex, 'graph_move_toy', [{ id: 100, name: 'ball', petId: 10 }]); + await knex('graph_move_friendship').insert([ + { personId: 1, friendId: 3 }, + { personId: 2, friendId: 99 }, + ]); + } + + function selectPeople() { + return knex('graph_move_person').select('id', 'name', 'favoritePetId').orderBy('id'); + } + + function selectPets() { + return knex('graph_move_pet').select('id', 'name', 'ownerId').orderBy('id'); + } + + beforeAll(() => { + for (const modelClass of [Category, Toy, Pet, Person]) { + modelClass.knex(knex); + } + }); + + afterAll(dropTables); + it('should delete a row before inserting a row with its unique key', async () => { + await createTables(); + await insertRows(); + + await Person.query().upsertGraph({ id: 1, pets: [{ name: 'Fluffy' }] }); + + expect(await selectPets()).toEqual([ + { id: 11, name: 'Rex', ownerId: 2 }, + { id: 13, name: 'Kit', ownerId: 3 }, + { id: 14, name: 'Fluffy', ownerId: 1 }, + ]); + expect(await knex('graph_move_toy')).toEqual([]); + }); + + it('should delete the replaced category of a row that stays with its owner', async () => { + await createTables(); + await insertRows(); + + await Person.query().upsertGraph( + { id: 1, pets: [{ id: 12 }, { id: 10, categoryId: 6, category: { id: 6 } }] }, + { relate: true }, + ); + + expect(await knex('graph_move_pet').select('id', 'categoryId').where('id', 10)).toEqual([ + { id: 10, categoryId: 6 }, + ]); + expect(await knex('graph_move_category').orderBy('id')).toEqual([{ id: 6, name: 'dogs' }]); + }); + + it('should delete the old owner that a root row replaces through its own relation', async () => { + await createTables(); + await insertRows(); + + await Pet.query().upsertGraph({ id: 10, owner: { id: 2 } }, { relate: true }); + + expect((await selectPeople()).map(({ id }) => id)).toEqual([2, 3, 99]); + expect(await selectPets()).toEqual([ + { id: 10, name: 'Fluffy', ownerId: 2 }, + { id: 11, name: 'Rex', ownerId: 2 }, + { id: 13, name: 'Kit', ownerId: 3 }, + ]); + }); + + it('should read the siblings of a root row at the key that it changes itself', async () => { + await createTables(); + await insertRows(); + + await Pet.query().upsertGraph( + { id: 10, ownerId: 2, siblings: [{ id: 10 }, { id: 11 }] }, + { relate: true }, + ); + + expect(await selectPets()).toEqual([ + { id: 10, name: 'Fluffy', ownerId: 2 }, + { id: 11, name: 'Rex', ownerId: 2 }, + { id: 13, name: 'Kit', ownerId: 3 }, + ]); + }); + + it('should keep the old owners in a chain of moved rows that name their new owners', async () => { + await createTables(); + await insertRows(); + await knex('graph_move_person').where('id', 2).update({ parentId: 99 }); + + await Person.query().upsertGraph( + { + id: 3, + children: [ + { id: 2, pets: [{ id: 11 }, { id: 10, owner: { id: 2, parent: { id: 3 } } }] }, + ], + }, + { relate: true }, + ); + + expect(await knex('graph_move_person').select('id', 'parentId').orderBy('id')).toEqual([ + { id: 1, parentId: 99 }, + { id: 2, parentId: 3 }, + { id: 3, parentId: null }, + { id: 99, parentId: null }, + ]); + expect(await selectPets()).toEqual([ + { id: 10, name: 'Fluffy', ownerId: 2 }, + { id: 11, name: 'Rex', ownerId: 2 }, + { id: 12, name: 'Tom', ownerId: 1 }, + { id: 13, name: 'Kit', ownerId: 3 }, + ]); + }); + + it('should read a to-many relation of a moved row at its new key', async () => { + await createTables(); + await insertRows(); + + await Person.query().upsertGraph( + { id: 2, pets: [{ id: 11 }, { id: 10, siblings: [{ id: 11 }, { id: 10 }] }] }, + { relate: true }, + ); + + expect(await selectPets()).toEqual([ + { id: 10, name: 'Fluffy', ownerId: 2 }, + { id: 11, name: 'Rex', ownerId: 2 }, + { id: 12, name: 'Tom', ownerId: 1 }, + { id: 13, name: 'Kit', ownerId: 3 }, + ]); + }); + + it('should read a many-to-many relation of a moved row at its new key', async () => { + await createTables(); + await insertRows(); + + await Person.query().upsertGraph( + { id: 2, pets: [{ id: 11 }, { id: 10, ownerFriends: [{ id: 99 }, { id: 3 }] }] }, + { relate: true, unrelate: true }, + ); + + expect((await selectPets()).find(({ id }) => id === 10).ownerId).toBe(2); + expect(await knex('graph_move_friendship').orderBy(['personId', 'friendId'])).toEqual([ + { personId: 1, friendId: 3 }, + { personId: 2, friendId: 3 }, + { personId: 2, friendId: 99 }, + ]); + }); + + it('should keep the old owner when a moved row also names its new owner', async () => { + for (const [owner, name] of [ + [{ id: 2, name: 'p2x' }, 'p2x'], + [{ id: 2 }, 'p2'], + ]) { + await createTables(); + await insertRows(); + + await Person.query().upsertGraph( + { id: 2, pets: [{ id: 11 }, { id: 10, owner }] }, + { relate: true }, + ); + + expect((await selectPeople()).map(({ id, name }) => ({ id, name }))).toEqual([ + { id: 1, name: 'p1' }, + { id: 2, name }, + { id: 3, name: 'p3' }, + { id: 99, name: 'root' }, + ]); + expect(await selectPets()).toEqual([ + { id: 10, name: 'Fluffy', ownerId: 2 }, + { id: 11, name: 'Rex', ownerId: 2 }, + { id: 12, name: 'Tom', ownerId: 1 }, + { id: 13, name: 'Kit', ownerId: 3 }, + ]); + } + }); + + it('should move a row out of its deleted owner before another place writes it after an insert', async () => { + await createTables(); + await insertRows(); + + await Person.query().upsertGraph( + [ + { id: 99, children: [] }, + { id: 2, pets: [{ id: 11 }, { id: 10 }] }, + { + id: 3, + pets: [{ id: 13 }, { '#id': 't', name: 'newname' }], + favoritePet: { id: 10, name: '#ref{t.name}' }, + }, + ], + { relate: true, allowRefs: true }, + ); + + expect(await selectPeople()).toEqual([ + { id: 2, name: 'p2', favoritePetId: null }, + { id: 3, name: 'p3', favoritePetId: 10 }, + { id: 99, name: 'root', favoritePetId: null }, + ]); + expect(await selectPets()).toEqual([ + { id: 10, name: 'newname', ownerId: 2 }, + { id: 11, name: 'Rex', ownerId: 2 }, + { id: 13, name: 'Kit', ownerId: 3 }, + { id: 14, name: 'newname', ownerId: 3 }, + ]); + expect(await knex('graph_move_toy')).toEqual([{ id: 100, name: 'ball', petId: 10 }]); + }); + + // A documented limit. + it('should fail to move a row that waits for an insert after the delete of its owner cascades to it', async () => { + await createTables(); + await insertRows(); + + const err = await Person.query() + .upsertGraph( + [ + { id: 99, children: [] }, + { id: 2, pets: [{ id: 11 }, { id: 10, name: '#ref{t.name}' }] }, + { id: 3, pets: [{ id: 13 }, { '#id': 't', name: 'newname' }] }, + ], + { relate: true, allowRefs: true }, + ) + .catch((err) => err); + + expect(err).toBeInstanceOf(NotFoundError); + expect(err.message).toBe( + 'model (id=10) was not found when updating it. An earlier write of the graph or a concurrent query may have deleted it', + ); + expect(await selectPets()).toEqual([ + { id: 11, name: 'Rex', ownerId: 2 }, + { id: 13, name: 'Kit', ownerId: 3 }, + { id: 14, name: 'newname', ownerId: 3 }, + ]); + }); + + it('should move a row with a NOT NULL key to a new owner without unrelating it first', async () => { + await createTables({ requiredOwner: true }); + await insertRows(); + + await Person.query().upsertGraph( + [ + { id: 1, pets: [{ id: 12 }] }, + { name: 'newOwner', pets: [{ id: 10 }] }, + ], + { relate: true, unrelate: true }, + ); + + expect((await selectPeople()).at(-1)).toEqual({ + id: 100, + name: 'newOwner', + favoritePetId: null, + }); + expect(await selectPets()).toEqual([ + { id: 10, name: 'Fluffy', ownerId: 100 }, + { id: 11, name: 'Rex', ownerId: 2 }, + { id: 12, name: 'Tom', ownerId: 1 }, + { id: 13, name: 'Kit', ownerId: 3 }, + ]); + }); + + it('should rename a moved row in another place after a delete frees the name', async () => { + await createTables(); + await insertRows(); + + await Person.query().upsertGraph( + [ + { id: 2, pets: [{ id: 10 }] }, + { id: 3, favoritePet: { id: 10, name: 'Rex' } }, + ], + { relate: true }, + ); + + expect((await selectPeople()).find(({ id }) => id === 3).favoritePetId).toBe(10); + expect(await selectPets()).toEqual([ + { id: 10, name: 'Rex', ownerId: 2 }, + { id: 12, name: 'Tom', ownerId: 1 }, + { id: 13, name: 'Kit', ownerId: 3 }, + ]); + }); + + it('should write a moved row that a many-to-many place lists before the move', async () => { + await createTables(); + await insertRows(); + + await Person.query().upsertGraph( + [ + { id: 3, likedPets: [{ id: 10, name: 'Fluffy2', note: 'n' }] }, + { id: 2, pets: [{ id: 11 }, { id: 10 }] }, + ], + { relate: true }, + ); + + expect((await selectPets()).find(({ id }) => id === 10)).toEqual({ + id: 10, + name: 'Fluffy2', + ownerId: 2, + }); + expect(await knex('graph_move_like')).toEqual([{ personId: 3, petId: 10, note: 'n' }]); + }); + + it('should move a row in a patched place and update it in another place', async () => { + await createTables(); + await insertRows(); + + await NamedPetPerson.query().upsertGraph( + [ + { id: 2, pets: [{ id: 11 }, { id: 10 }] }, + { id: 3, favoritePet: { id: 10, name: 'Fluffy2' } }, + ], + { relate: true, update: ['favoritePet'] }, + ); + + expect((await selectPets()).find(({ id }) => id === 10)).toEqual({ + id: 10, + name: 'Fluffy2', + ownerId: 2, + }); + }); + + it('should move a row in an updated place and patch it in another place', async () => { + await createTables(); + await insertRows(); + + await NamedPetPerson.query().upsertGraph( + [ + { + id: 2, + pets: [ + { id: 11, name: 'Rex' }, + { id: 10, name: 'Fluffy2' }, + ], + }, + { id: 3, favoritePet: { id: 10, categoryId: 6 } }, + ], + { relate: true, update: ['pets'] }, + ); + + expect( + await knex('graph_move_pet') + .select('id', 'name', 'ownerId', 'categoryId') + .where('id', 10), + ).toEqual([{ id: 10, name: 'Fluffy2', ownerId: 2, categoryId: 6 }]); + }); + + // A documented limit. + it('should fail to move a row to a new owner after the delete of its old owner cascades to it', async () => { + await createTables(); + await insertRows(); + + const err = await Person.query() + .upsertGraph( + [ + { id: 99, children: [] }, + { name: 'newOwner', pets: [{ id: 10 }] }, + ], + { relate: true }, + ) + .catch((err) => err); + + expect(err).toBeInstanceOf(NotFoundError); + expect(err.message).toBe( + 'model (id=10) was not found when updating it. An earlier write of the graph or a concurrent query may have deleted it', + ); + expect(await selectPets()).toEqual([ + { id: 11, name: 'Rex', ownerId: 2 }, + { id: 13, name: 'Kit', ownerId: 3 }, + ]); + }); + + for (const ownerOnDelete of ['SET NULL', null]) { + describe(`pets with ${ownerOnDelete ?? 'no foreign key'} on the delete of their owner`, () => { + // The row that stays with the deleted owner: + const tom = { id: 12, name: 'Tom', ownerId: ownerOnDelete ? null : 1 }; + it('should move a row to a new owner after the delete of its old owner', async () => { + await createTables({ ownerOnDelete }); + await insertRows(); + + await Person.query().upsertGraph( + [ + { id: 99, children: [] }, + { name: 'newOwner', pets: [{ id: 10 }] }, + ], + { relate: true }, + ); + + expect(await selectPets()).toEqual([ + { id: 10, name: 'Fluffy', ownerId: 100 }, + { id: 11, name: 'Rex', ownerId: 2 }, + tom, + { id: 13, name: 'Kit', ownerId: 3 }, + ]); + }); + + it('should move a row to the new row that replaces its deleted owner', async () => { + await createTables({ ownerOnDelete }); + await insertRows(); + + await Person.query().upsertGraph( + { id: 99, children: [{ name: 'newChild', pets: [{ id: 10 }] }] }, + { relate: true }, + ); + + expect( + await knex('graph_move_person').select('id', 'name', 'parentId').orderBy('id'), + ).toEqual([ + { id: 2, name: 'p2', parentId: null }, + { id: 3, name: 'p3', parentId: null }, + { id: 99, name: 'root', parentId: null }, + { id: 100, name: 'newChild', parentId: 99 }, + ]); + expect(await selectPets()).toEqual([ + { id: 10, name: 'Fluffy', ownerId: 100 }, + { id: 11, name: 'Rex', ownerId: 2 }, + tom, + { id: 13, name: 'Kit', ownerId: 3 }, + ]); + }); + }); + } + it('should insert the new owner of a root row before deleting the old one', async () => { + await createTables(); + await insertRows(); + + await Pet.query().upsertGraph({ id: 13, owner: { name: 'new' } }); + + expect((await selectPeople()).map(({ id, name }) => ({ id, name }))).toEqual([ + { id: 1, name: 'p1' }, + { id: 2, name: 'p2' }, + { id: 99, name: 'root' }, + { id: 100, name: 'new' }, + ]); + expect((await selectPets()).find(({ id }) => id === 13)).toEqual({ + id: 13, + name: 'Kit', + ownerId: 100, + }); + }); + + // A documented limit, as the moves to new owners run after the inserts. + it('should fail to insert a row with the unique key of a row that moves to a new owner', async () => { + await createTables(); + await insertRows(); + + await expect( + Person.query().upsertGraph( + [ + { id: 1, pets: [{ id: 12 }, { name: 'Fluffy' }] }, + { name: 'newOwner', pets: [{ id: 10 }] }, + ], + { relate: true, unrelate: true }, + ), + ).rejects.toThrow(UniqueViolationError); + + expect(await selectPets()).toEqual([ + { id: 10, name: 'Fluffy', ownerId: 1 }, + { id: 11, name: 'Rex', ownerId: 2 }, + { id: 12, name: 'Tom', ownerId: 1 }, + { id: 13, name: 'Kit', ownerId: 3 }, + ]); + }); + }); + + describe('rows of HasOneRelation slots', () => { + const { knex } = session; + + class Passport extends Model { + static get tableName() { + return 'graph_move_passport'; + } + + static get relationMappings() { + return { + people: { + relation: Model.HasManyRelation, + modelClass: Person, + join: { from: 'graph_move_passport.id', to: 'graph_move_holder.passportRefId' }, + }, + }; + } + } + + class Person extends Model { + static get tableName() { + return 'graph_move_holder'; + } + + static get relationMappings() { + return { + passport: { + relation: Model.HasOneRelation, + modelClass: Passport, + join: { from: 'graph_move_holder.id', to: 'graph_move_passport.personId' }, + }, + passportRef: { + relation: Model.BelongsToOneRelation, + modelClass: Passport, + join: { from: 'graph_move_holder.passportRefId', to: 'graph_move_passport.id' }, + }, + children: { + relation: Model.HasManyRelation, + modelClass: Person, + join: { from: 'graph_move_holder.id', to: 'graph_move_holder.parentId' }, + }, + }; + } + } + + const dropTables = () => + knex.schema.dropTableIfExists('graph_move_passport').dropTableIfExists('graph_move_holder'); + + // `personOnDelete` is the action of the foreign key of the passports' + // people, null for none. + async function createTables({ + personOnDelete = null, + requiredPerson = false, + uniquePerson = true, + } = {}) { + await dropTables(); + await knex.schema + .createTable('graph_move_holder', (table) => { + table.increments('id'); + table.string('name'); + table.integer('parentId'); + table.integer('passportRefId'); + table.integer('tag'); + }) + .createTable('graph_move_passport', (table) => { + table.increments('id'); + table.string('no'); + const personId = table + .integer('personId') + .unsigned() + .references('graph_move_holder.id'); + if (personOnDelete) { + personId.onDelete(personOnDelete); + } + if (requiredPerson) { + personId.notNullable(); + } + if (uniquePerson) { + table.unique(['personId']); + } + }); + } + + function selectPeople() { + return knex('graph_move_holder').select('id', 'name', 'passportRefId', 'tag').orderBy('id'); + } + + function selectPassports() { + return knex('graph_move_passport').orderBy('id'); + } + + beforeAll(() => { + Person.knex(knex); + }); + + afterAll(dropTables); + + describe('swapped between a person and a new one', () => { + // Person 2, a child of person 9, has passport 20 (R), which moves into + // the slot of person 1, whose passport 10 (S) moves to the new person 10: + async function insertRows() { + await insertRowsWithIds(knex, 'graph_move_holder', [ + { id: 1, name: 'P' }, + { id: 2, name: 'O', parentId: 9 }, + { id: 9, name: 'root' }, + ]); + await insertRowsWithIds(knex, 'graph_move_passport', [ + { id: 10, no: 'S', personId: 1 }, + { id: 20, no: 'R', personId: 2 }, + ]); + } + it('should move the rows with nullable and NOT NULL keys', async () => { + for (const requiredPerson of [false, true]) { + await createTables({ personOnDelete: 'CASCADE', requiredPerson }); + await insertRows(); + + await Person.query().upsertGraph( + [ + { id: 1, passport: { id: 20 } }, + { name: 'new', passport: { id: 10 } }, + ], + { relate: true }, + ); + + expect(await selectPassports()).toEqual([ + { id: 10, no: 'S', personId: 10 }, + { id: 20, no: 'R', personId: 1 }, + ]); + } + }); + + // A documented limit. + it('should fail to move a row after the delete of its old owner cascades to it', async () => { + for (const requiredPerson of [false, true]) { + await createTables({ personOnDelete: 'CASCADE', requiredPerson }); + await insertRows(); + + const err = await Person.query() + .upsertGraph( + [ + { id: 9, children: [] }, + { id: 1, passport: { id: 20 } }, + { name: 'new', passport: { id: 10 } }, + ], + { relate: true }, + ) + .catch((err) => err); + + expect(err).toBeInstanceOf(NotFoundError); + expect(err.message).toBe( + 'model (id=20) was not found when updating it. An earlier write of the graph or a concurrent query may have deleted it', + ); + expect(await selectPassports()).toEqual([{ id: 10, no: 'S', personId: 10 }]); + } + }); + + it('should move a row after the delete of its old owner without a cascade', async () => { + await createTables({ personOnDelete: 'SET NULL' }); + await insertRows(); + + await Person.query().upsertGraph( + [ + { id: 9, children: [] }, + { id: 1, passport: { id: 20 } }, + { name: 'new', passport: { id: 10 } }, + ], + { relate: true }, + ); + + expect((await selectPeople()).map(({ id }) => id)).toEqual([1, 9, 10]); + expect(await selectPassports()).toEqual([ + { id: 10, no: 'S', personId: 10 }, + { id: 20, no: 'R', personId: 1 }, + ]); + }); + }); + + describe('cycles of inserts and moves out of a slot', () => { + // Person 1 has passport 10 (S). The graphs insert person 2 and + // passport 11. + async function insertRows() { + await insertRowsWithIds(knex, 'graph_move_holder', [{ id: 1, name: 'P' }]); + await insertRowsWithIds(knex, 'graph_move_passport', [{ id: 10, no: 'S', personId: 1 }]); + } + + // The new passport N takes the slot of person 1, and its current + // passport S moves to the new person, whose name reads N's id. That's + // a cycle, which the slot yields: N is inserted before S moves, which + // only works without a unique key on the slot. + it('should insert a row into a slot before its current row moves to a new owner that reads its id', async () => { + await createTables({ uniquePerson: false }); + await insertRows(); + + await Person.query().upsertGraph( + [ + { id: 1, passport: { '#id': 'n', no: 'N' } }, + { name: 'q#ref{n.id}', passport: { id: 10 } }, + ], + { relate: true, allowRefs: true }, + ); + + expect(await selectPeople()).toEqual([ + { id: 1, name: 'P', passportRefId: null, tag: null }, + { id: 2, name: 'q11', passportRefId: null, tag: null }, + ]); + expect(await selectPassports()).toEqual([ + { id: 10, no: 'S', personId: 2 }, + { id: 11, no: 'N', personId: 1 }, + ]); + }); + + // The new owner of S is also placed in N.people, with a #ref. + it('should insert a row into a slot that a #ref place of the new owner of its current row reads', async () => { + await createTables(); + await insertRows(); + + await Person.query().upsertGraph( + [ + { id: 1, passport: { '#id': 'n', no: 'N', people: [{ '#ref': 'q' }] } }, + { '#id': 'q', name: 'Q', passport: { id: 10 } }, + ], + { relate: true, allowRefs: true }, + ); + + expect(await selectPeople()).toEqual([ + { id: 1, name: 'P', passportRefId: null, tag: null }, + { id: 2, name: 'Q', passportRefId: 11, tag: null }, + ]); + expect(await selectPassports()).toEqual([ + { id: 10, no: 'S', personId: 2 }, + { id: 11, no: 'N', personId: 1 }, + ]); + }); + + // The same, with a prop of the #ref place that reads N's id. + it('should write the props of a #ref place that read the id of the row in the slot', async () => { + await createTables({ uniquePerson: false }); + await insertRows(); + + await Person.query().upsertGraph( + [ + { + id: 1, + passport: { '#id': 'n', no: 'N', people: [{ '#ref': 'q', tag: '#ref{n.id}' }] }, + }, + { '#id': 'q', name: 'Q', passport: { id: 10 } }, + ], + { relate: true, allowRefs: true }, + ); + + expect(await selectPeople()).toEqual([ + { id: 1, name: 'P', passportRefId: null, tag: null }, + { id: 2, name: 'Q', passportRefId: 11, tag: 11 }, + ]); + expect(await selectPassports()).toEqual([ + { id: 10, no: 'S', personId: 2 }, + { id: 11, no: 'N', personId: 1 }, + ]); + }); + + // N is inserted for the passportRef of the new owner of S, and + // its #ref place takes the slot of person 1 that S leaves. + it('should relate a #ref into a slot after its current row moves to the new owner that inserts it', async () => { + await createTables(); + await insertRows(); + + await Person.query().upsertGraph( + [ + { id: 1, passport: { '#ref': 'q' } }, + { name: 'Z', passport: { id: 10 }, passportRef: { '#id': 'q', no: 'N' } }, + ], + { relate: true, allowRefs: true }, + ); + + expect(await selectPeople()).toEqual([ + { id: 1, name: 'P', passportRefId: null, tag: null }, + { id: 2, name: 'Z', passportRefId: 11, tag: null }, + ]); + expect(await selectPassports()).toEqual([ + { id: 10, no: 'S', personId: 2 }, + { id: 11, no: 'N', personId: 1 }, + ]); + }); + }); + }); + }); +}; diff --git a/tests/integration/misc/upsertGraphOrder.js b/tests/integration/misc/upsertGraphOrder.js new file mode 100644 index 000000000..93655b572 --- /dev/null +++ b/tests/integration/misc/upsertGraphOrder.js @@ -0,0 +1,927 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import { Model, UniqueViolationError } from 'objection'; + +export default (session) => { + describe('upsertGraph() order of the writes (#214)', () => { + const { knex } = session; + + class Node extends Model { + static get tableName() { + return 'graph_order_node'; + } + + static get relationMappings() { + return { + children: { + relation: Model.HasManyRelation, + modelClass: Node, + join: { from: 'graph_order_node.id', to: 'graph_order_node.parentId' }, + }, + parent: { + relation: Model.BelongsToOneRelation, + modelClass: Node, + join: { from: 'graph_order_node.parentId', to: 'graph_order_node.id' }, + }, + }; + } + } + + class Pet extends Model { + static get tableName() { + return 'graph_order_pet'; + } + + // Tests delay the updates of these rows, so that writes that run + // concurrently would run out of order: + static delayedIds = []; + + async $beforeUpdate() { + if (Pet.delayedIds.includes(this.id)) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + + static get relationMappings() { + return { + owner: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: 'graph_order_pet.ownerId', to: 'graph_order_person.id' }, + }, + siblings: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: 'graph_order_pet.ownerId', to: 'graph_order_pet.ownerId' }, + }, + fans: { + relation: Model.HasManyRelation, + modelClass: Person, + join: { from: 'graph_order_pet.id', to: 'graph_order_person.favoritePetId' }, + }, + }; + } + } + + class Person extends Model { + static get tableName() { + return 'graph_order_person'; + } + + static get relationMappings() { + return { + pets: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: 'graph_order_person.id', to: 'graph_order_pet.ownerId' }, + }, + petsNamedA: { + relation: Model.HasManyRelation, + modelClass: Pet, + filter: (query) => query.where('name', 'a'), + join: { from: 'graph_order_person.id', to: 'graph_order_pet.ownerId' }, + }, + onePet: { + relation: Model.HasOneRelation, + modelClass: Pet, + join: { from: 'graph_order_person.id', to: 'graph_order_pet.ownerId' }, + }, + favoritePet: { + relation: Model.BelongsToOneRelation, + modelClass: Pet, + join: { from: 'graph_order_person.favoritePetId', to: 'graph_order_pet.id' }, + }, + likedPets: { + relation: Model.ManyToManyRelation, + modelClass: Pet, + join: { + from: 'graph_order_person.id', + through: { from: 'graph_order_like.personId', to: 'graph_order_like.petId' }, + to: 'graph_order_pet.id', + }, + }, + }; + } + } + + class Org extends Model { + static get tableName() { + return 'graph_order_org'; + } + + static get relationMappings() { + return { + people: { + relation: Model.HasManyRelation, + modelClass: Person, + join: { from: 'graph_order_org.id', to: 'graph_order_person.orgId' }, + }, + }; + } + } + + const tableNames = [ + 'graph_order_like', + 'graph_order_node', + 'graph_order_pet', + 'graph_order_person', + 'graph_order_org', + ]; + + function dropTables() { + return tableNames.reduce((schema, name) => schema.dropTableIfExists(name), knex.schema); + } + + // `nodeOnDelete` is the action of the tree's foreign key, null for none. + async function createTables({ + nodeOnDelete = null, + uniqueOwner = false, + uniqueName = false, + requiredOwner = false, + } = {}) { + await dropTables(); + await knex.schema + .createTable('graph_order_node', (table) => { + table.integer('id').primary(); + const parentId = table.integer('parentId').references('graph_order_node.id'); + if (nodeOnDelete) { + parentId.onDelete(nodeOnDelete); + } + }) + .createTable('graph_order_org', (table) => { + table.integer('id').primary(); + }) + .createTable('graph_order_person', (table) => { + table.integer('id').primary(); + table.integer('orgId').references('graph_order_org.id').onDelete('CASCADE'); + table.integer('favoritePetId'); + }) + .createTable('graph_order_like', (table) => { + table.integer('personId'); + table.integer('petId'); + }) + .createTable('graph_order_pet', (table) => { + table.integer('id').primary(); + table.string('name'); + const ownerId = table + .integer('ownerId') + .references('graph_order_person.id') + .onDelete('CASCADE'); + if (requiredOwner) { + ownerId.notNullable(); + } + if (uniqueOwner) { + table.unique(['ownerId']); + } + if (uniqueName) { + table.unique(['ownerId', 'name']); + } + }); + } + + async function insertPeople(pets) { + await knex('graph_order_org').insert({ id: 1 }); + await knex('graph_order_person').insert([ + { id: 1, orgId: 1 }, + { id: 2, orgId: 1 }, + ]); + await knex('graph_order_pet').insert(pets); + } + + function selectNodes() { + return knex('graph_order_node').orderBy('id'); + } + + function selectPets() { + return knex('graph_order_pet').orderBy('id'); + } + + beforeAll(() => { + for (const modelClass of [Node, Pet, Person, Org]) { + modelClass.knex(knex); + } + }); + + afterAll(dropTables); + + // SQL Server rejects cascading actions on self-references. + for (const onDelete of [null, 'CASCADE', 'SET NULL']) { + describe(`tree with ${onDelete ?? 'no action'} on delete`, () => { + const itTree = it.skipIf(onDelete && session.isMsSql()); + + itTree('should keep a row that moves up from below a deleted row', async () => { + await createTables({ nodeOnDelete: onDelete }); + await knex('graph_order_node').insert([ + { id: 1, parentId: null }, + { id: 2, parentId: 1 }, + { id: 3, parentId: 2 }, + ]); + + await Node.query().upsertGraph({ id: 1, children: [{ id: 3 }] }, { relate: true }); + + expect(await selectNodes()).toEqual([ + { id: 1, parentId: null }, + { id: 3, parentId: 1 }, + ]); + }); + + itTree('should keep a row that moves to a sibling of its deleted parent', async () => { + await createTables({ nodeOnDelete: onDelete }); + await knex('graph_order_node').insert([ + { id: 1, parentId: null }, + { id: 2, parentId: 1 }, + { id: 3, parentId: 2 }, + { id: 4, parentId: 1 }, + ]); + + await Node.query().upsertGraph( + { id: 1, children: [{ id: 4, children: [{ id: 3 }] }] }, + { relate: true }, + ); + + expect(await selectNodes()).toEqual([ + { id: 1, parentId: null }, + { id: 3, parentId: 4 }, + { id: 4, parentId: 1 }, + ]); + }); + }); + } + + it('should keep a row that moves out of a deleted branch of another table', async () => { + await createTables(); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 2 }, + ]); + + await Org.query().upsertGraph( + { id: 1, people: [{ id: 1, pets: [{ id: 1 }, { id: 2 }] }] }, + { relate: true }, + ); + + expect(await knex('graph_order_person')).toEqual([{ id: 1, orgId: 1, favoritePetId: null }]); + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 1 }, + ]); + }); + + it('should keep a row that moves into an empty HasOneRelation from a deleted branch', async () => { + await createTables(); + await insertPeople([{ id: 2, name: 'b', ownerId: 2 }]); + + await Org.query().upsertGraph( + { id: 1, people: [{ id: 1, onePet: { id: 2 } }] }, + { relate: true }, + ); + + expect(await selectPets()).toEqual([{ id: 2, name: 'b', ownerId: 1 }]); + }); + + // The ids of `#ref` models may come from the inserts, so they're related after. + it('should relate #ref models after the inserts', async () => { + await createTables(); + await knex('graph_order_person').insert({ id: 1 }); + + await Person.query().upsertGraph( + { id: 1, favoritePet: { '#id': 'x', id: 3, name: 'c' }, pets: [{ '#ref': 'x' }] }, + { relate: true, insertMissing: true, allowRefs: true }, + ); + + expect(await knex('graph_order_person')).toEqual([{ id: 1, orgId: null, favoritePetId: 3 }]); + expect(await selectPets().select('id', 'name')).toEqual([{ id: 3, name: 'c' }]); + }); + + it('should give #ref models related into a HasManyRelation their foreign key (#311)', async () => { + await createTables(); + await knex('graph_order_person').insert({ id: 1 }); + + await Person.query().upsertGraph( + { id: 1, favoritePet: { '#id': 'x', id: 3, name: 'c' }, pets: [{ '#ref': 'x' }] }, + { relate: true, insertMissing: true, allowRefs: true }, + ); + + expect(await selectPets()).toEqual([{ id: 3, name: 'c', ownerId: 1 }]); + }); + + describe('rows that move with the relation back to their new owner', () => { + it('should keep the old owner of a row moved into a HasManyRelation', async () => { + for (const relate of [true, ['pets']]) { + await createTables(); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 2 }, + ]); + + await Person.query().upsertGraph( + { id: 1, pets: [{ id: 1 }, { id: 2, owner: { id: 1, orgId: null } }] }, + { relate }, + ); + + expect(await knex('graph_order_person').orderBy('id')).toEqual([ + { id: 1, orgId: null, favoritePetId: null }, + { id: 2, orgId: 1, favoritePetId: null }, + ]); + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 1 }, + ]); + } + }); + + it('should upsert the relations that the graph gives the new owner there', async () => { + for (const relate of [true, ['pets']]) { + await createTables(); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 2 }, + ]); + await knex('graph_order_person').where('id', 1).update({ favoritePetId: 1 }); + await knex('graph_order_like').insert({ personId: 1, petId: 1 }); + + await Person.query().upsertGraph( + { + id: 1, + pets: [ + { id: 1 }, + { id: 2, owner: { id: 1, favoritePet: { id: 1 }, likedPets: [{ id: 1 }] } }, + ], + }, + { relate }, + ); + + expect(await knex('graph_order_person').orderBy('id')).toEqual([ + { id: 1, orgId: 1, favoritePetId: 1 }, + { id: 2, orgId: 1, favoritePetId: null }, + ]); + expect(await knex('graph_order_like')).toEqual([{ personId: 1, petId: 1 }]); + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 1 }, + ]); + } + }); + + it('should find the moved row below its new owner there', async () => { + for (const relate of [true, ['pets']]) { + await createTables(); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 2 }, + ]); + + await Person.query().upsertGraph( + { + id: 1, + pets: [ + { id: 1 }, + { id: 2, owner: { id: 1, pets: [{ id: 1 }, { id: 2, name: 'c' }] } }, + ], + }, + { relate }, + ); + + expect(await knex('graph_order_person').orderBy('id').pluck('id')).toEqual([1, 2]); + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'c', ownerId: 1 }, + ]); + } + }); + + it('should keep a row that the graph relates below a moved row', async () => { + await createTables(); + await knex('graph_order_node').insert([ + { id: 1, parentId: null }, + { id: 2, parentId: null }, + { id: 3, parentId: 1 }, + { id: 4, parentId: 1 }, + { id: 5, parentId: 2 }, + ]); + + await Node.query().upsertGraph( + { id: 1, children: [{ id: 5, children: [{ id: 3 }] }] }, + { relate: true }, + ); + + expect(await selectNodes()).toEqual([ + { id: 1, parentId: null }, + { id: 2, parentId: null }, + { id: 3, parentId: 5 }, + { id: 5, parentId: 1 }, + ]); + }); + + it('should keep a row below a moved row that the graph keeps elsewhere', async () => { + await createTables(); + await knex('graph_order_node').insert([ + { id: 1, parentId: null }, + { id: 4, parentId: 1 }, + ]); + + await Node.query().upsertGraph( + [{ id: 4 }, { id: 9, children: [{ id: 1, children: [] }] }], + { relate: true, insertMissing: true }, + ); + + expect(await selectNodes()).toEqual([ + { id: 1, parentId: 9 }, + { id: 4, parentId: null }, + { id: 9, parentId: null }, + ]); + }); + + it('should keep the old owner of a row moved into a HasOneRelation', async () => { + await createTables(); + await insertPeople([{ id: 2, name: 'b', ownerId: 2 }]); + + await Person.query().upsertGraph( + { id: 1, onePet: { id: 2, owner: { id: 1 } } }, + { relate: true }, + ); + + expect(await knex('graph_order_person').orderBy('id').pluck('id')).toEqual([1, 2]); + expect(await selectPets()).toEqual([{ id: 2, name: 'b', ownerId: 1 }]); + }); + + it('should keep the old parent of a row moved in a tree', async () => { + await createTables(); + await knex('graph_order_node').insert([ + { id: 1, parentId: null }, + { id: 2, parentId: null }, + { id: 3, parentId: 2 }, + ]); + + await Node.query().upsertGraph( + { id: 1, children: [{ id: 3, parent: { id: 1 } }] }, + { relate: true }, + ); + + expect(await selectNodes()).toEqual([ + { id: 1, parentId: null }, + { id: 2, parentId: null }, + { id: 3, parentId: 1 }, + ]); + }); + }); + + it('should unrelate the rows of each owner without the rows moved between them', async () => { + await createTables(); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 2 }, + ]); + + // Pet 1 moves from person 1 to 2 through another relation: + await Person.query().upsertGraph( + [ + { id: 1, pets: [] }, + { id: 2, pets: [], petsNamedA: [{ id: 1 }] }, + ], + { relate: true, unrelate: true }, + ); + + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 2 }, + { id: 2, name: 'b', ownerId: null }, + ]); + }); + + it('should not unrelate a row that moves between owners with the unrelate option', async () => { + await createTables(); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 2 }, + ]); + + await Person.query().upsertGraph( + [ + { id: 1, pets: [{ id: 2 }] }, + { id: 2, pets: [] }, + ], + { relate: true, unrelate: true }, + ); + + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: null }, + { id: 2, name: 'b', ownerId: 1 }, + ]); + }); + + // Pet 4 lists no siblings, which leaves both pets out of its siblings, but + // the pets of person 2 keep them through the same key column: + it('should keep the rows that another relation on the same key leaves out', async () => { + await createTables(); + await insertPeople([ + { id: 3, name: 'c', ownerId: 2 }, + { id: 4, name: 'd', ownerId: 2 }, + ]); + + await Person.query().upsertGraph({ id: 2, pets: [{ id: 3 }, { id: 4, siblings: [] }] }); + + expect(await selectPets()).toEqual([ + { id: 3, name: 'c', ownerId: 2 }, + { id: 4, name: 'd', ownerId: 2 }, + ]); + }); + + describe('rows that read keys that the graph fills in', () => { + // Person 3 likes pet 10, which belongs to person 1. Pet 11 belongs to + // person 3. + beforeEach(async () => { + await createTables(); + await knex('graph_order_org').insert({ id: 1 }); + await knex('graph_order_person').insert([ + { id: 1, orgId: 1 }, + { id: 2, orgId: 1 }, + { id: 3, orgId: 1, favoritePetId: 10 }, + ]); + await knex('graph_order_pet').insert([ + { id: 10, name: 'a', ownerId: 1 }, + { id: 11, name: 'b', ownerId: 3 }, + ]); + }); + + // The siblings move first with the key of the owner of pet 10, before + // the removed fan and replaced owner cascade to their pets: + for (const ownerId of [1, 2]) { + it(`should move siblings first with the key of the owner ${ownerId === 1 ? 'that pet 10 keeps' : 'that replaces the owner of pet 10'}`, async () => { + await Pet.query().upsertGraph( + { id: 10, owner: { id: ownerId }, fans: [], siblings: [{ id: 10 }, { id: 11 }] }, + { relate: true }, + ); + + expect((await selectPets()).map(({ id, ownerId }) => ({ id, ownerId }))).toEqual([ + { id: 10, ownerId }, + { id: 11, ownerId }, + ]); + }); + } + + it('should resolve a #ref{} to a key that the graph fills in before moving', async () => { + await Person.query().upsertGraph( + [ + { id: 3, favoritePet: { id: 10, '#id': 'p', owner: { id: 2 } } }, + { id: 1, pets: [{ id: 11, name: '#ref{p.ownerId}' }] }, + ], + { relate: true, noDelete: true, allowRefs: true }, + ); + + expect(await selectPets()).toEqual([ + { id: 10, name: 'a', ownerId: 2 }, + { id: 11, name: '2', ownerId: 1 }, + ]); + }); + }); + + describe('unique foreign key of a HasOneRelation', () => { + it('should delete the current row before inserting its replacement', async () => { + await createTables({ uniqueOwner: true }); + await insertPeople([{ id: 1, name: 'a', ownerId: 1 }]); + + await Person.query().upsertGraph( + { id: 1, onePet: { id: 3, name: 'c' } }, + { insertMissing: true }, + ); + + expect(await selectPets()).toEqual([{ id: 3, name: 'c', ownerId: 1 }]); + }); + + it('should delete the current row before relating its replacement', async () => { + await createTables({ uniqueOwner: true }); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 2 }, + ]); + + await Person.query().upsertGraph({ id: 1, onePet: { id: 2 } }, { relate: true }); + + expect(await selectPets()).toEqual([{ id: 2, name: 'b', ownerId: 1 }]); + }); + + it('should move the current row to another owner before relating its replacement', async () => { + for (const reversed of [false, true]) { + await createTables({ uniqueOwner: true }); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: null }, + ]); + + // Pet 1 moves from person 1 to 2, and pet 2 into its slot: + const people = [ + { id: 1, onePet: { id: 2 } }, + { id: 2, onePet: { id: 1 } }, + ]; + await Person.query().upsertGraph(reversed ? people.reverse() : people, { + relate: true, + }); + + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 2 }, + { id: 2, name: 'b', ownerId: 1 }, + ]); + } + }); + + it('should relate a row into a slot after its current row moves into a slot that a delete frees', async () => { + for (const reversed of [false, true]) { + await createTables({ uniqueOwner: true }); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 2 }, + { id: 3, name: 'c', ownerId: null }, + ]); + + // Pet 2 is deleted, pet 1 moves into its slot, and pet 3 into pet 1's: + const people = [ + { id: 1, onePet: { id: 3 } }, + { id: 2, onePet: { id: 1 } }, + ]; + await Person.query().upsertGraph(reversed ? people.reverse() : people, { + relate: true, + }); + + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 2 }, + { id: 3, name: 'c', ownerId: 1 }, + ]); + } + }); + + it('should relate a row into a slot after its current row moves to a new owner', async () => { + for (const reversed of [false, true]) { + await createTables({ uniqueOwner: true }); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: null }, + ]); + + // Pet 1 moves to the new person 3, and pet 2 into its slot: + const people = [ + { id: 1, onePet: { id: 2 } }, + { id: 3, onePet: { id: 1 } }, + ]; + await Person.query().upsertGraph(reversed ? people.reverse() : people, { + relate: true, + insertMissing: true, + }); + + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 3 }, + { id: 2, name: 'b', ownerId: 1 }, + ]); + } + }); + + it('should insert a row into a slot after its current row moves to a new owner', async () => { + for (const reversed of [false, true]) { + await createTables({ uniqueOwner: true }); + await insertPeople([{ id: 1, name: 'a', ownerId: 1 }]); + + // Pet 1 moves to the new person 3, and the new pet 3 into its slot: + const people = [ + { id: 1, onePet: { id: 3, name: 'c' } }, + { id: 3, onePet: { id: 1 } }, + ]; + await Person.query().upsertGraph(reversed ? people.reverse() : people, { + relate: true, + insertMissing: true, + }); + + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 3 }, + { id: 3, name: 'c', ownerId: 1 }, + ]); + } + }); + + it('should insert a row into a slot after a chain of rows moves to a new owner', async () => { + for (const reversed of [false, true]) { + await createTables({ uniqueOwner: true }); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 2 }, + ]); + + // Pet 2 moves to the new person 3, pet 1 into its slot, and the new + // pet 3 into pet 1's: + const people = [ + { id: 1, onePet: { id: 3, name: 'c' } }, + { id: 2, onePet: { id: 1 } }, + { id: 3, onePet: { id: 2 } }, + ]; + await Person.query().upsertGraph(reversed ? people.reverse() : people, { + relate: true, + insertMissing: true, + }); + + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 2 }, + { id: 2, name: 'b', ownerId: 3 }, + { id: 3, name: 'c', ownerId: 1 }, + ]); + } + }); + + it('should relate a #ref into a slot after its current row moves to a new owner', async () => { + await createTables({ uniqueOwner: true }); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: null }, + ]); + Pet.delayedIds = [1]; + + try { + // Pet 1 moves to the new person 3, and pet 2 into its slot: + await Person.query().upsertGraph( + [ + { id: 1, onePet: { '#ref': 'b' } }, + { id: 3, onePet: { id: 1 } }, + { id: 2, likedPets: [{ '#id': 'b', id: 2 }] }, + ], + { relate: true, insertMissing: true, allowRefs: true }, + ); + } finally { + Pet.delayedIds = []; + } + + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 3 }, + { id: 2, name: 'b', ownerId: 1 }, + ]); + }); + + // Rows that move out of a slot below a row that moves to another owner + // first are related in a later pass, so they leave their slots by being + // unrelated before the rows that go into them (#398): + it('should write the rows of a slot whose row moves in a later pass', async () => { + for (const [people1, people2, expectedPets] of [ + // The new pet 3 goes into the slot of person 1, and pet 1 moves to + // person 2, which moves to org 2 first: + [ + [{ id: 1, onePet: { id: 3, name: 'c' } }], + [{ id: 2, onePet: { id: 1 } }], + [ + { id: 1, ownerId: 2 }, + { id: 3, ownerId: 1 }, + ], + ], + // The same, and pet 2 moves from person 2 to the new person 3: + [ + [{ id: 1, onePet: { id: 3, name: 'c' } }], + [ + { id: 2, onePet: { id: 1 } }, + { id: 3, onePet: { id: 2 } }, + ], + [ + { id: 1, ownerId: 2 }, + { id: 2, ownerId: 3 }, + { id: 3, ownerId: 1 }, + ], + ], + // Pet 2 is related into the slot of person 1 instead: + [ + [{ id: 1, onePet: { id: 2 } }], + [{ id: 2, onePet: { id: 1 } }], + [ + { id: 1, ownerId: 2 }, + { id: 2, ownerId: 1 }, + ], + ], + ]) { + await createTables({ uniqueOwner: true }); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 2 }, + ]); + await knex('graph_order_org').insert({ id: 2 }); + + await Org.query().upsertGraph( + [ + { id: 1, people: people1 }, + { id: 2, people: people2 }, + ], + { relate: true, insertMissing: true }, + ); + + const pets = await knex('graph_order_pet').select('id', 'ownerId').orderBy('id'); + expect(pets.filter((pet) => pet.ownerId !== null)).toEqual(expectedPets); + } + }); + + it('should keep a row in an emptied slot that a later pass moves', async () => { + await createTables({ uniqueOwner: true, requiredOwner: true }); + await insertPeople([{ id: 1, name: 'a', ownerId: 1 }]); + await knex('graph_order_org').insert({ id: 2 }); + + // Pet 1 moves to person 2, which moves to org 2 first, and nothing goes + // into its slot, so it isn't unrelated, which its key doesn't allow: + await Org.query().upsertGraph( + [ + { id: 1, people: [{ id: 1, onePet: null }] }, + { id: 2, people: [{ id: 2, onePet: { id: 1 } }] }, + ], + { relate: true }, + ); + + expect(await selectPets()).toEqual([{ id: 1, name: 'a', ownerId: 2 }]); + }); + + it('should move a chain of rows between the slots of owners', async () => { + for (const reversed of [false, true]) { + await createTables({ uniqueOwner: true }); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 2 }, + { id: 3, name: 'c', ownerId: null }, + ]); + await knex('graph_order_person').insert({ id: 3, orgId: 1 }); + + // Pet 2 moves from person 2 to 3, pet 1 from person 1 to 2, and pet 3 + // into the slot of person 1: + const people = [ + { id: 1, onePet: { id: 3 } }, + { id: 2, onePet: { id: 1 } }, + { id: 3, onePet: { id: 2 } }, + ]; + await Person.query().upsertGraph(reversed ? people.reverse() : people, { + relate: true, + }); + + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 2 }, + { id: 2, name: 'b', ownerId: 3 }, + { id: 3, name: 'c', ownerId: 1 }, + ]); + } + }); + }); + + // A row that moves out of the slot that a new row goes into, below that + // new row, can't move first. Without a unique key, both are written: + it('should insert a row into a slot that its current row moves out of below it', async () => { + await createTables(); + await insertPeople([{ id: 1, name: 'a', ownerId: 1 }]); + + await Person.query().upsertGraph( + { id: 1, onePet: { id: 3, name: 'c', fans: [{ id: 3, onePet: { id: 1 } }] } }, + { relate: true, insertMissing: true }, + ); + + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 3 }, + { id: 3, name: 'c', ownerId: 1 }, + ]); + expect(await knex('graph_order_person').where('id', 3).first()).toMatchObject({ + favoritePetId: 3, + }); + }); + + describe('unique key with the foreign key of a HasManyRelation', () => { + it('should delete a row before inserting a row with its key', async () => { + await createTables({ uniqueName: true }); + await insertPeople([{ id: 1, name: 'a', ownerId: 1 }]); + + await Person.query().upsertGraph( + { id: 1, pets: [{ id: 3, name: 'a' }] }, + { insertMissing: true }, + ); + + expect(await selectPets()).toEqual([{ id: 3, name: 'a', ownerId: 1 }]); + }); + + it('should delete a row before patching a kept row to its key', async () => { + await createTables({ uniqueName: true }); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 3, name: 'c', ownerId: 1 }, + ]); + + await Person.query().upsertGraph({ id: 1, pets: [{ id: 3, name: 'a' }] }); + + expect(await selectPets()).toEqual([{ id: 3, name: 'a', ownerId: 1 }]); + }); + + // Relates to existing owners run before the deletes, and objection + // doesn't know the unique keys to order them otherwise. + it('should fail to relate a row with the key of a row that it deletes', async () => { + await createTables({ uniqueName: true }); + await insertPeople([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 2 }, + { id: 3, name: 'b', ownerId: 1 }, + ]); + + await expect( + Person.query().upsertGraph({ id: 1, pets: [{ id: 1 }, { id: 2 }] }, { relate: true }), + ).rejects.toThrow(UniqueViolationError); + + expect(await selectPets()).toEqual([ + { id: 1, name: 'a', ownerId: 1 }, + { id: 2, name: 'b', ownerId: 2 }, + { id: 3, name: 'b', ownerId: 1 }, + ]); + }); + }); + }); +}; diff --git a/tests/integration/misc/upsertGraphQueries.js b/tests/integration/misc/upsertGraphQueries.js new file mode 100644 index 000000000..cfe80ef9e --- /dev/null +++ b/tests/integration/misc/upsertGraphQueries.js @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import { Model } from 'objection'; + +export default (session) => { + describe('upsertGraph() queries', () => { + const { knex } = session; + + class Pet extends Model { + static get tableName() { + return 'graph_queries_pet'; + } + + // Convert the tags without guarding against null, like the docs do: + $formatDatabaseJson(json) { + json = super.$formatDatabaseJson(json); + if ('tags' in json) { + json.tags = json.tags.join(','); + } + return json; + } + } + + class Person extends Model { + static get tableName() { + return 'graph_queries_person'; + } + + static get relationMappings() { + return { + pets: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: 'graph_queries_person.id', to: 'graph_queries_pet.ownerId' }, + }, + }; + } + } + + const dropTables = () => + knex.schema.dropTableIfExists('graph_queries_pet').dropTableIfExists('graph_queries_person'); + + beforeAll(async () => { + await dropTables(); + await knex.schema + .createTable('graph_queries_person', (table) => { + table.integer('id').primary(); + }) + .createTable('graph_queries_pet', (table) => { + table.integer('id').primary(); + table.string('name'); + table.integer('age'); + table.decimal('weight', 38, 4); + table.string('tags'); + table.integer('ownerId'); + }); + }); + + beforeEach(async () => { + await knex('graph_queries_pet').delete(); + await knex('graph_queries_person').delete(); + await knex('graph_queries_person').insert([{ id: 1 }, { id: 2 }]); + await knex('graph_queries_pet').insert([ + { id: 1, name: 'a', age: 1, ownerId: 1 }, + { id: 2, name: 'b', age: 2, ownerId: 1 }, + { id: 3, name: 'c', age: 3, ownerId: 2 }, + { id: 4, name: 'd', age: 4, ownerId: 2 }, + ]); + }); + + afterAll(dropTables); + + const collectQueries = async (callback) => { + const queries = []; + const onQuery = ({ sql }) => queries.push(sql.toLowerCase()); + knex.on('query', onQuery); + + try { + await callback(); + } finally { + knex.removeListener('query', onQuery); + } + + return queries; + }; + + const selectPets = () => knex('graph_queries_pet').select('id', 'name', 'age').orderBy('id'); + + // A statement that picks the values of several rows converts them to one + // type on MySQL and SQL Server. SQLite stores decimals as floats. + it.skipIf(session.isSqlite())( + 'should leave the columns that a patch does not change untouched', + async () => { + const weight = '12345678901234567.1234'; + await knex('graph_queries_pet').whereIn('id', [1, 2]).update({ weight }); + + await Person.query(knex).upsertGraph({ + id: 1, + pets: [ + { id: 1, weight: 1e-7 }, + { id: 2, name: 'x' }, + ], + }); + + const rows = knex('graph_queries_pet').where( + 'weight', + knex.raw('cast(? as decimal(38, 4))', [weight]), + ); + expect(await rows.pluck('id')).toEqual([2]); + }, + ); + + it('should format the values of each row on its own', async () => { + await Person.query(knex).upsertGraph({ + id: 1, + pets: [ + { id: 1, tags: ['x', 'y'] }, + { id: 2, name: 'x' }, + ], + }); + + expect(await knex('graph_queries_pet').whereIn('id', [1, 2]).pluck('tags')).toEqual([ + 'x,y', + null, + ]); + }); + + // SQL Server binds at most 2100 values per query. + it('should patch and delete more rows than one query can bind', async () => { + const pets = Array.from({ length: 4200 }, (_, i) => ({ id: 10 + i, name: 'p', ownerId: 1 })); + + for (let i = 0; i < pets.length; i += 100) { + await knex('graph_queries_pet').insert(pets.slice(i, i + 100)); + } + + await Person.query(knex).upsertGraph({ + id: 1, + pets: pets.slice(0, 2100).map(({ id }) => ({ id, name: 'q' })), + }); + + const rows = await knex('graph_queries_pet').where('ownerId', 1); + expect(rows).toHaveLength(2100); + expect(rows.every((row) => row.name === 'q')).toBe(true); + }); + + it('should delete the removed rows of a relation in one query', async () => { + const queries = await collectQueries(() => + Person.query(knex).upsertGraph([ + { id: 1, pets: [{ id: 1 }] }, + { id: 2, pets: [{ id: 3 }] }, + ]), + ); + + expect(queries.filter((sql) => sql.startsWith('delete'))).toHaveLength(1); + expect((await selectPets()).map((pet) => pet.id)).toEqual([1, 3]); + }); + }); +}; diff --git a/tests/integration/misc/upsertGraphRefCycles.js b/tests/integration/misc/upsertGraphRefCycles.js new file mode 100644 index 000000000..124d34067 --- /dev/null +++ b/tests/integration/misc/upsertGraphRefCycles.js @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import { Model } from 'objection'; + +export default (session) => { + describe('upsertGraph() #ref cycles through existing models', () => { + const { knex } = session; + + class Person extends Model { + static get tableName() { + return 'graph_ref_cycle_person'; + } + + static get relationMappings() { + return { + boss: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { from: 'graph_ref_cycle_person.bossId', to: 'graph_ref_cycle_person.id' }, + }, + // Joined through a column that isn't the id: + mentor: { + relation: Model.BelongsToOneRelation, + modelClass: Person, + join: { + from: 'graph_ref_cycle_person.mentorName', + to: 'graph_ref_cycle_person.name', + }, + }, + }; + } + } + + beforeAll(async () => { + await knex.schema.dropTableIfExists('graph_ref_cycle_person'); + await knex.schema.createTable('graph_ref_cycle_person', (table) => { + table.increments('id'); + table.string('name'); + table.integer('bossId'); + table.string('mentorName'); + }); + }); + + beforeEach(async () => { + await knex('graph_ref_cycle_person').truncate(); + + for (const name of ['a', 'b']) { + await knex('graph_ref_cycle_person').insert({ name }); + } + }); + + afterAll(() => knex.schema.dropTableIfExists('graph_ref_cycle_person')); + + const getPerson = (id) => knex('graph_ref_cycle_person').where('id', id).first(); + + it('should relate a model to itself', async () => { + await Person.query(knex).upsertGraph( + { id: 1, '#id': 'a', boss: { '#ref': 'a' } }, + { allowRefs: true, relate: true }, + ); + + expect((await getPerson(1)).bossId).toBe(1); + }); + + it('should relate existing models to each other', async () => { + await Person.query(knex).upsertGraph( + { id: 1, '#id': 'a', boss: { id: 2, boss: { '#ref': 'a' } } }, + { allowRefs: true, relate: true }, + ); + + expect((await getPerson(1)).bossId).toBe(2); + expect((await getPerson(2)).bossId).toBe(1); + }); + + // The key that the relation reads from the referenced model is filled in by + // the graph, so the `#ref` still waits for it: + it('should read the key of a #ref once the graph fills it in', async () => { + await Person.query(knex).upsertGraph( + [ + { '#id': 'a', id: 1, name: 'renamed #ref{n.name}' }, + { id: 2, mentor: { '#ref': 'a' } }, + { '#id': 'n', name: 'new' }, + ], + { allowRefs: true, insertMissing: true }, + ); + + expect((await getPerson(2)).mentorName).toBe('renamed new'); + }); + }); +}; diff --git a/tests/integration/misc/upsertGraphRefManyToMany.js b/tests/integration/misc/upsertGraphRefManyToMany.js new file mode 100644 index 000000000..e6f7e7761 --- /dev/null +++ b/tests/integration/misc/upsertGraphRefManyToMany.js @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import { Model } from 'objection'; + +export default (session) => { + describe('upsertGraph() #ref to an existing model in a ManyToManyRelation', () => { + const { knex } = session; + + class Pet extends Model { + static get tableName() { + return 'graph_ref_m2m_pet'; + } + } + + class Person extends Model { + static get tableName() { + return 'graph_ref_m2m_person'; + } + + static get relationMappings() { + return { + children: { + relation: Model.HasManyRelation, + modelClass: Person, + join: { from: 'graph_ref_m2m_person.id', to: 'graph_ref_m2m_person.parentId' }, + }, + pets: { + relation: Model.HasManyRelation, + modelClass: Pet, + join: { from: 'graph_ref_m2m_person.id', to: 'graph_ref_m2m_pet.ownerId' }, + }, + friends: { + relation: Model.ManyToManyRelation, + modelClass: Person, + join: { + from: 'graph_ref_m2m_person.id', + through: { + from: 'graph_ref_m2m_friend.aId', + to: 'graph_ref_m2m_friend.bId', + extra: ['role'], + }, + to: 'graph_ref_m2m_person.id', + }, + }, + }; + } + } + + const dropTables = () => + knex.schema + .dropTableIfExists('graph_ref_m2m_friend') + .dropTableIfExists('graph_ref_m2m_pet') + .dropTableIfExists('graph_ref_m2m_person'); + + beforeAll(async () => { + await dropTables(); + await knex.schema + .createTable('graph_ref_m2m_person', (table) => { + table.integer('id').primary(); + table.integer('parentId'); + }) + .createTable('graph_ref_m2m_pet', (table) => { + table.integer('id').primary(); + table.integer('ownerId'); + }) + .createTable('graph_ref_m2m_friend', (table) => { + table.integer('aId'); + table.integer('bId'); + table.string('role'); + }); + }); + + beforeEach(async () => { + for (const table of ['graph_ref_m2m_friend', 'graph_ref_m2m_pet', 'graph_ref_m2m_person']) { + await knex(table).delete(); + } + + await knex('graph_ref_m2m_person').insert([{ id: 1 }, { id: 2, parentId: 1 }, { id: 3 }]); + await knex('graph_ref_m2m_pet').insert([{ id: 10, ownerId: 2 }]); + }); + + afterAll(dropTables); + + it('should relate a #ref with relations to a model that the graph patches elsewhere', async () => { + await Person.query(knex).upsertGraph( + { + id: 1, + children: [{ '#id': 'child', id: 2 }], + friends: [{ '#ref': 'child', pets: [{ id: 10 }] }], + }, + { relate: true, allowRefs: true }, + ); + + expect(await knex('graph_ref_m2m_friend').select('aId', 'bId')).toEqual([{ aId: 1, bId: 2 }]); + expect(await knex('graph_ref_m2m_pet')).toEqual([{ id: 10, ownerId: 2 }]); + }); + + it('should write the extras of a join row that a #ref relates again with them', async () => { + await knex('graph_ref_m2m_friend').insert({ aId: 2, bId: 3, role: 'best' }); + + await Person.query(knex).upsertGraph( + { + id: 1, + children: [{ id: 2, friends: [{ '#ref': 'friend', role: 'worst' }] }], + friends: [{ id: 3, '#id': 'friend' }], + }, + { relate: true, allowRefs: true }, + ); + + expect(await knex('graph_ref_m2m_friend').where('aId', 2)).toEqual([ + { aId: 2, bId: 3, role: 'worst' }, + ]); + }); + + it('should keep the extras of the join row of a model out of its #ref in another relation', async () => { + await Person.query(knex).upsertGraph( + { + id: 1, + friends: [{ id: 3, '#id': 'friend', role: 'x' }], + children: [{ '#ref': 'friend' }], + }, + { relate: true, allowRefs: true }, + ); + + expect(await knex('graph_ref_m2m_friend')).toEqual([{ aId: 1, bId: 3, role: 'x' }]); + expect((await knex('graph_ref_m2m_person').where('id', 3).first()).parentId).toBe(1); + }); + + it('should keep the extras of the join row of a model out of the join row of its #ref', async () => { + await Person.query(knex).upsertGraph( + { + id: 1, + friends: [{ id: 3, '#id': 'friend', role: 'x' }], + children: [{ id: 2, friends: [{ '#ref': 'friend' }] }], + }, + { relate: true, allowRefs: true }, + ); + + expect(await knex('graph_ref_m2m_friend').orderBy('aId')).toEqual([ + { aId: 1, bId: 3, role: 'x' }, + { aId: 2, bId: 3, role: null }, + ]); + }); + + it('should keep the extras of a join row that a #ref relates again', async () => { + await knex('graph_ref_m2m_friend').insert({ aId: 2, bId: 3, role: 'best' }); + + await Person.query(knex).upsertGraph( + { + id: 1, + children: [{ id: 2, friends: [{ '#ref': 'friend' }] }], + friends: [{ id: 3, '#id': 'friend' }], + }, + { relate: true, allowRefs: true }, + ); + + expect(await knex('graph_ref_m2m_friend').orderBy('aId')).toEqual([ + { aId: 1, bId: 3, role: null }, + { aId: 2, bId: 3, role: 'best' }, + ]); + }); + }); +}; diff --git a/tests/integration/misc/usingUnboundModelsByPassingKnex.js b/tests/integration/misc/usingUnboundModelsByPassingKnex.js index 0df98f164..d6cbf95c2 100644 --- a/tests/integration/misc/usingUnboundModelsByPassingKnex.js +++ b/tests/integration/misc/usingUnboundModelsByPassingKnex.js @@ -1,8 +1,8 @@ -const expect = require('expect.js'); -const { createRejectionReflection, sortBy } = require('../../../testUtils/testUtils'); -const { try: promiseTry } = require('../../../lib/utils/promiseUtils'); +import { describe, it, expect, beforeEach } from 'vitest'; +import { createRejectionReflection, sortBy } from '../../../testUtils/testUtils.js'; +import { try as promiseTry } from '../../../lib/utils/promiseUtils/index.js'; -module.exports = (session) => { +export default (session) => { describe('using unbound models by passing a knex to query', () => { let Model1 = session.unboundModels.Model1; let Model2 = session.unboundModels.Model2; @@ -80,7 +80,7 @@ module.exports = (session) => { const query = Model1.query().orWhereNot('id', '>', 10).whereIn('id', [1, 8, 11]); return query.knex(session.knex).then((models) => { - expect(models[0].model1Prop1).to.equal('hello 1'); + expect(models[0].model1Prop1).toBe('hello 1'); }); }); @@ -88,7 +88,7 @@ module.exports = (session) => { const query = Model1.query().findById(1); return query.knex(session.knex).then((model) => { - expect(model.model1Prop1).to.equal('hello 1'); + expect(model.model1Prop1).toBe('hello 1'); }); }); @@ -102,7 +102,7 @@ module.exports = (session) => { const query = TestModel.query().findById([1, 'hello 1']); return query.knex(session.knex).then((model) => { - expect(model.model1Prop1).to.equal('hello 1'); + expect(model.model1Prop1).toBe('hello 1'); }); }); @@ -129,7 +129,7 @@ module.exports = (session) => { ), ]).then((results) => { results.forEach((models) => { - expect(sortRelations(models)).to.eql({ + expect(sortRelations(models)).toEqual({ id: 1, model1Id: 2, model1Prop1: 'hello 1', @@ -210,7 +210,7 @@ module.exports = (session) => { const query = Model1.query().whereIn('id', Model1.query().select('id').where('id', 5)); return query.knex(session.knex).then((models) => { - expect(models[0].model1Prop1).to.equal('hello 5'); + expect(models[0].model1Prop1).toBe('hello 5'); }); }); @@ -224,7 +224,7 @@ module.exports = (session) => { ); return query.knex(session.knex).then((models) => { - expect(models[0].id).to.equal(3); + expect(models[0].id).toBe(3); }); }); @@ -234,7 +234,7 @@ module.exports = (session) => { .select('Model1.*', Model1.relatedQuery('model1Relation2').count().as('count')); return query.knex(session.knex).then((model) => { - expect(model.count).to.eql(2); + expect(Number(model.count)).toBe(2); }); }); }); @@ -266,7 +266,7 @@ module.exports = (session) => { return model.$relatedQuery('model2Relation1', session.knex); }), ]).then((results) => { - expect(results[0]).to.eql({ + expect(results[0]).toEqual({ id: 2, model1Id: 3, model1Prop1: 'hello 2', @@ -274,7 +274,7 @@ module.exports = (session) => { $afterFindCalled: 1, }); - expect(results[1]).to.eql({ + expect(results[1]).toEqual({ id: 1, model1Id: 2, model1Prop1: 'hello 1', @@ -282,7 +282,7 @@ module.exports = (session) => { $afterFindCalled: 1, }); - expect(sortBy(results[2], 'idCol')).to.eql([ + expect(sortBy(results[2], 'idCol')).toEqual([ { idCol: 1, model1Id: 1, @@ -299,7 +299,7 @@ module.exports = (session) => { }, ]); - expect(sortBy(results[3], 'id')).to.eql([ + expect(sortBy(results[3], 'id')).toEqual([ { id: 5, model1Id: null, @@ -330,7 +330,7 @@ module.exports = (session) => { return model.$query(session.knex); }), ]).then((model) => { - expect(model).to.eql([ + expect(model).toEqual([ { id: 1, model1Id: 2, @@ -347,7 +347,7 @@ module.exports = (session) => { .$query(session.knex) .insert() .then((model) => { - expect(model).to.eql({ + expect(model).toEqual({ id: 100, model1Prop1: 'foo', $afterInsertCalled: 1, @@ -361,7 +361,7 @@ module.exports = (session) => { .$query(session.knex) .insertAndFetch() .then((model) => { - expect(model).to.eql({ + expect(model).toEqual({ id: 101, model1Id: null, model1Prop1: 'foo', @@ -378,7 +378,7 @@ module.exports = (session) => { .select('Model1.id as id', 'model1Relation1.id as relId') .innerJoinRelated('model1Relation1') .then((models) => { - expect(sortBy(models, 'id')).to.eql([ + expect(sortBy(models, 'id')).toEqual([ { id: 1, relId: 2, $afterFindCalled: 1 }, { id: 2, relId: 3, $afterFindCalled: 1 }, { id: 3, relId: 4, $afterFindCalled: 1 }, @@ -392,7 +392,7 @@ module.exports = (session) => { .select('Model1.id as id', 'model1Relation3.id_col as relId') .innerJoinRelated('model1Relation3') .then((models) => { - expect(sortBy(models, 'id')).to.eql([ + expect(sortBy(models, 'id')).toEqual([ { id: 5, relId: 2, $afterFindCalled: 1 }, { id: 6, relId: 2, $afterFindCalled: 1 }, ]); @@ -470,8 +470,8 @@ module.exports = (session) => { }).catch((err) => createRejectionReflection(err)), ]).then((results) => { results.forEach((result) => { - expect(result.isRejected()).to.equal(true); - expect(result.reason().message).to.match( + expect(result.isRejected()).toBe(true); + expect(result.reason().message).toMatch( /no database connection available for a query. You need to bind the model class or the query to a knex instance./, ); }); diff --git a/tests/integration/misc/zeroValueInRelationColumn.js b/tests/integration/misc/zeroValueInRelationColumn.js index 475a00876..b89be5161 100644 --- a/tests/integration/misc/zeroValueInRelationColumn.js +++ b/tests/integration/misc/zeroValueInRelationColumn.js @@ -1,12 +1,12 @@ -const expect = require('expect.js'); -const { Model } = require('../../../'); +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('zero value in relation column', () => { let Table1; let Table2; - before(async () => { + beforeAll(async () => { await session.knex.schema.dropTableIfExists('table1').dropTableIfExists('table2'); await session.createTable('table1', (table, { increments }) => { increments('id'); @@ -18,14 +18,14 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return Promise.all([ session.knex.schema.dropTableIfExists('table1'), session.knex.schema.dropTableIfExists('table2'), ]); }); - before(() => { + beforeAll(() => { Table1 = class Table1 extends Model { static get tableName() { return 'table1'; @@ -55,7 +55,7 @@ module.exports = (session) => { Table2.knex(session.knex); }); - before(() => { + beforeAll(() => { return Promise.all([ Table1.query().insert({ id: 1, value: 0 }), Table1.query().insert({ id: 2, value: 1 }), @@ -71,7 +71,7 @@ module.exports = (session) => { return model.$relatedQuery('relation'); }) .then((models) => { - expect(models).to.eql([{ id: 1, value: 0 }]); + expect(models).toEqual([{ id: 1, value: 0 }]); }); }); }); diff --git a/tests/integration/modifiers.js b/tests/integration/modifiers.js index 5028014d0..ac72e91c9 100644 --- a/tests/integration/modifiers.js +++ b/tests/integration/modifiers.js @@ -1,7 +1,7 @@ -const { Model } = require('../../'); -const expect = require('chai').expect; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('modifiers', () => { class Person extends Model { static get tableName() { @@ -72,7 +72,7 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('personMovie') .dropTableIfExists('animal') @@ -99,7 +99,7 @@ module.exports = (session) => { }); }); - before(() => { + beforeAll(() => { Person.knex(session.knex); Animal.knex(session.knex); Movie.knex(session.knex); @@ -170,11 +170,11 @@ module.exports = (session) => { }); for (const movie of arnold.movies) { - expect(movie.stars).to.be.greaterThan(2); + expect(movie.stars).toBeGreaterThan(2); } - expect(arnold.pets.length).to.equal(1); - expect(arnold.pets[0].name).to.equal('Stalin'); + expect(arnold.pets.length).toBe(1); + expect(arnold.pets[0].name).toBe('Stalin'); }); it('joinEager', async () => { @@ -191,11 +191,11 @@ module.exports = (session) => { }); for (const movie of arnold.movies) { - expect(movie.stars).to.be.greaterThan(2); + expect(movie.stars).toBeGreaterThan(2); } - expect(arnold.pets.length).to.equal(1); - expect(arnold.pets[0].name).to.equal('Stalin'); + expect(arnold.pets.length).toBe(1); + expect(arnold.pets[0].name).toBe('Stalin'); }); it('joinRelated', async () => { @@ -213,13 +213,13 @@ module.exports = (session) => { }) .orderBy(['person.name', 'movies.name', 'pets.name']); - expect(result).to.eql([ + expect(result).toEqual([ { name: 'Arnold', movieName: 'Terminator', petName: 'Stalin' }, { name: 'Arnold', movieName: 'Terminator 2', petName: 'Stalin' }, ]); }); - after(() => { + afterAll(() => { return session.knex.schema .dropTableIfExists('personMovie') .dropTableIfExists('animal') diff --git a/tests/integration/modifyJoin.js b/tests/integration/modifyJoin.js new file mode 100644 index 000000000..3576cadc3 --- /dev/null +++ b/tests/integration/modifyJoin.js @@ -0,0 +1,351 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { sortBy } from '../../testUtils/testUtils.js'; + +export default (session) => { + const { Model1 } = session.models; + + describe('modifyJoin()', () => { + beforeAll(() => { + return session.populate([ + { + id: 1, + model1Prop2: 10, + + model1Relation1: { + id: 2, + model1Prop2: 5, + + model1Relation2: [ + { idCol: 5, model2Prop2: 1 }, + { idCol: 6, model2Prop2: 9 }, + ], + }, + + model1Relation2: [ + { idCol: 1, model2Prop2: 5 }, + { idCol: 2, model2Prop2: 20 }, + ], + + model1Relation3: [ + { idCol: 3, extra1: 'x' }, + { idCol: 4, extra1: 'y' }, + ], + }, + ]); + }); + + // Keep the related models whose `model2Prop2` is greater than the + // `model1Prop2` of their owner. + const greaterThanOwner = (join, { owner, related }) => { + join.andOn(`${related}.model2_prop2`, '>', `${owner}.model1Prop2`); + }; + + const getIds = (models) => models.map((it) => it.idCol).sort(); + + describe('joinRelated()', () => { + it('should modify the join of a has many relation', async () => { + const aliases = []; + const models = await Model1.query() + .select('model1Relation2.id_col as idCol') + .joinRelated('model1Relation2') + .modifyJoin('model1Relation2', (join, args) => { + aliases.push(args); + greaterThanOwner(join, args); + }) + .where('Model1.id', 1); + + expect(aliases).toEqual([{ owner: 'Model1', related: 'model1Relation2' }]); + expect(getIds(models)).toEqual([2]); + }); + + it('should modify the join of a belongs to one relation', async () => { + const models = await Model1.query() + .select('Model1.id', 'model1Relation1.id as relatedId') + .leftJoinRelated('model1Relation1') + .modifyJoin('model1Relation1', (join, { owner, related }) => { + join.andOn(`${related}.model1Prop2`, '>', `${owner}.model1Prop2`); + }) + .where('Model1.id', 1); + + expect(models.map((it) => it.relatedId)).toEqual([null]); + }); + + it('should modify the join of a many to many relation with the join table alias', async () => { + const aliases = []; + const models = await Model1.query() + .select('model1Relation3.id_col as idCol') + .joinRelated('model1Relation3') + .modifyJoin('model1Relation3', (join, args) => { + aliases.push(args); + join.andOnVal(`${args.joinTable}.extra1`, 'x'); + }) + .where('Model1.id', 1); + + expect(aliases).toEqual([ + { owner: 'Model1', related: 'model1Relation3', joinTable: 'model1Relation3_join' }, + ]); + expect(getIds(models)).toEqual([3]); + }); + + it('should leave out the join table rows that a many to many modifier excludes in outer joins', async () => { + const models = await Model1.query() + .select('model1Relation3.id_col as idCol') + .leftJoinRelated('model1Relation3') + .modifyJoin('model1Relation3', (join, { joinTable }) => { + join.andOnVal(`${joinTable}.extra1`, 'x'); + }) + .where('Model1.id', 1); + + expect(models.map((it) => it.idCol)).toEqual([3]); + }); + + it('should keep the owners without matching rows of a modified many to many relation in outer joins', async () => { + const models = await Model1.query() + .select('Model1.id', 'model1Relation3.id_col as idCol') + .leftJoinRelated('model1Relation3(lowIds)') + .modifiers({ lowIds: (query) => query.where('id_col', '<', 4) }) + .modifyJoin('model1Relation3', (join, { joinTable }) => { + join.andOnVal(`${joinTable}.extra1`, 'y'); + }) + .whereIn('Model1.id', [1, 2]) + .orderBy('Model1.id'); + + expect(models.map(({ id, idCol }) => ({ id, idCol }))).toEqual([ + { id: 1, idCol: null }, + { id: 2, idCol: null }, + ]); + }); + + it('should keep the related rows without join table rows in right joins of a modified many to many relation', async () => { + const models = await Model1.query() + .select('Model1.id', 'model1Relation3.id_col as idCol') + .rightJoinRelated('model1Relation3') + .modifyJoin('model1Relation3', (join, { joinTable }) => { + join.andOnVal(`${joinTable}.extra1`, 'x'); + }); + + expect(sortBy(models, ['idCol']).map(({ id, idCol }) => ({ id, idCol }))).toEqual([ + { id: null, idCol: 1 }, + { id: null, idCol: 2 }, + { id: 1, idCol: 3 }, + { id: null, idCol: 4 }, + { id: null, idCol: 5 }, + { id: null, idCol: 6 }, + ]); + }); + + it('should group the conditions of the modifiers', async () => { + const models = await Model1.query() + .select('Model1.id', 'model1Relation2.id_col as idCol') + .joinRelated('model1Relation2') + .modifyJoin('model1Relation2', (join, { related }) => { + join.onVal(`${related}.model2_prop2`, 5).orOnVal(`${related}.model2_prop2`, 1); + }); + + expect(sortBy(models, ['idCol']).map(({ id, idCol }) => ({ id, idCol }))).toEqual([ + { id: 1, idCol: 1 }, + { id: 2, idCol: 5 }, + ]); + }); + + it('should group the conditions of the modifiers of a many to many relation', async () => { + const models = await Model1.query() + .select('Model1.id', 'model1Relation3.id_col as idCol') + .joinRelated('model1Relation3') + .modifyJoin('model1Relation3', (join, { joinTable }) => { + join.onVal(`${joinTable}.extra1`, 'x').orOnVal(`${joinTable}.extra1`, 'y'); + }); + + expect(sortBy(models, ['idCol']).map(({ id, idCol }) => ({ id, idCol }))).toEqual([ + { id: 1, idCol: 3 }, + { id: 1, idCol: 4 }, + ]); + }); + + it('should accept modifiers that add no conditions', async () => { + const models = await Model1.query() + .select('model1Relation3.id_col as idCol') + .joinRelated('[model1Relation2, model1Relation3]') + .modifyJoin('[model1Relation2, model1Relation3]', () => {}) + .where('model1Relation2.id_col', 1); + + expect(getIds(models)).toEqual([3, 4]); + }); + + it('should match the paths by the names of the aliases option', async () => { + const aliases = []; + const models = await Model1.query() + .select('m.id_col as idCol') + .joinRelated('model1Relation2', { aliases: { model1Relation2: 'm' } }) + .modifyJoin('m', (join, args) => { + aliases.push(args); + greaterThanOwner(join, args); + }) + .where('Model1.id', 1); + + expect(aliases).toEqual([{ owner: 'Model1', related: 'm' }]); + expect(getIds(models)).toEqual([2]); + }); + + it('should modify the joins of nested relations only', async () => { + const aliases = []; + const models = await Model1.query() + .select('model1Relation1:model1Relation2.id_col as idCol') + .joinRelated('[model1Relation2, model1Relation1.model1Relation2]') + .modifyJoin('model1Relation1.model1Relation2', (join, args) => { + aliases.push(args); + join.andOnVal(`${args.related}.model2_prop2`, '>', 5); + }) + .where('Model1.id', 1); + + expect(aliases).toEqual([ + { owner: 'model1Relation1', related: 'model1Relation1:model1Relation2' }, + ]); + // Two `model1Relation2` rows times one nested row: + expect(getIds(models)).toEqual([6, 6]); + }); + + it('should match the paths by the aliases of the relations', async () => { + const aliases = []; + const models = await Model1.query() + .select('m.id_col as idCol') + .joinRelated('model1Relation2 as m') + .modifyJoin('m', (join, args) => { + aliases.push(args); + greaterThanOwner(join, args); + }) + .modifyJoin('model1Relation2', () => { + throw new Error('should not be called'); + }) + .where('Model1.id', 1); + + expect(aliases).toEqual([{ owner: 'Model1', related: 'm' }]); + expect(getIds(models)).toEqual([2]); + }); + + it('should call all modifiers of a path', async () => { + const models = await Model1.query() + .select('model1Relation2.id_col as idCol') + .joinRelated('model1Relation2') + .modifyJoin('[model1Relation1, model1Relation2]', greaterThanOwner) + .modifyJoin('model1Relation2', (join, { related }) => { + join.andOnVal(`${related}.model2_prop2`, '<', 10); + }) + .where('Model1.id', 1); + + expect(models).toEqual([]); + }); + }); + + describe('withGraphJoined()', () => { + it('should modify the join of a has many relation', async () => { + const aliases = []; + const model = await Model1.query() + .withGraphJoined('model1Relation2') + .modifyJoin('model1Relation2', (join, args) => { + aliases.push(args); + greaterThanOwner(join, args); + }) + .findById(1); + + expect(aliases).toEqual([{ owner: 'Model1', related: 'model1Relation2' }]); + expect(getIds(model.model1Relation2)).toEqual([2]); + }); + + it('should modify the join of a belongs to one relation', async () => { + const model = await Model1.query() + .withGraphJoined('model1Relation1') + .modifyJoin('model1Relation1', (join, { owner, related }) => { + join.andOn(`${related}.model1Prop2`, '>', `${owner}.model1Prop2`); + }) + .findById(1); + + expect(model.model1Relation1).toBe(null); + }); + + it('should modify the join of a many to many relation with the join table alias', async () => { + const aliases = []; + const model = await Model1.query() + .withGraphJoined('model1Relation3') + .modifyJoin('model1Relation3', (join, args) => { + aliases.push(args); + join.andOnVal(`${args.joinTable}.extra1`, 'x'); + }) + .findById(1); + + expect(aliases).toEqual([ + { owner: 'Model1', related: 'model1Relation3', joinTable: 'model1Relation3_join' }, + ]); + expect(getIds(model.model1Relation3)).toEqual([3]); + }); + + it('should select the join table extras of a modified many to many relation', async () => { + const model = await Model1.query() + .withGraphJoined('model1Relation3(lowIds)') + .modifiers({ lowIds: (query) => query.where('id_col', '<', 4) }) + .modifyJoin('model1Relation3', (join, { joinTable }) => { + join.andOnVal(`${joinTable}.extra1`, 'x'); + }) + .findById(1); + + expect(model.model1Relation3.map(({ idCol, extra1 }) => ({ idCol, extra1 }))).toEqual([ + { idCol: 3, extra1: 'x' }, + ]); + }); + + it('should match the paths by the relation names with the aliases option', async () => { + const aliases = []; + const model = await Model1.query() + .withGraphJoined('model1Relation2', { aliases: { model1Relation2: 'm' } }) + .modifyJoin('model1Relation2', (join, args) => { + aliases.push(args); + greaterThanOwner(join, args); + }) + .findById(1); + + expect(aliases).toEqual([{ owner: 'Model1', related: 'm' }]); + expect(getIds(model.model1Relation2)).toEqual([2]); + }); + + it('should modify the joins of nested relations only', async () => { + const model = await Model1.query() + .withGraphJoined('[model1Relation2, model1Relation1.model1Relation2]') + .modifyJoin('model1Relation1.model1Relation2', greaterThanOwner) + .findById(1); + + expect(getIds(model.model1Relation2)).toEqual([1, 2]); + expect(getIds(model.model1Relation1.model1Relation2)).toEqual([6]); + }); + + it('should match the paths by the aliases of the relations', async () => { + const model = await Model1.query() + .withGraphJoined('model1Relation2 as m') + .modifyJoin('m', greaterThanOwner) + .findById(1); + + expect(getIds(model.m)).toEqual([2]); + }); + + it('should not modify withGraphFetched()', async () => { + const model = await Model1.query() + .withGraphFetched('model1Relation2') + .modifyJoin('model1Relation2', () => { + throw new Error('should not be called'); + }) + .findById(1); + + expect(getIds(model.model1Relation2)).toEqual([1, 2]); + }); + }); + + it('should be kept by clone()', async () => { + const query = Model1.query() + .select('model1Relation2.id_col as idCol') + .joinRelated('model1Relation2') + .modifyJoin('model1Relation2', greaterThanOwner) + .where('Model1.id', 1); + + expect(getIds(await query.clone())).toEqual([2]); + }); + }); +}; diff --git a/tests/integration/nonPrimaryKeyRelations.js b/tests/integration/nonPrimaryKeyRelations.js index 3e8d3db70..9e5a4e6b9 100644 --- a/tests/integration/nonPrimaryKeyRelations.js +++ b/tests/integration/nonPrimaryKeyRelations.js @@ -1,8 +1,8 @@ -const { Model, raw } = require('../../'); -const { expect } = require('chai'); -const { sortBy } = require('../../testUtils/testUtils'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model, raw } from 'objection'; +import { sortBy } from '../../testUtils/testUtils.js'; -module.exports = (session) => { +export default (session) => { describe("relations that don't use the primary keys", () => { class Person extends Model { static get tableName() { @@ -72,7 +72,7 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('personMovie') .dropTableIfExists('animal') @@ -101,7 +101,7 @@ module.exports = (session) => { }); }); - before(() => { + beforeAll(() => { Person.knex(session.knex); PersonMovie.knex(session.knex); Animal.knex(session.knex); @@ -159,7 +159,7 @@ module.exports = (session) => { it('find', () => { return findArnold() .then((arnold) => arnold.$relatedQuery('parent')) - .then((gustav) => expect(gustav.name).to.eql('Gustav')); + .then((gustav) => expect(gustav.name).toBe('Gustav')); }); it('update', () => { @@ -167,9 +167,9 @@ module.exports = (session) => { .then((arnold) => { return arnold.$relatedQuery('parent').update({ nickname: 'Gus' }); }) - .then((numUpdated) => expect(numUpdated).to.equal(1)) + .then((numUpdated) => expect(numUpdated).toBe(1)) .then(findGustav) - .then((gustav) => expect(gustav.nickname).to.equal('Gus')); + .then((gustav) => expect(gustav.nickname).toBe('Gus')); }); it('delete', () => { @@ -177,9 +177,9 @@ module.exports = (session) => { .then((arnold) => { return arnold.$relatedQuery('parent').delete(); }) - .then((numDeleted) => expect(numDeleted).to.equal(1)) + .then((numDeleted) => expect(numDeleted).toBe(1)) .then(findGustav) - .then((gustav) => expect(gustav).to.equal(undefined)); + .then((gustav) => expect(gustav).toBeUndefined()); }); it('insert', () => { @@ -189,9 +189,9 @@ module.exports = (session) => { }) .then(findArnold) .then((arnold) => arnold.$relatedQuery('parent')) - .then((gustavNeue) => expect(gustavNeue.name).to.equal('Gustav-neue')) + .then((gustavNeue) => expect(gustavNeue.name).toBe('Gustav-neue')) .then(findGustav) - .then((gustav) => expect(gustav.name).to.equal('Gustav')); + .then((gustav) => expect(gustav.name).toBe('Gustav')); }); it('relate', () => { @@ -201,9 +201,9 @@ module.exports = (session) => { }) .then(findArnold) .then((arnold) => arnold.$relatedQuery('parent')) - .then((meinhard) => expect(meinhard.name).to.equal('Meinhard')) + .then((meinhard) => expect(meinhard.name).toBe('Meinhard')) .then(findGustav) - .then((gustav) => expect(gustav.name).to.equal('Gustav')); + .then((gustav) => expect(gustav.name).toBe('Gustav')); }); it('unrelate', () => { @@ -213,9 +213,9 @@ module.exports = (session) => { }) .then(findArnold) .then((arnold) => arnold.$relatedQuery('parent')) - .then((parent) => expect(parent).to.eql(undefined)) + .then((parent) => expect(parent).toBeUndefined()) .then(findGustav) - .then((gustav) => expect(gustav.name).to.equal('Gustav')); + .then((gustav) => expect(gustav.name).toBe('Gustav')); }); }); @@ -223,7 +223,7 @@ module.exports = (session) => { it('find', () => { return findArnold() .then((arnold) => arnold.$relatedQuery('pets')) - .then((pets) => expect(pets.map((it) => it.name).sort()).to.eql(['Freud', 'Stalin'])); + .then((pets) => expect(pets.map((it) => it.name).sort()).toEqual(['Freud', 'Stalin'])); }); it('update', () => { @@ -231,11 +231,11 @@ module.exports = (session) => { .then((arnold) => { return arnold.$relatedQuery('pets').update({ nickname: concat('name', "'zilla'") }); }) - .then((numUpdated) => expect(numUpdated).to.equal(2)) + .then((numUpdated) => expect(numUpdated).toBe(2)) .then(findArnold) .then((arnold) => arnold.$relatedQuery('pets')) .then((pets) => { - expect(sortBy(pets, 'nickname').map((pet) => pet.nickname)).to.eql([ + expect(sortBy(pets, 'nickname').map((pet) => pet.nickname)).toEqual([ 'Freudzilla', 'Stalinzilla', ]); @@ -247,10 +247,10 @@ module.exports = (session) => { .then((arnold) => { return arnold.$relatedQuery('pets').delete(); }) - .then((numDeleted) => expect(numDeleted).to.equal(2)) + .then((numDeleted) => expect(numDeleted).toBe(2)) .then(findArnold) .then((arnold) => arnold.$relatedQuery('pets')) - .then((pets) => expect(pets).to.have.length(0)); + .then((pets) => expect(pets).toHaveLength(0)); }); it('insert', () => { @@ -260,7 +260,7 @@ module.exports = (session) => { }) .then(findArnold) .then((arnold) => arnold.$relatedQuery('pets').orderBy('name').select('name')) - .then((pets) => expect(pets.map((it) => it.name)).to.eql(['Cat', 'Freud', 'Stalin'])); + .then((pets) => expect(pets.map((it) => it.name)).toEqual(['Cat', 'Freud', 'Stalin'])); }); it('relate', () => { @@ -271,7 +271,7 @@ module.exports = (session) => { .then(findArnold) .then((arnold) => arnold.$relatedQuery('movies').orderBy('name').select('name')) .then((movies) => - expect(movies.map((it) => it.name)).to.eql([ + expect(movies.map((it) => it.name)).toEqual([ 'Terminator', 'Terminator 2', 'Terminator 3', @@ -286,7 +286,7 @@ module.exports = (session) => { }) .then(findArnold) .then((arnold) => arnold.$relatedQuery('movies')) - .then((movies) => expect(movies).to.eql([])); + .then((movies) => expect(movies).toEqual([])); }); }); @@ -295,7 +295,7 @@ module.exports = (session) => { return findArnold() .then((arnold) => arnold.$relatedQuery('movies').orderBy('name')) .then((movies) => - expect(movies.map((it) => it.name)).to.eql(['Terminator', 'Terminator 2']), + expect(movies.map((it) => it.name)).toEqual(['Terminator', 'Terminator 2']), ); }); @@ -307,12 +307,12 @@ module.exports = (session) => { .where('name', 'Terminator') .patch({ altName: concat('name', "': This Time its Personal'") }); }) - .then((numUpdated) => expect(numUpdated).to.equal(1)) + .then((numUpdated) => expect(numUpdated).toBe(1)) .then(findArnold) .then((arnold) => arnold.$relatedQuery('movies')) .then((movies) => { - expect(movies.length).to.equal(2); - expect(movies.filter((it) => it.altName).map((it) => it.altName)).to.eql([ + expect(movies.length).toBe(2); + expect(movies.filter((it) => it.altName).map((it) => it.altName)).toEqual([ 'Terminator: This Time its Personal', ]); }); @@ -323,11 +323,11 @@ module.exports = (session) => { .then((arnold) => { return arnold.$relatedQuery('movies').delete().where('name', 'Terminator 2'); }) - .then((numDeleted) => expect(numDeleted).to.equal(1)) + .then((numDeleted) => expect(numDeleted).toBe(1)) .then(findArnold) .then((arnold) => arnold.$relatedQuery('movies')) .then((movies) => { - expect(movies.map((it) => it.name)).to.eql(['Terminator']); + expect(movies.map((it) => it.name)).toEqual(['Terminator']); }); }); @@ -338,9 +338,9 @@ module.exports = (session) => { }) .then(findArnold) .then((arnold) => arnold.$relatedQuery('parent')) - .then((meinhard) => expect(meinhard.name).to.equal('Meinhard')) + .then((meinhard) => expect(meinhard.name).toBe('Meinhard')) .then(findGustav) - .then((gustav) => expect(gustav.name).to.equal('Gustav')); + .then((gustav) => expect(gustav.name).toBe('Gustav')); }); it('unrelate', () => { @@ -350,9 +350,9 @@ module.exports = (session) => { }) .then(findArnold) .then((arnold) => arnold.$relatedQuery('parent')) - .then((parent) => expect(parent).to.eql(undefined)) + .then((parent) => expect(parent).toBeUndefined()) .then(findGustav) - .then((gustav) => expect(gustav.name).to.equal('Gustav')); + .then((gustav) => expect(gustav.name).toBe('Gustav')); }); }); }); @@ -367,8 +367,8 @@ module.exports = (session) => { .whereExists(Person.relatedQuery('pets')) .orderBy('name') .then((result) => { - expect(result.length).to.equal(2); - expect(result).to.containSubset([ + expect(result.length).toBe(2); + expect(result).toContainSubset([ { name: 'Arnold', @@ -417,8 +417,8 @@ module.exports = (session) => { .whereExists(Person.relatedQuery('pets')) .orderBy('person.name') .then((result) => { - expect(result.length).to.equal(2); - expect(result).to.containSubset([ + expect(result.length).toBe(2); + expect(result).toContainSubset([ { name: 'Arnold', @@ -467,7 +467,7 @@ module.exports = (session) => { }) .then(findArnoldEagerly) .then((arnold) => { - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ name: 'Arnold', parent: { @@ -495,7 +495,7 @@ module.exports = (session) => { }) .then(findGustav) .then((gustav) => { - expect(gustav).to.equal(undefined); + expect(gustav).toBeUndefined(); }); }); @@ -507,7 +507,7 @@ module.exports = (session) => { }) .then(findArnoldEagerly) .then((arnold) => { - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ name: 'Arnold', parent: null, @@ -532,7 +532,7 @@ module.exports = (session) => { }) .then(findGustav) .then((gustav) => { - expect(gustav).to.equal(undefined); + expect(gustav).toBeUndefined(); }); }); @@ -549,7 +549,7 @@ module.exports = (session) => { }) .then(findArnoldEagerly) .then((arnold) => { - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ name: 'Arnold', parent: { @@ -577,7 +577,7 @@ module.exports = (session) => { }) .then(findGustav) .then((gustav) => { - expect(gustav.name).to.equal('Gustav'); + expect(gustav.name).toBe('Gustav'); }); }); @@ -593,7 +593,7 @@ module.exports = (session) => { }) .then(findArnoldEagerly) .then((arnold) => { - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ name: 'Arnold', parent: null, @@ -618,7 +618,7 @@ module.exports = (session) => { }) .then(findGustav) .then((gustav) => { - expect(gustav.name).to.equal('Gustav'); + expect(gustav.name).toBe('Gustav'); }); }); }); @@ -635,7 +635,7 @@ module.exports = (session) => { }) .then(findArnoldEagerly) .then((arnold) => { - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ name: 'Arnold', parent: { @@ -674,7 +674,7 @@ module.exports = (session) => { }) .then(findArnoldEagerly) .then((arnold) => { - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ name: 'Arnold', parent: { @@ -699,7 +699,7 @@ module.exports = (session) => { }) .then(findStalin) .then((stalin) => { - expect(stalin).to.equal(undefined); + expect(stalin).toBeUndefined(); }); }); @@ -715,7 +715,7 @@ module.exports = (session) => { }) .then(findArnoldEagerly) .then((arnold) => { - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ name: 'Arnold', parent: { @@ -746,7 +746,7 @@ module.exports = (session) => { }) .then(() => Animal.query().where('name', 'Tahvo')) .then((tahvos) => { - expect(tahvos.length).to.equal(1); + expect(tahvos.length).toBe(1); }); }); @@ -762,7 +762,7 @@ module.exports = (session) => { }) .then(findArnoldEagerly) .then((arnold) => { - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ name: 'Arnold', parent: { @@ -787,8 +787,8 @@ module.exports = (session) => { }) .then(findStalin) .then((stalin) => { - expect(stalin.name).to.equal('Stalin'); - expect(stalin.ownerName).to.equal(null); + expect(stalin.name).toBe('Stalin'); + expect(stalin.ownerName).toBeNull(); }); }); }); @@ -802,7 +802,7 @@ module.exports = (session) => { }) .then(findArnoldEagerly) .then((arnold) => { - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ name: 'Arnold', parent: { @@ -841,7 +841,7 @@ module.exports = (session) => { }) .then(findArnoldEagerly) .then((arnold) => { - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ name: 'Arnold', parent: { @@ -866,7 +866,7 @@ module.exports = (session) => { }) .then(findTerminator) .then((terminator) => { - expect(terminator).to.equal(undefined); + expect(terminator).toBeUndefined(); }); }); @@ -882,7 +882,7 @@ module.exports = (session) => { }) .then(findArnoldEagerly) .then((arnold) => { - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ name: 'Arnold', parent: { @@ -913,7 +913,7 @@ module.exports = (session) => { }) .then(() => Movie.query().where('name', 'Terminator 3')) .then((terminator3s) => { - expect(terminator3s.length).to.equal(1); + expect(terminator3s.length).toBe(1); }); }); @@ -929,7 +929,7 @@ module.exports = (session) => { }) .then(findArnoldEagerly) .then((arnold) => { - expect(arnold).to.containSubset({ + expect(arnold).toContainSubset({ name: 'Arnold', parent: { @@ -954,13 +954,13 @@ module.exports = (session) => { }) .then(findTerminator) .then((terminator) => { - expect(terminator.name).to.equal('Terminator'); + expect(terminator.name).toBe('Terminator'); }); }); }); }); - after(() => { + afterAll(() => { return session.knex.schema .dropTableIfExists('personMovie') .dropTableIfExists('animal') diff --git a/tests/integration/patch.js b/tests/integration/patch.js index ccffe1926..7a9ff759a 100644 --- a/tests/integration/patch.js +++ b/tests/integration/patch.js @@ -1,16 +1,15 @@ -const chai = require('chai'); -const expect = require('expect.js'); -const { inheritModel } = require('../../lib/model/inheritModel'); -const { - expectPartialEqual: expectPartEql, +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { inheritModel } from '../../lib/model/inheritModel.js'; +import { + expectPartialEqual as expectPartEql, delay, cloneDeep, -} = require('./../../testUtils/testUtils'); -const { Model, QueryBuilder, ValidationError, raw } = require('../../'); -const { isPostgres, isSqlite } = require('../../lib/utils/knexUtils'); -const mockKnexFactory = require('../../testUtils/mockKnex'); +} from '../../testUtils/testUtils.js'; +import { Model, QueryBuilder, ValidationError, raw } from 'objection'; +import { isPostgres, isSqlite } from '../../lib/utils/knexUtils.js'; +import mockKnexFactory from '../../testUtils/mockKnex.js'; -module.exports = (session) => { +export default (session) => { const Model1 = session.models.Model1; const Model2 = session.models.Model2; @@ -52,19 +51,19 @@ module.exports = (session) => { .patch(model) .where('id', '=', 2) .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(model.$beforeInsertCalled).to.equal(undefined); - expect(model.$afterInsertCalled).to.equal(undefined); - expect(model.$beforeDeleteCalled).to.equal(undefined); - expect(model.$afterDeleteCalled).to.equal(undefined); - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$beforeUpdateOptions).to.eql({ patch: true }); - expect(model.$afterUpdateCalled).to.equal(1); - expect(model.$afterUpdateOptions).to.eql({ patch: true }); + expect(numUpdated).toBe(1); + expect(model.$beforeInsertCalled).toBeUndefined(); + expect(model.$afterInsertCalled).toBeUndefined(); + expect(model.$beforeDeleteCalled).toBeUndefined(); + expect(model.$afterDeleteCalled).toBeUndefined(); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$beforeUpdateOptions).toEqual({ patch: true }); + expect(model.$afterUpdateCalled).toBe(1); + expect(model.$afterUpdateOptions).toEqual({ patch: true }); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -78,19 +77,19 @@ module.exports = (session) => { .patch(model) .where('id_col', '=', 1) .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(model.$beforeInsertCalled).to.equal(undefined); - expect(model.$afterInsertCalled).to.equal(undefined); - expect(model.$beforeDeleteCalled).to.equal(undefined); - expect(model.$afterDeleteCalled).to.equal(undefined); - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$beforeUpdateOptions).to.eql({ patch: true }); - expect(model.$afterUpdateCalled).to.equal(1); - expect(model.$afterUpdateOptions).to.eql({ patch: true }); + expect(numUpdated).toBe(1); + expect(model.$beforeInsertCalled).toBeUndefined(); + expect(model.$afterInsertCalled).toBeUndefined(); + expect(model.$beforeDeleteCalled).toBeUndefined(); + expect(model.$afterDeleteCalled).toBeUndefined(); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$beforeUpdateOptions).toEqual({ patch: true }); + expect(model.$afterUpdateCalled).toBe(1); + expect(model.$afterUpdateOptions).toEqual({ patch: true }); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'updated text', model2_prop2: 2 }); expectPartEql(rows[1], { id_col: 2, model2_prop1: 'text 2', model2_prop2: 1 }); }); @@ -101,11 +100,11 @@ module.exports = (session) => { .patch({ model1Prop1: 'updated text' }) .where('id', '=', 2) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -121,11 +120,11 @@ module.exports = (session) => { }) .where('id', '=', 2) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -140,11 +139,11 @@ module.exports = (session) => { }) .where('id', '=', 1) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'text 2', model1Prop2: 3 }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -159,11 +158,11 @@ module.exports = (session) => { }) .where('id', '=', 1) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'Morten', model1Prop2: 3 }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -175,11 +174,11 @@ module.exports = (session) => { .patch({ model1Prop1: 'updated text' }) .where('model1Prop1', '<', 'hello 3') .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -192,9 +191,9 @@ module.exports = (session) => { .where('model1Prop1', '<', 'hello 3') .orderBy('id'); - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); const rows = await session.knex('Model1').orderBy('id'); - expect(rows.map((row) => row.model1Prop1)).to.eql([ + expect(rows.map((row) => row.model1Prop1)).toEqual([ 'updated text', 'updated text', 'hello 3', @@ -208,7 +207,7 @@ module.exports = (session) => { await Model1.query() .patch({ model1Prop1: 'where group' }) .where((builder) => builder.whereIn('id', subquery())), - ).to.equal(1); + ).toBe(1); if (session.isMySql()) { // knex only supports joins in updates on MySQL. @@ -218,12 +217,12 @@ module.exports = (session) => { .join('model2', (join) => { join.on('model2.model1_id', 'Model1.id').andOnIn('Model1.id', subquery()); }), - ).to.be.above(0); + ).toBeGreaterThan(0); } const rows = await session.knex('Model1').orderBy('id'); const expected = session.isMySql() ? 'join clause' : 'where group'; - expect(rows.map((row) => row.model1Prop1)).to.eql([expected, 'hello 2', 'hello 3']); + expect(rows.map((row) => row.model1Prop1)).toEqual([expected, 'hello 2', 'hello 3']); }); if (session.isPostgres()) { @@ -233,8 +232,8 @@ module.exports = (session) => { .where('model1Prop1', '<', 'hello 3') .returning('*') .then((updatedRows) => { - expect(updatedRows).to.have.length(2); - chai.expect(updatedRows).to.containSubset([ + expect(updatedRows).toHaveLength(2); + expect(updatedRows).toContainSubset([ { id: 1, model1Id: null, @@ -257,11 +256,11 @@ module.exports = (session) => { .increment('model2Prop2', 10) .where('id_col', '=', 2) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id_col: 1, model2_prop2: 2 }); expectPartEql(rows[1], { id_col: 2, model2_prop2: 11 }); }); @@ -272,17 +271,17 @@ module.exports = (session) => { .decrement('model2Prop2', 10) .where('id_col', '=', 2) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id_col: 1, model2_prop2: 2 }); expectPartEql(rows[1], { id_col: 2, model2_prop2: -9 }); }); }); - it('should validate (1)', (done) => { + it('should validate (1)', () => { let ModelWithSchema = subClassWithSchema(Model1, { type: 'object', required: ['model1Prop2'], @@ -293,15 +292,15 @@ module.exports = (session) => { }, }); - ModelWithSchema.query() + return ModelWithSchema.query() .patch({ model1Prop1: 100 }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(ValidationError); - expect(err.type).to.equal('ModelValidation'); - expect(err.data).to.eql({ + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('ModelValidation'); + expect(err.data).toEqual({ model1Prop1: [ { message: 'must be string', @@ -315,17 +314,15 @@ module.exports = (session) => { return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', ]); - done(); - }) - .catch(done); + }); }); - it('should skip requirement validation', (done) => { + it('should skip requirement validation', () => { let ModelWithSchema = subClassWithSchema(Model1, { type: 'object', required: ['model1Prop2'], @@ -336,16 +333,14 @@ module.exports = (session) => { }, }); - ModelWithSchema.query() + return ModelWithSchema.query() .patch({ model1Prop1: 'text' }) .then(() => { return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql(['text', 'text', 'text']); - done(); - }) - .catch(done); + expect(rows.map((it) => it.model1Prop1).sort()).toEqual(['text', 'text', 'text']); + }); }); it('should be able to use objection.raw in hooks', () => { @@ -368,7 +363,7 @@ module.exports = (session) => { return Test.query(session.knex).findById(2); }) .then((model) => { - expect(model).to.eql({ + expect(model).toEqual({ id: 2, model1Id: null, model1Prop1: 'updated', @@ -398,8 +393,8 @@ module.exports = (session) => { .where({ id: 1 }) .patch({ model1Prop1: 'updated text' }) .then((count) => { - expect(count).to.eql(1); - expect(runBeforeCalled).to.eql(1); + expect(count).toBe(1); + expect(runBeforeCalled).toBe(1); }); }); }); @@ -436,14 +431,14 @@ module.exports = (session) => { const model = Model1.fromJson({ model1Prop1: 'updated text' }); const numUpdated = await Model1.query().patchById(2, model); - expect(numUpdated).to.equal(1); - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$beforeUpdateOptions).to.eql({ patch: true }); - expect(model.$afterUpdateCalled).to.equal(1); - expect(model.$afterUpdateOptions).to.eql({ patch: true }); + expect(numUpdated).toBe(1); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$beforeUpdateOptions).toEqual({ patch: true }); + expect(model.$afterUpdateCalled).toBe(1); + expect(model.$afterUpdateOptions).toEqual({ patch: true }); const rows = await session.knex('Model1').orderBy('id'); - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -451,7 +446,7 @@ module.exports = (session) => { it('should return 0 if the model does not exist', async () => { const numUpdated = await Model1.query().patchById(1000, { model1Prop1: 'updated text' }); - expect(numUpdated).to.equal(0); + expect(numUpdated).toBe(0); }); it('should throw a NotFoundError with throwIfNotFound() if the model does not exist', async () => { @@ -463,7 +458,7 @@ module.exports = (session) => { error = err; } - expect(error).to.be.a(Model1.NotFoundError); + expect(error).toBeInstanceOf(Model1.NotFoundError); }); it('should throw a clear error if undefined is passed as id', async () => { @@ -475,10 +470,10 @@ module.exports = (session) => { error = err; } - expect(error.message).to.equal('undefined was passed to patchById'); + expect(error.message).toBe('undefined was passed to patchById'); const rows = await session.knex('Model1').where('model1Prop1', 'updated text'); - expect(rows).to.have.length(0); + expect(rows).toHaveLength(0); }); it('should patch a related model by id', async () => { @@ -486,7 +481,7 @@ module.exports = (session) => { .for(1) .patchById(2, { model2Prop1: 'updated text' }); - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); const rows = await session.knex('model2').orderBy('id_col'); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'text 1' }); @@ -530,16 +525,16 @@ module.exports = (session) => { return Model1.query() .patchAndFetchById(2, model) .then((fetchedModel) => { - expect(model.$beforeInsertCalled).to.equal(undefined); - expect(model.$afterInsertCalled).to.equal(undefined); - expect(model.$beforeDeleteCalled).to.equal(undefined); - expect(model.$afterDeleteCalled).to.equal(undefined); - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$beforeUpdateOptions).to.eql({ patch: true }); - expect(model.$afterUpdateCalled).to.equal(1); - expect(model.$afterUpdateOptions).to.eql({ patch: true }); - expect(fetchedModel).to.equal(model); - expect(fetchedModel).eql({ + expect(model.$beforeInsertCalled).toBeUndefined(); + expect(model.$afterInsertCalled).toBeUndefined(); + expect(model.$beforeDeleteCalled).toBeUndefined(); + expect(model.$afterDeleteCalled).toBeUndefined(); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$beforeUpdateOptions).toEqual({ patch: true }); + expect(model.$afterUpdateCalled).toBe(1); + expect(model.$afterUpdateOptions).toEqual({ patch: true }); + expect(fetchedModel).toBe(model); + expect(fetchedModel).toEqual({ id: 2, model1Prop1: 'updated text', model1Prop2: null, @@ -552,7 +547,7 @@ module.exports = (session) => { return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -564,10 +559,10 @@ module.exports = (session) => { .patchAndFetchById(2, { model1Prop1: 'updated text' }) .orderBy('id'); - expect(fetchedModel.id).to.equal(2); - expect(fetchedModel.model1Prop1).to.equal('updated text'); + expect(fetchedModel.id).toBe(2); + expect(fetchedModel.model1Prop1).toBe('updated text'); const rows = await session.knex('Model1').orderBy('id'); - expect(rows.map((row) => row.model1Prop1)).to.eql(['hello 1', 'updated text', 'hello 3']); + expect(rows.map((row) => row.model1Prop1)).toEqual(['hello 1', 'updated text', 'hello 3']); }); it('should apply `select` to the fetch query', async () => { @@ -576,11 +571,11 @@ module.exports = (session) => { .patchAndFetchById(2, model) .select('id', 'model1Prop2'); - expect(fetchedModel).to.equal(model); - expect(fetchedModel.id).to.equal(2); - expect(fetchedModel.model1Prop1).to.equal('updated text'); - expect(fetchedModel.model1Prop2).to.equal(null); - expect(fetchedModel).to.not.have.property('model1Id'); + expect(fetchedModel).toBe(model); + expect(fetchedModel.id).toBe(2); + expect(fetchedModel.model1Prop1).toBe('updated text'); + expect(fetchedModel.model1Prop2).toBeNull(); + expect(fetchedModel).not.toHaveProperty('model1Id'); const rows = await session.knex('Model1').orderBy('id'); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); @@ -594,7 +589,7 @@ module.exports = (session) => { .withGraphFetched('model1Relation2') .modifyGraph('model1Relation2', (qb) => qb.orderBy('id_col')) .then((fetchedModel) => { - expect(fetchedModel).eql({ + expect(fetchedModel).toEqual({ id: 1, model1Prop1: 'updated text', model1Prop2: null, @@ -625,7 +620,7 @@ module.exports = (session) => { return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -637,11 +632,11 @@ module.exports = (session) => { .patchAndFetchById(2, { model1Prop1: 'updated text' }) .where('id', -1) .then((fetchedModel) => { - expect(fetchedModel).to.equal(undefined); + expect(fetchedModel).toBeUndefined(); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -670,12 +665,12 @@ module.exports = (session) => { .$query() .patch({ model1Prop1: 'updated text', undefinedShouldBeIgnored: undefined }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(model.model1Prop1).to.equal('updated text'); + expect(numUpdated).toBe(1); + expect(model.model1Prop1).toBe('updated text'); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); }); @@ -691,13 +686,13 @@ module.exports = (session) => { .returning('model1Prop1', 'model1Prop2') .then((patched) => { const expected = { model1Prop1: 'updated text', model1Prop2: null }; - expect(patched).to.be.a(Model1); - expect(patched).to.eql(expected); - expect(model).to.eql(Object.assign({}, expected, { id: 1 })); + expect(patched).toBeInstanceOf(Model1); + expect(patched).toEqual(expected); + expect(model).toEqual(Object.assign({}, expected, { id: 1 })); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); }); @@ -717,13 +712,13 @@ module.exports = (session) => { model1Prop1: 'updated text', model1Prop2: null, }; - expect(patched).to.be.a(Model1); - expect(patched).to.eql(expected); - expect(model).to.eql(expected); + expect(patched).toBeInstanceOf(Model1); + expect(patched).toEqual(expected); + expect(model).toEqual(expected); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); }); @@ -735,11 +730,11 @@ module.exports = (session) => { .$query() .patch() .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); }); @@ -752,18 +747,18 @@ module.exports = (session) => { .$query() .patch(patch) .then(() => { - expect(patch.$beforeInsertCalled).to.equal(undefined); - expect(patch.$afterInsertCalled).to.equal(undefined); - expect(patch.$beforeDeleteCalled).to.equal(undefined); - expect(patch.$afterDeleteCalled).to.equal(undefined); - expect(patch.$beforeUpdateCalled).to.equal(1); - expect(patch.$beforeUpdateOptions).to.eql({ patch: true, old: { id: 1 } }); - expect(patch.$afterUpdateCalled).to.equal(1); - expect(patch.$afterUpdateOptions).to.eql({ patch: true, old: { id: 1 } }); + expect(patch.$beforeInsertCalled).toBeUndefined(); + expect(patch.$afterInsertCalled).toBeUndefined(); + expect(patch.$beforeDeleteCalled).toBeUndefined(); + expect(patch.$afterDeleteCalled).toBeUndefined(); + expect(patch.$beforeUpdateCalled).toBe(1); + expect(patch.$beforeUpdateOptions).toEqual({ patch: true, old: { id: 1 } }); + expect(patch.$afterUpdateCalled).toBe(1); + expect(patch.$afterUpdateOptions).toEqual({ patch: true, old: { id: 1 } }); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); }); @@ -783,50 +778,46 @@ module.exports = (session) => { .$query() .patch() .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); }); }); - it('should throw if the id is undefined', (done) => { + it('should throw if the id is undefined', () => { let model = Model1.fromJson({ model1Prop2: 1 }); - model + return model .$query() .patch({ model1Prop1: 'updated text', undefinedShouldBeIgnored: undefined }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( `one of the identifier columns [id] is null or undefined. Have you specified the correct identifier column for the model 'Model1' using the 'idColumn' property?`, ); - done(); - }) - .catch(done); + }); }); - it('should throw if the id is null', (done) => { + it('should throw if the id is null', () => { let model = Model1.fromJson({ id: null }); - model + return model .$query() .patch({ model1Prop1: 'updated text', undefinedShouldBeIgnored: undefined }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( `one of the identifier columns [id] is null or undefined. Have you specified the correct identifier column for the model 'Model1' using the 'idColumn' property?`, ); - done(); - }) - .catch(done); + }); }); }); @@ -834,7 +825,7 @@ module.exports = (session) => { let ModelOne; let queries = []; - before(() => { + beforeAll(() => { const knex = mockKnexFactory(session.knex, function (mock, oldImpl, args) { queries.push(this.toString()); return oldImpl.apply(this, args); @@ -865,10 +856,10 @@ module.exports = (session) => { .$query() .patchAndFetch({ model1Prop2: 10, undefinedShouldBeIgnored: undefined }) .then((updated) => { - expect(updated.id).to.equal(1); - expect(updated.model1Id).to.equal(null); - expect(updated.model1Prop1).to.equal('hello 1'); - expect(updated.model1Prop2).to.equal(10); + expect(updated.id).toBe(1); + expect(updated.model1Id).toBeNull(); + expect(updated.model1Prop1).toBe('hello 1'); + expect(updated.model1Prop2).toBe(10); expectPartEql(model, { id: 1, model1Prop1: 'hello 1', @@ -877,7 +868,7 @@ module.exports = (session) => { }); if (session.isPostgres()) { - expect(queries).to.eql([ + expect(queries).toEqual([ 'update "Model1" set "model1Prop2" = 10 where "Model1"."id" = 1', 'select "Model1".* from "Model1" where "Model1"."id" = 1', ]); @@ -886,7 +877,7 @@ module.exports = (session) => { return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1', model1Prop2: 10 }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2', model1Prop2: null }); }); @@ -899,13 +890,13 @@ module.exports = (session) => { .patchAndFetch({ model1Prop2: 10 }) .select('model1Prop1'); - expect(updated.id).to.equal(1); - expect(updated.model1Prop1).to.equal('hello 1'); - expect(updated.model1Prop2).to.equal(10); - expect(updated).to.not.have.property('model1Id'); + expect(updated.id).toBe(1); + expect(updated.model1Prop1).toBe('hello 1'); + expect(updated.model1Prop2).toBe(10); + expect(updated).not.toHaveProperty('model1Id'); if (session.isPostgres()) { - expect(queries).to.eql([ + expect(queries).toEqual([ 'update "Model1" set "model1Prop2" = 10 where "Model1"."id" = 1', 'select "model1Prop1" from "Model1" where "Model1"."id" = 1', ]); @@ -953,20 +944,20 @@ module.exports = (session) => { .$relatedQuery('model1Relation1') .patch(model) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); - expect(model.$beforeInsertCalled).to.equal(undefined); - expect(model.$afterInsertCalled).to.equal(undefined); - expect(model.$beforeDeleteCalled).to.equal(undefined); - expect(model.$afterDeleteCalled).to.equal(undefined); - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$beforeUpdateOptions).to.eql({ patch: true }); - expect(model.$afterUpdateCalled).to.equal(1); - expect(model.$afterUpdateOptions).to.eql({ patch: true }); + expect(model.$beforeInsertCalled).toBeUndefined(); + expect(model.$afterInsertCalled).toBeUndefined(); + expect(model.$beforeDeleteCalled).toBeUndefined(); + expect(model.$afterDeleteCalled).toBeUndefined(); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$beforeUpdateOptions).toEqual({ patch: true }); + expect(model.$afterUpdateCalled).toBe(1); + expect(model.$afterUpdateOptions).toEqual({ patch: true }); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); @@ -980,11 +971,11 @@ module.exports = (session) => { .$relatedQuery('model1Relation1') .patch({ model1Prop1: 'updated text', model1Prop2: 1000 }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -1059,21 +1050,21 @@ module.exports = (session) => { .patch(model) .where('id_col', 2) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); - expect(model.$beforeInsertCalled).to.equal(undefined); - expect(model.$afterInsertCalled).to.equal(undefined); - expect(model.$beforeDeleteCalled).to.equal(undefined); - expect(model.$afterDeleteCalled).to.equal(undefined); - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$beforeUpdateOptions).to.eql({ patch: true }); - expect(model.$afterUpdateCalled).to.equal(1); - expect(model.$afterUpdateOptions).to.eql({ patch: true }); + expect(model.$beforeInsertCalled).toBeUndefined(); + expect(model.$afterInsertCalled).toBeUndefined(); + expect(model.$beforeDeleteCalled).toBeUndefined(); + expect(model.$afterDeleteCalled).toBeUndefined(); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$beforeUpdateOptions).toEqual({ patch: true }); + expect(model.$afterUpdateCalled).toBe(1); + expect(model.$afterUpdateOptions).toEqual({ patch: true }); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(6); + expect(rows).toHaveLength(6); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(rows[1], { id_col: 2, @@ -1094,11 +1085,11 @@ module.exports = (session) => { .where('model2_prop2', '<', 6) .where('model2_prop1', 'like', 'text %') .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(6); + expect(rows).toHaveLength(6); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(rows[1], { id_col: 2, @@ -1246,21 +1237,21 @@ module.exports = (session) => { .patch(model) .where('Model1.id', 5) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); - expect(model.$beforeInsertCalled).to.equal(undefined); - expect(model.$afterInsertCalled).to.equal(undefined); - expect(model.$beforeDeleteCalled).to.equal(undefined); - expect(model.$afterDeleteCalled).to.equal(undefined); - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$beforeUpdateOptions).to.eql({ patch: true }); - expect(model.$afterUpdateCalled).to.equal(1); - expect(model.$afterUpdateOptions).to.eql({ patch: true }); + expect(model.$beforeInsertCalled).toBeUndefined(); + expect(model.$afterInsertCalled).toBeUndefined(); + expect(model.$beforeDeleteCalled).toBeUndefined(); + expect(model.$afterDeleteCalled).toBeUndefined(); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$beforeUpdateOptions).toEqual({ patch: true }); + expect(model.$afterUpdateCalled).toBe(1); + expect(model.$afterUpdateOptions).toEqual({ patch: true }); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(9); + expect(rows).toHaveLength(9); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -1287,7 +1278,7 @@ module.exports = (session) => { }); }) .then((result) => { - chai.expect(result).to.containSubset({ + expect(result).toMatchObject({ model2Prop1: 'iam updated', extra1: 'updated extra 1', extra2: 'updated extra 2', @@ -1297,7 +1288,7 @@ module.exports = (session) => { return Model1.query().findById(1).withGraphFetched('model1Relation3'); }) .then((model1) => { - chai.expect(model1).to.containSubset({ + expect(model1).toContainSubset({ id: 1, model1Id: null, model1Prop1: 'hello 1', @@ -1353,7 +1344,7 @@ module.exports = (session) => { }) .where('id_col', '<', 5) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return Promise.all([ session.knex('model2').orderBy('id_col'), @@ -1364,8 +1355,8 @@ module.exports = (session) => { ]); }) .then(([model2, model1Model2]) => { - expect(model2.length).to.equal(8); - expect(model1Model2.length).to.equal(12); + expect(model2.length).toBe(8); + expect(model1Model2.length).toBe(12); expectPartEql(model2[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(model2[1], { id_col: 2, model2_prop1: 'text 2' }); @@ -1429,7 +1420,7 @@ module.exports = (session) => { extra2: 'updated extra 2', }) .then((numUpdated) => { - expect(numUpdated).to.equal(3); + expect(numUpdated).toBe(3); return Promise.all([ session.knex('model2').orderBy('id_col'), @@ -1440,8 +1431,8 @@ module.exports = (session) => { ]); }) .then(([model2, model1Model2]) => { - expect(model2.length).to.equal(8); - expect(model1Model2.length).to.equal(12); + expect(model2.length).toBe(8); + expect(model1Model2.length).toBe(12); expectPartEql(model2[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(model2[1], { id_col: 2, model2_prop1: 'text 2' }); @@ -1498,11 +1489,11 @@ module.exports = (session) => { .$relatedQuery('model2Relation1') .patch({ model1Prop1: 'updated text', model1Prop2: 123 }) .then((numUpdated) => { - expect(numUpdated).to.equal(3); + expect(numUpdated).toBe(3); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(9); + expect(rows).toHaveLength(9); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -1521,11 +1512,11 @@ module.exports = (session) => { .where('model1Prop1', 'like', 'blaa 4') .orWhere('model1Prop1', 'like', 'blaa 6') .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(9); + expect(rows).toHaveLength(9); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -1543,11 +1534,11 @@ module.exports = (session) => { .patch({ model1Prop1: 'updated text', model1Prop2: 123 }) .where('model1Prop2', '<', 6) .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(9); + expect(rows).toHaveLength(9); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -1565,11 +1556,11 @@ module.exports = (session) => { .innerJoinRelated('model1Relation1') .patch({ model1Prop1: 'updated text', model1Prop2: 123 }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(9); + expect(rows).toHaveLength(9); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'updated text', model1Prop2: 123 }); @@ -1633,11 +1624,11 @@ module.exports = (session) => { .$relatedQuery('model2Relation2') .patch({ model1Prop1: 'updated text' }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'updated text' }); @@ -1677,20 +1668,20 @@ module.exports = (session) => { .for(1) .patch(model) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); - expect(model.$beforeInsertCalled).to.equal(undefined); - expect(model.$afterInsertCalled).to.equal(undefined); - expect(model.$beforeDeleteCalled).to.equal(undefined); - expect(model.$afterDeleteCalled).to.equal(undefined); - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$beforeUpdateOptions).to.eql({ patch: true }); - expect(model.$afterUpdateCalled).to.equal(1); - expect(model.$afterUpdateOptions).to.eql({ patch: true }); + expect(model.$beforeInsertCalled).toBeUndefined(); + expect(model.$afterInsertCalled).toBeUndefined(); + expect(model.$beforeDeleteCalled).toBeUndefined(); + expect(model.$afterDeleteCalled).toBeUndefined(); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$beforeUpdateOptions).toEqual({ patch: true }); + expect(model.$afterUpdateCalled).toBe(1); + expect(model.$afterUpdateOptions).toEqual({ patch: true }); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); @@ -1704,11 +1695,11 @@ module.exports = (session) => { .for(3) .patch({ model1Prop1: 'updated text', model1Prop2: 1000 }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -1721,11 +1712,11 @@ module.exports = (session) => { .for([1, 3]) .patch({ model1Prop1: 'updated text', model1Prop2: 1000 }) .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text', model1Prop2: 1000 }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -1738,11 +1729,11 @@ module.exports = (session) => { .for(Model1.query().where('id', 1)) .patch({ model1Prop1: 'updated text', model1Prop2: 1000 }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text', model1Prop2: 1000 }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -1818,21 +1809,21 @@ module.exports = (session) => { .patch(model) .where('id_col', 2) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); - expect(model.$beforeInsertCalled).to.equal(undefined); - expect(model.$afterInsertCalled).to.equal(undefined); - expect(model.$beforeDeleteCalled).to.equal(undefined); - expect(model.$afterDeleteCalled).to.equal(undefined); - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$beforeUpdateOptions).to.eql({ patch: true }); - expect(model.$afterUpdateCalled).to.equal(1); - expect(model.$afterUpdateOptions).to.eql({ patch: true }); + expect(model.$beforeInsertCalled).toBeUndefined(); + expect(model.$afterInsertCalled).toBeUndefined(); + expect(model.$beforeDeleteCalled).toBeUndefined(); + expect(model.$afterDeleteCalled).toBeUndefined(); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$beforeUpdateOptions).toEqual({ patch: true }); + expect(model.$afterUpdateCalled).toBe(1); + expect(model.$afterUpdateOptions).toEqual({ patch: true }); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(7); + expect(rows).toHaveLength(7); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(rows[1], { id_col: 2, @@ -1854,11 +1845,11 @@ module.exports = (session) => { .where('model2_prop2', '<', 6) .where('model2_prop1', 'like', 'text %') .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(7); + expect(rows).toHaveLength(7); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(rows[1], { id_col: 2, @@ -1883,11 +1874,11 @@ module.exports = (session) => { .patch({ model2Prop1: 'updated text' }) .where('model2_prop1', '!=', 'text 4') .then((numUpdated) => { - expect(numUpdated).to.equal(5); + expect(numUpdated).toBe(5); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(7); + expect(rows).toHaveLength(7); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'updated text' }); expectPartEql(rows[1], { id_col: 2, model2_prop1: 'updated text' }); expectPartEql(rows[2], { id_col: 3, model2_prop1: 'updated text' }); @@ -1904,11 +1895,11 @@ module.exports = (session) => { .patch({ model2Prop1: 'updated text' }) .where('model2_prop1', '!=', 'text 4') .then((numUpdated) => { - expect(numUpdated).to.equal(5); + expect(numUpdated).toBe(5); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(7); + expect(rows).toHaveLength(7); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'updated text' }); expectPartEql(rows[1], { id_col: 2, model2_prop1: 'updated text' }); expectPartEql(rows[2], { id_col: 3, model2_prop1: 'updated text' }); @@ -2039,21 +2030,21 @@ module.exports = (session) => { .patch(model) .where('Model1.id', 5) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); - expect(model.$beforeInsertCalled).to.equal(undefined); - expect(model.$afterInsertCalled).to.equal(undefined); - expect(model.$beforeDeleteCalled).to.equal(undefined); - expect(model.$afterDeleteCalled).to.equal(undefined); - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$beforeUpdateOptions).to.eql({ patch: true }); - expect(model.$afterUpdateCalled).to.equal(1); - expect(model.$afterUpdateOptions).to.eql({ patch: true }); + expect(model.$beforeInsertCalled).toBeUndefined(); + expect(model.$afterInsertCalled).toBeUndefined(); + expect(model.$beforeDeleteCalled).toBeUndefined(); + expect(model.$afterDeleteCalled).toBeUndefined(); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$beforeUpdateOptions).toEqual({ patch: true }); + expect(model.$afterUpdateCalled).toBe(1); + expect(model.$afterUpdateOptions).toEqual({ patch: true }); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(9); + expect(rows).toHaveLength(9); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -2080,7 +2071,7 @@ module.exports = (session) => { }) .where('id_col', '<', 5) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return Promise.all([ session.knex('model2').orderBy('id_col'), @@ -2091,8 +2082,8 @@ module.exports = (session) => { ]); }) .then(([model2, model1Model2]) => { - expect(model2.length).to.equal(8); - expect(model1Model2.length).to.equal(12); + expect(model2.length).toBe(8); + expect(model1Model2.length).toBe(12); expectPartEql(model2[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(model2[1], { id_col: 2, model2_prop1: 'text 2' }); @@ -2152,7 +2143,7 @@ module.exports = (session) => { extra2: 'updated extra 2', }) .then((numUpdated) => { - expect(numUpdated).to.equal(3); + expect(numUpdated).toBe(3); return Promise.all([ session.knex('model2').orderBy('id_col'), @@ -2163,8 +2154,8 @@ module.exports = (session) => { ]); }) .then(([model2, model1Model2]) => { - expect(model2.length).to.equal(8); - expect(model1Model2.length).to.equal(12); + expect(model2.length).toBe(8); + expect(model1Model2.length).toBe(12); expectPartEql(model2[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(model2[1], { id_col: 2, model2_prop1: 'text 2' }); @@ -2220,11 +2211,11 @@ module.exports = (session) => { .for(2) .patch({ model1Prop1: 'updated text', model1Prop2: 123 }) .then((numUpdated) => { - expect(numUpdated).to.equal(3); + expect(numUpdated).toBe(3); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(9); + expect(rows).toHaveLength(9); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -2242,11 +2233,11 @@ module.exports = (session) => { .patch({ model1Prop1: 'updated text' }) .where('model1Prop1', '!=', 'blaa 2') .then((numUpdated) => { - expect(numUpdated).to.equal(5); + expect(numUpdated).toBe(5); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(9); + expect(rows).toHaveLength(9); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'updated text' }); @@ -2264,11 +2255,11 @@ module.exports = (session) => { .patch({ model1Prop1: 'updated text' }) .where('model1Prop1', '!=', 'blaa 2') .then((numUpdated) => { - expect(numUpdated).to.equal(5); + expect(numUpdated).toBe(5); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(9); + expect(rows).toHaveLength(9); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'updated text' }); @@ -2287,11 +2278,11 @@ module.exports = (session) => { .where('model1Prop1', 'like', 'blaa 4') .orWhere('model1Prop1', 'like', 'blaa 6') .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(9); + expect(rows).toHaveLength(9); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -2309,11 +2300,11 @@ module.exports = (session) => { .patch({ model1Prop1: 'updated text', model1Prop2: 123 }) .where('model1Prop2', '<', 6) .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(9); + expect(rows).toHaveLength(9); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -2331,11 +2322,11 @@ module.exports = (session) => { .innerJoinRelated('model1Relation1') .patch({ model1Prop1: 'updated text', model1Prop2: 123 }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(9); + expect(rows).toHaveLength(9); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'updated text', model1Prop2: 123 }); @@ -2358,7 +2349,7 @@ module.exports = (session) => { let beforeUpdateOpt = null; let afterUpdateOpt = null; - before(() => { + beforeAll(() => { // Create a new knex object by wrapping session.knex so that we get a new // instance instead of a cached one from `bindKnex`. const knex = mockKnexFactory(session.knex, function (mock, oldImpl, args) { @@ -2368,10 +2359,10 @@ module.exports = (session) => { ModelOne = session.unboundModels.Model1.bindKnex(knex); ModelTwo = ModelOne.getRelation('model1Relation2').relatedModelClass; - expect(ModelOne).to.not.equal(Model1); - expect(ModelTwo).to.not.equal(Model2); - expect(ModelOne).to.not.equal(session.unboundModels.Model1); - expect(ModelTwo).to.not.equal(session.unboundModels.Model2); + expect(ModelOne).not.toBe(Model1); + expect(ModelTwo).not.toBe(Model2); + expect(ModelOne).not.toBe(session.unboundModels.Model1); + expect(ModelTwo).not.toBe(session.unboundModels.Model2); ModelOne.prototype.$beforeUpdate = function (opt, ctx) { beforeUpdateCalled += 'ModelOne'; @@ -2434,10 +2425,10 @@ module.exports = (session) => { .findById(1) .patch({ model1Prop1: 'updated text' }) .then(() => { - expect(beforeUpdateCalled).to.equal('ModelOne'); - expect(beforeUpdateOpt).to.eql({ patch: true }); - expect(afterUpdateCalled).to.equal('ModelOne'); - expect(afterUpdateOpt).to.eql({ patch: true }); + expect(beforeUpdateCalled).toBe('ModelOne'); + expect(beforeUpdateOpt).toEqual({ patch: true }); + expect(afterUpdateCalled).toBe('ModelOne'); + expect(afterUpdateOpt).toEqual({ patch: true }); }); }); @@ -2448,8 +2439,8 @@ module.exports = (session) => { return model.$query().patch({ model1Prop1: 'updated text' }); }) .then(() => { - expect(beforeUpdateCalled).to.equal('ModelOne'); - expect(beforeUpdateOpt).to.eql({ + expect(beforeUpdateCalled).toBe('ModelOne'); + expect(beforeUpdateOpt).toEqual({ patch: true, old: { $afterFindCalled: 1, @@ -2460,8 +2451,8 @@ module.exports = (session) => { }, }); - expect(afterUpdateCalled).to.equal('ModelOne'); - expect(afterUpdateOpt).to.eql({ + expect(afterUpdateCalled).toBe('ModelOne'); + expect(afterUpdateOpt).toEqual({ patch: true, old: { $afterFindCalled: 1, @@ -2482,10 +2473,10 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation1').patch({ model1Prop1: 'updated text' }); }) .then(() => { - expect(beforeUpdateCalled).to.equal('ModelOne'); - expect(beforeUpdateOpt).to.eql({ patch: true }); - expect(afterUpdateCalled).to.equal('ModelOne'); - expect(afterUpdateOpt).to.eql({ patch: true }); + expect(beforeUpdateCalled).toBe('ModelOne'); + expect(beforeUpdateOpt).toEqual({ patch: true }); + expect(afterUpdateCalled).toBe('ModelOne'); + expect(afterUpdateOpt).toEqual({ patch: true }); }); }); @@ -2496,10 +2487,10 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation2').patch({ model2Prop1: 'updated text' }); }) .then(() => { - expect(beforeUpdateCalled).to.equal('ModelTwo'); - expect(beforeUpdateOpt).to.eql({ patch: true }); - expect(afterUpdateCalled).to.equal('ModelTwo'); - expect(afterUpdateOpt).to.eql({ patch: true }); + expect(beforeUpdateCalled).toBe('ModelTwo'); + expect(beforeUpdateOpt).toEqual({ patch: true }); + expect(afterUpdateCalled).toBe('ModelTwo'); + expect(afterUpdateOpt).toEqual({ patch: true }); }); }); @@ -2510,10 +2501,10 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation3').patch({ model2Prop1: 'updated text' }); }) .then(() => { - expect(beforeUpdateCalled).to.equal('ModelTwo'); - expect(beforeUpdateOpt).to.eql({ patch: true }); - expect(afterUpdateCalled).to.equal('ModelTwo'); - expect(afterUpdateOpt).to.eql({ patch: true }); + expect(beforeUpdateCalled).toBe('ModelTwo'); + expect(beforeUpdateOpt).toEqual({ patch: true }); + expect(afterUpdateCalled).toBe('ModelTwo'); + expect(afterUpdateOpt).toEqual({ patch: true }); }); }); }); diff --git a/tests/integration/queryContext.js b/tests/integration/queryContext.js index c98d7d65e..502df3532 100644 --- a/tests/integration/queryContext.js +++ b/tests/integration/queryContext.js @@ -1,10 +1,9 @@ -const utils = require('../../lib/utils/knexUtils'); -const expect = require('expect.js'); -const chai = require('chai'); -const inheritModel = require('../../lib/model/inheritModel').inheritModel; -const knexMocker = require('../../testUtils/mockKnex'); +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import * as utils from '../../lib/utils/knexUtils.js'; +import { inheritModel } from '../../lib/model/inheritModel.js'; +import knexMocker from '../../testUtils/mockKnex.js'; -module.exports = (session) => { +export default (session) => { let Model1; let Model2; let mockKnex; @@ -14,7 +13,7 @@ module.exports = (session) => { // multiple other test sets. describe('Query context', () => { - before(() => { + beforeAll(() => { mockKnex = knexMocker(session.knex, function (mock, origImpl, args) { mock.executedQueries.push(this.toString()); @@ -83,10 +82,10 @@ module.exports = (session) => { let called = false; Model.prototype.$afterFind = (queryContext) => { - expect(queryContext).to.eql(context); - expect(context.transaction).to.equal(undefined); - expect(queryContext.transaction).to.equal(mockKnex); - expect(Object.keys(queryContext).indexOf('transaction')).to.equal(-1); + expect(queryContext).toEqual(context); + expect(context.transaction).toBeUndefined(); + expect(queryContext.transaction).toBe(mockKnex); + expect(Object.keys(queryContext).indexOf('transaction')).toBe(-1); called = true; }; @@ -94,7 +93,7 @@ module.exports = (session) => { .context(context) .where('id', 1) .then(() => { - expect(called).to.equal(true); + expect(called).toBe(true); }); }); @@ -104,10 +103,10 @@ module.exports = (session) => { let called = false; Model.prototype.$beforeUpdate = function (opt, queryContext) { - expect(queryContext).to.eql(context); - expect(context.transaction).to.equal(undefined); - expect(queryContext.transaction).to.equal(mockKnex); - expect(Object.keys(queryContext).indexOf('transaction')).to.equal(-1); + expect(queryContext).toEqual(context); + expect(context.transaction).toBeUndefined(); + expect(queryContext.transaction).toBe(mockKnex); + expect(Object.keys(queryContext).indexOf('transaction')).toBe(-1); called = true; }; @@ -116,7 +115,7 @@ module.exports = (session) => { .update({ model1Prop1: 'updated' }) .where('id', 1) .then(() => { - expect(called).to.equal(true); + expect(called).toBe(true); }); }); @@ -126,10 +125,10 @@ module.exports = (session) => { let called = false; Model.prototype.$afterUpdate = function (opt, queryContext) { - expect(queryContext).to.eql(context); - expect(context.transaction).to.equal(undefined); - expect(queryContext.transaction).to.equal(mockKnex); - expect(Object.keys(queryContext).indexOf('transaction')).to.equal(-1); + expect(queryContext).toEqual(context); + expect(context.transaction).toBeUndefined(); + expect(queryContext.transaction).toBe(mockKnex); + expect(Object.keys(queryContext).indexOf('transaction')).toBe(-1); called = true; }; @@ -138,7 +137,7 @@ module.exports = (session) => { .update({ model1Prop1: 'updated' }) .where('id', 1) .then(() => { - expect(called).to.equal(true); + expect(called).toBe(true); }); }); @@ -148,10 +147,10 @@ module.exports = (session) => { let called = false; Model.prototype.$beforeInsert = (queryContext) => { - expect(queryContext).to.eql(context); - expect(context.transaction).to.equal(undefined); - expect(queryContext.transaction).to.equal(mockKnex); - expect(Object.keys(queryContext).indexOf('transaction')).to.equal(-1); + expect(queryContext).toEqual(context); + expect(context.transaction).toBeUndefined(); + expect(queryContext.transaction).toBe(mockKnex); + expect(Object.keys(queryContext).indexOf('transaction')).toBe(-1); called = true; }; @@ -159,7 +158,7 @@ module.exports = (session) => { .context(context) .insert({ model1Prop1: 'new' }) .then(() => { - expect(called).to.equal(true); + expect(called).toBe(true); }); }); @@ -169,10 +168,10 @@ module.exports = (session) => { let called = false; Model.prototype.$afterInsert = (queryContext) => { - expect(queryContext).to.eql(context); - expect(context.transaction).to.equal(undefined); - expect(queryContext.transaction).to.equal(mockKnex); - expect(Object.keys(queryContext).indexOf('transaction')).to.equal(-1); + expect(queryContext).toEqual(context); + expect(context.transaction).toBeUndefined(); + expect(queryContext.transaction).toBe(mockKnex); + expect(Object.keys(queryContext).indexOf('transaction')).toBe(-1); called = true; }; @@ -180,7 +179,7 @@ module.exports = (session) => { .context(context) .insert({ model1Prop1: 'new' }) .then(() => { - expect(called).to.equal(true); + expect(called).toBe(true); }); }); @@ -190,10 +189,10 @@ module.exports = (session) => { let called = false; Model.prototype.$beforeDelete = (queryContext) => { - expect(queryContext).to.eql(context); - expect(context.transaction).to.equal(undefined); - expect(queryContext.transaction).to.equal(mockKnex); - expect(Object.keys(queryContext).indexOf('transaction')).to.equal(-1); + expect(queryContext).toEqual(context); + expect(context.transaction).toBeUndefined(); + expect(queryContext.transaction).toBe(mockKnex); + expect(Object.keys(queryContext).indexOf('transaction')).toBe(-1); called = true; }; @@ -202,7 +201,7 @@ module.exports = (session) => { .context(context) .delete() .then(() => { - expect(called).to.equal(true); + expect(called).toBe(true); }); }); @@ -212,10 +211,10 @@ module.exports = (session) => { let called = false; Model.prototype.$afterDelete = (queryContext) => { - expect(queryContext).to.eql(context); - expect(context.transaction).to.equal(undefined); - expect(queryContext.transaction).to.equal(mockKnex); - expect(Object.keys(queryContext).indexOf('transaction')).to.equal(-1); + expect(queryContext).toEqual(context); + expect(context.transaction).toBeUndefined(); + expect(queryContext.transaction).toBe(mockKnex); + expect(Object.keys(queryContext).indexOf('transaction')).toBe(-1); called = true; }; @@ -224,7 +223,7 @@ module.exports = (session) => { .context(context) .delete() .then(() => { - expect(called).to.equal(true); + expect(called).toBe(true); }); }); @@ -236,10 +235,10 @@ module.exports = (session) => { let called = false; Model.prototype.$afterDelete = (queryContext) => { - expect(queryContext).to.eql(Object.assign({}, context, merge1, merge2)); - expect(context.transaction).to.equal(undefined); - expect(queryContext.transaction).to.equal(mockKnex); - expect(Object.keys(queryContext).indexOf('transaction')).to.equal(-1); + expect(queryContext).toEqual(Object.assign({}, context, merge1, merge2)); + expect(context.transaction).toBeUndefined(); + expect(queryContext.transaction).toBe(mockKnex); + expect(Object.keys(queryContext).indexOf('transaction')).toBe(-1); called = true; }; @@ -250,7 +249,7 @@ module.exports = (session) => { .delete() .context(merge2) .then(() => { - expect(called).to.equal(true); + expect(called).toBe(true); }); }); @@ -277,13 +276,13 @@ module.exports = (session) => { }, }) .then((model) => { - expect(mockKnex.executedQueries).to.eql(queries); - expect(mockKnex.executedQueries).to.eql([ + expect(mockKnex.executedQueries).toEqual(queries); + expect(mockKnex.executedQueries).toEqual([ 'insert into "public"."Model1" ("model1Prop1") values (\'new\') returning *', 'select "Model1".* from "public"."Model1" where "Model1"."id" in (5)', ]); - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ model1Prop1: 'new', id: 5, model1Id: null, @@ -312,13 +311,13 @@ module.exports = (session) => { }, }) .then((model) => { - expect(mockKnex.executedQueries).to.eql(queries); - expect(mockKnex.executedQueries).to.eql([ + expect(mockKnex.executedQueries).toEqual(queries); + expect(mockKnex.executedQueries).toEqual([ 'update "public"."Model1" set "model1Prop1" = \'updated\' where "Model1"."id" = 1 returning *', 'select "Model1".* from "public"."Model1" where "Model1"."id" = 1', ]); - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ model1Prop1: 'updated', id: 1, model1Id: 2, @@ -396,20 +395,18 @@ module.exports = (session) => { }, }) .then((model) => { - expect(mockKnex.executedQueries.length).to.equal(4); - expect(mockKnex.executedQueries.length).to.equal(queries.length); - - chai.expect(mockKnex.executedQueries).to.containSubset(queries); - chai - .expect(mockKnex.executedQueries) - .to.containSubset([ - 'insert into "public"."Model1" ("model1Prop1") values (\'new 2\'), (\'new 4\') returning "id", "model1Prop1" || \' computed1\' as computed', - 'insert into "public"."Model1" ("model1Id", "model1Prop1") values (5, \'new 1\') returning "id", "model1Prop1" || \' computed1\' as computed', - 'insert into "public"."model2" ("model1_id", "model2_prop1") values (5, \'new 3\') returning "id_col", "model2_prop1" || \' computed2\' as computed', - 'insert into "public"."Model1Model2" ("model1Id", "model2Id") values (6, 3) returning "model1Id"', - ]); - - expect(model.$toJson()).to.eql({ + expect(mockKnex.executedQueries.length).toBe(4); + expect(mockKnex.executedQueries.length).toBe(queries.length); + + expect(mockKnex.executedQueries).toContainSubset(queries); + expect(mockKnex.executedQueries).toContainSubset([ + 'insert into "public"."Model1" ("model1Prop1") values (\'new 2\'), (\'new 4\') returning "id", "model1Prop1" || \' computed1\' as computed', + 'insert into "public"."Model1" ("model1Id", "model1Prop1") values (5, \'new 1\') returning "id", "model1Prop1" || \' computed1\' as computed', + 'insert into "public"."model2" ("model1_id", "model2_prop1") values (5, \'new 3\') returning "id_col", "model2_prop1" || \' computed2\' as computed', + 'insert into "public"."Model1Model2" ("model1Id", "model2Id") values (6, 3) returning "model1Id"', + ]); + + expect(model.$toJson()).toEqual({ id: 7, model1Id: 5, model1Prop1: 'new 1', @@ -490,7 +487,7 @@ module.exports = (session) => { builder.orderBy('id_col'); }) .then((models) => { - expect(queries).to.eql([ + expect(queries).toEqual([ 'select "Model1".*, "model1Prop1" || \' computed1\' as computed from "public"."Model1" where "id" = 1', 'select "Model1".*, "model1Prop1" || \' computed1\' as computed from "public"."Model1" where "Model1"."id" in (2)', 'select "Model1".*, "model1Prop1" || \' computed1\' as computed from "public"."Model1" where "Model1"."id" in (3)', @@ -498,7 +495,7 @@ module.exports = (session) => { 'select "Model1Model2"."model2Id" as "objectiontmpjoin0", "Model1".*, "model1Prop1" || \' computed1\' as computed from "public"."Model1" inner join "public"."Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1, 2)', ]); - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -573,8 +570,8 @@ module.exports = (session) => { }, }) .then(() => { - expect(queries).to.eql(mockKnex.executedQueries); - expect(queries).to.eql([ + expect(queries).toEqual(mockKnex.executedQueries); + expect(queries).toEqual([ 'select (select avg("model2Prop1") from "someSchema"."model2") as "avg" from "public"."Model1"', ]); }); @@ -596,8 +593,8 @@ module.exports = (session) => { }, }) .then(() => { - expect(queries).to.eql(mockKnex.executedQueries); - expect(queries).to.eql([ + expect(queries).toEqual(mockKnex.executedQueries); + expect(queries).toEqual([ 'select (select avg("model2Prop1") from "someSchema"."model2") as "avg" from "public"."Model1"', ]); }); @@ -621,8 +618,8 @@ module.exports = (session) => { }, }) .then(() => { - expect(queries).to.eql(mockKnex.executedQueries); - expect(queries).to.eql([ + expect(queries).toEqual(mockKnex.executedQueries); + expect(queries).toEqual([ 'select (select avg("model2Prop1") from "someSchema"."model2") as "avg" from "public"."Model1"', ]); }); @@ -646,8 +643,8 @@ module.exports = (session) => { }, }) .then(() => { - expect(queries).to.eql(mockKnex.executedQueries); - expect(queries).to.eql([ + expect(queries).toEqual(mockKnex.executedQueries); + expect(queries).toEqual([ 'select (select avg("model2Prop1") from "someSchema"."model2") as "avg" from "public"."Model1"', ]); }); @@ -700,13 +697,13 @@ module.exports = (session) => { }, }) .then((model) => { - expect(mockKnex.executedQueries).to.eql(queries); - expect(mockKnex.executedQueries).to.eql([ + expect(mockKnex.executedQueries).toEqual(queries); + expect(mockKnex.executedQueries).toEqual([ 'insert into "public"."Model1" ("model1Prop1") values (\'new\') returning "id", "model1Prop1" || \' computed1\' as computed', 'update "public"."Model1" set "model1Id" = 5 where "Model1"."id" in (4) returning "id", "model1Prop1" || \' computed1\' as computed', ]); - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ model1Prop1: 'new', id: 5, computed: 'new computed1', @@ -735,15 +732,15 @@ module.exports = (session) => { }, }) .then(() => { - expect(mockKnex.executedQueries).to.eql(queries); - expect(mockKnex.executedQueries).to.eql([ + expect(mockKnex.executedQueries).toEqual(queries); + expect(mockKnex.executedQueries).toEqual([ 'update "public"."Model1" set "model1Id" = 1 where "Model1"."id" in (4) returning *', ]); return session.knex('Model1').where('id', 4); }) .then((rows) => { - expect(rows[0].model1Id).to.eql(1); + expect(rows[0].model1Id).toBe(1); }) ); }); @@ -768,15 +765,15 @@ module.exports = (session) => { }, }) .then(() => { - expect(mockKnex.executedQueries).to.eql(queries); - expect(mockKnex.executedQueries).to.eql([ + expect(mockKnex.executedQueries).toEqual(queries); + expect(mockKnex.executedQueries).toEqual([ 'update "public"."Model1" set "model1Id" = NULL where "Model1"."id" in (2) returning *', ]); return session.knex('Model1').where('id', 2); }) .then((rows) => { - expect(rows[0].model1Id).to.eql(null); + expect(rows[0].model1Id).toBeNull(); }) ); }); @@ -820,15 +817,15 @@ module.exports = (session) => { }, }) .then(() => { - expect(mockKnex.executedQueries).to.eql(queries); - expect(mockKnex.executedQueries).to.eql([ + expect(mockKnex.executedQueries).toEqual(queries); + expect(mockKnex.executedQueries).toEqual([ 'update "public"."model2" set "model1_id" = 2 where "model2"."id_col" in (3) returning *', ]); return session.knex('model2').where('id_col', newModel.idCol); }) .then((rows) => { - expect(rows[0].model1_id).to.eql(2); + expect(rows[0].model1_id).toBe(2); }) ); }); @@ -853,8 +850,8 @@ module.exports = (session) => { }, }) .then(() => { - expect(mockKnex.executedQueries).to.eql(queries); - expect(mockKnex.executedQueries).to.eql([ + expect(mockKnex.executedQueries).toEqual(queries); + expect(mockKnex.executedQueries).toEqual([ 'update "public"."model2" set "model1_id" = NULL where "model2"."model1_id" in (2) returning *', ]); @@ -862,7 +859,7 @@ module.exports = (session) => { }) .then((rows) => { rows.forEach((row) => { - expect(row.model1_id).to.equal(null); + expect(row.model1_id).toBeNull(); }); }) ); @@ -907,13 +904,13 @@ module.exports = (session) => { }, }) .then((model) => { - expect(mockKnex.executedQueries).to.eql(queries); - expect(mockKnex.executedQueries).to.eql([ + expect(mockKnex.executedQueries).toEqual(queries); + expect(mockKnex.executedQueries).toEqual([ 'insert into "public"."Model1" ("model1Prop1") values (\'new\') returning "id", "model1Prop1" || \' computed1\' as computed', 'insert into "public"."Model1Model2" ("model1Id", "model2Id") values (5, 1) returning "model1Id"', ]); - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ model1Prop1: 'new', id: 5, computed: 'new computed1', @@ -942,17 +939,17 @@ module.exports = (session) => { }, }) .then(() => { - expect(mockKnex.executedQueries).to.eql(queries); - expect(mockKnex.executedQueries).to.eql([ + expect(mockKnex.executedQueries).toEqual(queries); + expect(mockKnex.executedQueries).toEqual([ 'insert into "public"."Model1Model2" ("model1Id", "model2Id") values (1, 1) returning *', ]); return session.knex('Model1Model2'); }) .then((rows) => { - expect( - rows.filter((it) => it.model1Id === 1 && it.model2Id === 1).length, - ).to.equal(1); + expect(rows.filter((it) => it.model1Id === 1 && it.model2Id === 1).length).toBe( + 1, + ); }) ); }); @@ -975,15 +972,15 @@ module.exports = (session) => { }, }) .then(() => { - expect(mockKnex.executedQueries).to.eql(queries); - expect(mockKnex.executedQueries).to.eql([ + expect(mockKnex.executedQueries).toEqual(queries); + expect(mockKnex.executedQueries).toEqual([ `delete from \"public\".\"Model1Model2\" where \"Model1Model2\".\"model2Id\" in (1) and exists (select 1 from \"public\".\"Model1\" where \"Model1\".\"id\" = \"Model1Model2\".\"model1Id\" and \"Model1\".\"id\" = 4)`, ]); return session.knex('Model1Model2'); }) .then((rows) => { - expect(rows).to.have.length(0); + expect(rows).toHaveLength(0); }) ); }); diff --git a/tests/integration/relate.js b/tests/integration/relate.js index 72b1c9105..f46b83dbf 100644 --- a/tests/integration/relate.js +++ b/tests/integration/relate.js @@ -1,23 +1,14 @@ -const expect = require('expect.js'); -const chai = require('chai'); -const { pick, sortBy } = require('../../testUtils/testUtils'); +import { describe, it, expect, beforeEach } from 'vitest'; +import { pick, sortBy } from '../../testUtils/testUtils.js'; -module.exports = (session) => { +export default (session) => { let Model1 = session.models.Model1; let Model2 = session.models.Model2; describe('Model relate queries', () => { describe('.$query()', () => { - it('should reject the query because relate makes no sense in this context', (done) => { - Model1.fromJson({ id: 1 }) - .$query() - .relate(1) - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - done(); - }); + it('should reject the query because relate makes no sense in this context', () => { + return expect(Model1.fromJson({ id: 1 }).$query().relate(1)).rejects.toThrow(); }); }); @@ -53,14 +44,14 @@ module.exports = (session) => { .$relatedQuery('model1Relation1') .relate(model2.id) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex(Model1.getTableName()).orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1Id).to.equal(model2.id); - expect(rows[1].model1Id).to.equal(null); - expect(rows[2].model1Id).to.equal(null); + expect(rows).toHaveLength(3); + expect(rows[0].model1Id).toBe(model2.id); + expect(rows[1].model1Id).toBeNull(); + expect(rows[2].model1Id).toBeNull(); }); }); @@ -69,14 +60,14 @@ module.exports = (session) => { .$relatedQuery('model1Relation1') .relate([model2.id]) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex(Model1.getTableName()).orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1Id).to.equal(model2.id); - expect(rows[1].model1Id).to.equal(null); - expect(rows[2].model1Id).to.equal(null); + expect(rows).toHaveLength(3); + expect(rows[0].model1Id).toBe(model2.id); + expect(rows[1].model1Id).toBeNull(); + expect(rows[2].model1Id).toBeNull(); }); }); @@ -85,14 +76,14 @@ module.exports = (session) => { .$relatedQuery('model1Relation1') .relate({ id: model2.id }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex(Model1.getTableName()).orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1Id).to.equal(model2.id); - expect(rows[1].model1Id).to.equal(null); - expect(rows[2].model1Id).to.equal(null); + expect(rows).toHaveLength(3); + expect(rows[0].model1Id).toBe(model2.id); + expect(rows[1].model1Id).toBeNull(); + expect(rows[2].model1Id).toBeNull(); }); }); @@ -101,36 +92,37 @@ module.exports = (session) => { .$relatedQuery('model1Relation1') .relate(model2) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex(Model1.getTableName()).orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1Id).to.equal(model2.id); - expect(rows[1].model1Id).to.equal(null); - expect(rows[2].model1Id).to.equal(null); + expect(rows).toHaveLength(3); + expect(rows[0].model1Id).toBe(model2.id); + expect(rows[1].model1Id).toBeNull(); + expect(rows[2].model1Id).toBeNull(); }); }); - it('should fail with invalid object value)', (done) => { - model1 + it('should fail with invalid object value)', () => { + return model1 .$relatedQuery('model1Relation1') .relate({ wrongId: model2.id }) - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - return session - .knex(Model1.getTableName()) - .orderBy('id') - .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1Id).to.equal(null); - expect(rows[1].model1Id).to.equal(null); - expect(rows[2].model1Id).to.equal(null); - done(); - }); - }); + .then( + () => { + throw new Error('should not get here'); + }, + () => { + return session + .knex(Model1.getTableName()) + .orderBy('id') + .then((rows) => { + expect(rows).toHaveLength(3); + expect(rows[0].model1Id).toBeNull(); + expect(rows[1].model1Id).toBeNull(); + expect(rows[2].model1Id).toBeNull(); + }); + }, + ); }); if (session.isPostgres()) { @@ -140,11 +132,11 @@ module.exports = (session) => { .relate(model2.id) .returning('*'); - expect(result).to.have.length(1); - expect(result[0]).to.be.a(Model1); - expect(result[0].id).to.equal(1); - expect(result[0].model1Id).to.equal(model2.id); - expect(result[0].model1Prop1).to.equal('hello 1'); + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(Model1); + expect(result[0].id).toBe(1); + expect(result[0].model1Id).toBe(model2.id); + expect(result[0].model1Prop1).toBe('hello 1'); }); } }); @@ -196,14 +188,14 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation2').relate(2); }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex(Model2.getTableName()).orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1_id).to.equal(1); - expect(rows[1].model1_id).to.equal(1); - expect(rows[2].model1_id).to.equal(3); + expect(rows).toHaveLength(3); + expect(rows[0].model1_id).toBe(1); + expect(rows[1].model1_id).toBe(1); + expect(rows[2].model1_id).toBe(3); }); }); @@ -215,14 +207,14 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation2').relate([2, 3]); }) .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex(Model2.getTableName()).orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1_id).to.equal(1); - expect(rows[1].model1_id).to.equal(1); - expect(rows[2].model1_id).to.equal(1); + expect(rows).toHaveLength(3); + expect(rows[0].model1_id).toBe(1); + expect(rows[1].model1_id).toBe(1); + expect(rows[2].model1_id).toBe(1); }); }); @@ -234,14 +226,14 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation2').relate({ idCol: 2 }); }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex(Model2.getTableName()).orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1_id).to.equal(1); - expect(rows[1].model1_id).to.equal(1); - expect(rows[2].model1_id).to.equal(3); + expect(rows).toHaveLength(3); + expect(rows[0].model1_id).toBe(1); + expect(rows[1].model1_id).toBe(1); + expect(rows[2].model1_id).toBe(3); }); }); @@ -253,14 +245,14 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation2').relate([{ idCol: 2 }, { idCol: 3 }]); }) .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex(Model2.getTableName()).orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1_id).to.equal(1); - expect(rows[1].model1_id).to.equal(1); - expect(rows[2].model1_id).to.equal(1); + expect(rows).toHaveLength(3); + expect(rows[0].model1_id).toBe(1); + expect(rows[1].model1_id).toBe(1); + expect(rows[2].model1_id).toBe(1); }); }); @@ -272,9 +264,9 @@ module.exports = (session) => { .relate([2, 3]) .returning('*'); - expect(result).to.have.length(2); - expect(result[0]).to.be.a(Model2); - expect(sortBy(result, 'idCol').map((it) => pick(it, 'idCol', 'model1Id'))).to.eql([ + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(Model2); + expect(sortBy(result, 'idCol').map((it) => pick(it, 'idCol', 'model1Id'))).toEqual([ { idCol: 2, model1Id: 1 }, { idCol: 3, model1Id: 1 }, ]); @@ -340,54 +332,42 @@ module.exports = (session) => { return model.$relatedQuery('model2Relation1').relate(5); }) .then((res) => { - expect(res).to.equal(1); + expect(res).toBe(1); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(5); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 4)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 5)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).to.have.length(1); + expect(rows).toHaveLength(5); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 4)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 5)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).toHaveLength(1); }); }); - if (session.isPostgres()) { - it('should relate (multiple values)', () => { - return Model2.query() - .where('id_col', 1) - .first() - .then((model) => { - return model.$relatedQuery('model2Relation1').relate([5, 6]); - }) - .then((res) => { - expect(res).to.equal(2); - return session.knex('Model1Model2').orderBy('id'); - }) - .then((rows) => { - expect(rows).to.have.length(6); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).to.have.length( - 1, - ); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).to.have.length( - 1, - ); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 6)).to.have.length( - 1, - ); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 4)).to.have.length( - 1, - ); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 5)).to.have.length( - 1, - ); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).to.have.length( - 1, - ); - }); - }); + it('should relate (multiple values)', () => { + return Model2.query() + .where('id_col', 1) + .first() + .then((model) => { + return model.$relatedQuery('model2Relation1').relate([5, 6]); + }) + .then((res) => { + expect(res).toBe(2); + return session.knex('Model1Model2').orderBy('id'); + }) + .then((rows) => { + expect(rows).toHaveLength(6); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 6)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 4)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 5)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).toHaveLength(1); + }); + }); + if (session.isPostgres()) { it('should relate with onConflict().ignore()', async () => { const sql = await Model2.relatedQuery('model2Relation1') .for(1) @@ -397,31 +377,35 @@ module.exports = (session) => { .toKnexQuery() .toSQL().sql; - expect(sql).to.equal( + expect(sql).toBe( 'insert into "Model1Model2" ("model1Id", "model2Id") values (?, ?) on conflict ("model1Id") do nothing returning "model1Id"', ); }); + } - it('should return the inserted join rows with returning()', async () => { + // MySQL has no `returning()`. + it.skipIf(session.isMySql())( + 'should return the inserted join rows with returning()', + async () => { const model = await Model2.query().findById(1); const result = await model .$relatedQuery('model2Relation1') .relate([5, 6]) .returning('*'); - expect(result).to.have.length(2); - expect(result.every((it) => typeof it.id === 'number')).to.equal(true); - expect(sortBy(result, 'model1Id').map((it) => pick(it, 'model1Id', 'model2Id'))).to.eql( - [ - { model1Id: 5, model2Id: 1 }, - { model1Id: 6, model2Id: 1 }, - ], - ); + expect(result).toHaveLength(2); + expect(result.every((it) => typeof it.id === 'number')).toBe(true); + expect( + sortBy(result, 'model1Id').map((it) => pick(it, 'model1Id', 'model2Id')), + ).toEqual([ + { model1Id: 5, model2Id: 1 }, + { model1Id: 6, model2Id: 1 }, + ]); const rows = await session.knex('Model1Model2').orderBy('id'); - expect(rows).to.have.length(6); - }); - } + expect(rows).toHaveLength(6); + }, + ); it('should relate (object value)', () => { return Model2.query() @@ -431,16 +415,16 @@ module.exports = (session) => { return model.$relatedQuery('model2Relation1').relate({ id: 5 }); }) .then((res) => { - expect(res).to.equal(1); + expect(res).toBe(1); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(5); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 4)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 5)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).to.have.length(1); + expect(rows).toHaveLength(5); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 4)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 5)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).toHaveLength(1); }); }); @@ -454,20 +438,20 @@ module.exports = (session) => { .relate({ id: 5, aliasedExtra: 'foobar' }); }) .then((res) => { - expect(res).to.equal(1); + expect(res).toBe(1); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(5); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).to.have.length(1); + expect(rows).toHaveLength(5); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).toHaveLength(1); expect( rows.filter( (it) => it.model2Id === 1 && it.model1Id === 5 && it.extra3 === 'foobar', ), - ).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 4)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 5)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).to.have.length(1); + ).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 4)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 5)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).toHaveLength(1); }); }); }); @@ -501,12 +485,12 @@ module.exports = (session) => { return model.$relatedQuery('model2Relation2').relate(2); }) .then((res) => { - expect(res).to.equal(1); + expect(res).toBe(1); return session.knex('Model1Model2One'); }) .then((rows) => { - expect(rows).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 2)).to.have.length(1); + expect(rows).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 2)).toHaveLength(1); }); }); }); @@ -536,14 +520,14 @@ module.exports = (session) => { .for(1) .relate(2) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex(Model1.getTableName()).orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1Id).to.equal(2); - expect(rows[1].model1Id).to.equal(null); - expect(rows[2].model1Id).to.equal(null); + expect(rows).toHaveLength(3); + expect(rows[0].model1Id).toBe(2); + expect(rows[1].model1Id).toBeNull(); + expect(rows[2].model1Id).toBeNull(); }); }); @@ -552,14 +536,14 @@ module.exports = (session) => { .for(1) .relate({ id: 2 }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex(Model1.getTableName()).orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1Id).to.equal(2); - expect(rows[1].model1Id).to.equal(null); - expect(rows[2].model1Id).to.equal(null); + expect(rows).toHaveLength(3); + expect(rows[0].model1Id).toBe(2); + expect(rows[1].model1Id).toBeNull(); + expect(rows[2].model1Id).toBeNull(); }); }); @@ -568,14 +552,14 @@ module.exports = (session) => { .for([1, 3]) .relate(2) .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex(Model1.getTableName()).orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1Id).to.equal(2); - expect(rows[1].model1Id).to.equal(null); - expect(rows[2].model1Id).to.equal(2); + expect(rows).toHaveLength(3); + expect(rows[0].model1Id).toBe(2); + expect(rows[1].model1Id).toBeNull(); + expect(rows[2].model1Id).toBe(2); }); }); @@ -584,36 +568,37 @@ module.exports = (session) => { .for(Model1.query().findByIds([1, 3])) .relate(2) .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex(Model1.getTableName()).orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1Id).to.equal(2); - expect(rows[1].model1Id).to.equal(null); - expect(rows[2].model1Id).to.equal(2); + expect(rows).toHaveLength(3); + expect(rows[0].model1Id).toBe(2); + expect(rows[1].model1Id).toBeNull(); + expect(rows[2].model1Id).toBe(2); }); }); - it('should fail with invalid object value)', (done) => { - Model1.relatedQuery('model1Relation1') + it('should fail with invalid object value)', () => { + return Model1.relatedQuery('model1Relation1') .for(1) .relate({ wrongId: 2 }) - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - return session - .knex(Model1.getTableName()) - .orderBy('id') - .then((rows) => { - expect(rows).to.have.length(3); - expect(rows[0].model1Id).to.equal(null); - expect(rows[1].model1Id).to.equal(null); - expect(rows[2].model1Id).to.equal(null); - done(); - }); - }); + .then( + () => { + throw new Error('should not get here'); + }, + () => { + return session + .knex(Model1.getTableName()) + .orderBy('id') + .then((rows) => { + expect(rows).toHaveLength(3); + expect(rows[0].model1Id).toBeNull(); + expect(rows[1].model1Id).toBeNull(); + expect(rows[2].model1Id).toBeNull(); + }); + }, + ); }); }); @@ -676,12 +661,12 @@ module.exports = (session) => { .for(1) .relate(3) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(6); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(6); + expect(rows).toContainSubset([ { id_col: 1, model1_id: 1 }, { id_col: 2, model1_id: 1 }, { id_col: 3, model1_id: 1 }, @@ -697,12 +682,12 @@ module.exports = (session) => { .for(Model1.query().findByIds(1)) .relate(3) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(6); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(6); + expect(rows).toContainSubset([ { id_col: 1, model1_id: 1 }, { id_col: 2, model1_id: 1 }, { id_col: 3, model1_id: 1 }, @@ -713,20 +698,18 @@ module.exports = (session) => { }); }); - it('should fail with multiple values', (done) => { - Model1.relatedQuery('model1Relation2') + it('should fail with multiple values', () => { + return Model1.relatedQuery('model1Relation2') .for([1, 2]) .relate(3) .then(() => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( "Can only relate items for one parent at a time in case of HasManyRelation. Otherwise multiple update queries would need to be created. If you need to relate items for multiple parents, simply loop through them. That's the most performant way.", ); - done(); - }) - .catch(done); + }); }); it('should relate (multiple values)', () => { @@ -734,12 +717,12 @@ module.exports = (session) => { .for(1) .relate([3, 5]) .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex(Model2.getTableName()).orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(6); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(6); + expect(rows).toContainSubset([ { id_col: 1, model1_id: 1 }, { id_col: 2, model1_id: 1 }, { id_col: 3, model1_id: 1 }, @@ -755,12 +738,12 @@ module.exports = (session) => { .for(1) .relate({ idCol: 3 }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(6); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(6); + expect(rows).toContainSubset([ { id_col: 1, model1_id: 1 }, { id_col: 2, model1_id: 1 }, { id_col: 3, model1_id: 1 }, @@ -776,12 +759,12 @@ module.exports = (session) => { .for(1) .relate([{ idCol: 3 }, { idCol: 5 }]) .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex(Model2.getTableName()).orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(6); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(6); + expect(rows).toContainSubset([ { id_col: 1, model1_id: 1 }, { id_col: 2, model1_id: 1 }, { id_col: 3, model1_id: 1 }, @@ -848,12 +831,12 @@ module.exports = (session) => { .for(1) .relate(5) .then((res) => { - expect(res).to.equal(1); + expect(res).toBe(1); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(5); - chai.expect(rows).to.containSubset([ + expect(rows).toHaveLength(5); + expect(rows).toContainSubset([ { model2Id: 1, model1Id: 3 }, { model2Id: 1, model1Id: 5 }, { model2Id: 2, model1Id: 4 }, @@ -868,12 +851,12 @@ module.exports = (session) => { .for(Model2.query().findById(1)) .relate(5) .then((res) => { - expect(res).to.equal(1); + expect(res).toBe(1); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(5); - chai.expect(rows).to.containSubset([ + expect(rows).toHaveLength(5); + expect(rows).toContainSubset([ { model2Id: 1, model1Id: 3 }, { model2Id: 1, model1Id: 5 }, { model2Id: 2, model1Id: 4 }, @@ -889,12 +872,12 @@ module.exports = (session) => { .for(1) .relate([5, 6]) .then((res) => { - expect(res).to.equal(2); + expect(res).toBe(2); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(6); - chai.expect(rows).to.containSubset([ + expect(rows).toHaveLength(6); + expect(rows).toContainSubset([ { model2Id: 1, model1Id: 3 }, { model2Id: 1, model1Id: 5 }, { model2Id: 1, model1Id: 6 }, @@ -913,8 +896,8 @@ module.exports = (session) => { return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(6); - chai.expect(rows).to.containSubset([ + expect(rows).toHaveLength(6); + expect(rows).toContainSubset([ { model2Id: 1, model1Id: 3 }, { model2Id: 1, model1Id: 5 }, { model2Id: 1, model1Id: 6 }, @@ -933,8 +916,8 @@ module.exports = (session) => { return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(8); - chai.expect(rows).to.containSubset([ + expect(rows).toHaveLength(8); + expect(rows).toContainSubset([ { model2Id: 1, model1Id: 1 }, { model2Id: 1, model1Id: 2 }, { model2Id: 1, model1Id: 3 }, @@ -953,12 +936,12 @@ module.exports = (session) => { .for(1) .relate({ id: 5 }) .then((res) => { - expect(res).to.eql(1); + expect(res).toBe(1); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(5); - chai.expect(rows).to.containSubset([ + expect(rows).toHaveLength(5); + expect(rows).toContainSubset([ { model2Id: 1, model1Id: 3 }, { model2Id: 1, model1Id: 5 }, { model2Id: 2, model1Id: 4 }, @@ -973,12 +956,12 @@ module.exports = (session) => { .for(1) .relate({ id: 5, aliasedExtra: 'foobar' }) .then((res) => { - expect(res).to.eql(1); + expect(res).toBe(1); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(5); - chai.expect(rows).to.containSubset([ + expect(rows).toHaveLength(5); + expect(rows).toContainSubset([ { model2Id: 1, model1Id: 3 }, { model2Id: 1, model1Id: 5, extra3: 'foobar' }, { model2Id: 2, model1Id: 4 }, diff --git a/tests/integration/relationModify.js b/tests/integration/relationModify.js index 9c2d8f91d..d40b6d37c 100644 --- a/tests/integration/relationModify.js +++ b/tests/integration/relationModify.js @@ -1,7 +1,7 @@ -const { Model } = require('../../'); -const expect = require('chai').expect; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('relation modify hooks', () => { class Person extends Model { static get tableName() { @@ -77,7 +77,7 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('personMovie') .dropTableIfExists('animal') @@ -103,7 +103,7 @@ module.exports = (session) => { }); }); - before(() => { + beforeAll(() => { Person.knex(session.knex); Animal.knex(session.knex); Movie.knex(session.knex); @@ -163,7 +163,7 @@ module.exports = (session) => { .$relatedQuery('parent') .context(modifyBelongsToOne((qb) => qb.select('name'))); }) - .then((gustav) => expect(gustav.name).to.eql('Gustav')); + .then((gustav) => expect(gustav.name).toBe('Gustav')); }); it('update', () => { @@ -174,7 +174,7 @@ module.exports = (session) => { .context(modifyBelongsToOne((qb) => qb.where('name', 'Not Gustav'))) .update({ name: 'Updated' }); }) - .then((numUpdated) => expect(numUpdated).to.equal(0)) + .then((numUpdated) => expect(numUpdated).toBe(0)) .then(findArnold) .then((arnold) => { return arnold @@ -182,7 +182,7 @@ module.exports = (session) => { .context(modifyBelongsToOne((qb) => qb.where('name', 'Gustav'))) .update({ name: 'Updated' }); }) - .then((numUpdated) => expect(numUpdated).to.equal(1)); + .then((numUpdated) => expect(numUpdated).toBe(1)); }); it('delete', () => { @@ -193,7 +193,7 @@ module.exports = (session) => { .context(modifyBelongsToOne((qb) => qb.where('name', 'Not Gustav'))) .delete(); }) - .then((numDeleted) => expect(numDeleted).to.equal(0)) + .then((numDeleted) => expect(numDeleted).toBe(0)) .then(findArnold) .then((arnold) => { return arnold @@ -201,7 +201,7 @@ module.exports = (session) => { .context(modifyBelongsToOne((qb) => qb.where('name', 'Gustav'))) .delete(); }) - .then((numDeleted) => expect(numDeleted).to.equal(1)); + .then((numDeleted) => expect(numDeleted).toBe(1)); }); it('insert', () => { @@ -215,7 +215,7 @@ module.exports = (session) => { }) .then(findArnold) .then((arnold) => arnold.$relatedQuery('parent')) - .then((gustavNeue) => expect(gustavNeue.name).to.equal('Gustav-neue')); + .then((gustavNeue) => expect(gustavNeue.name).toBe('Gustav-neue')); }); it('relate', () => { @@ -230,22 +230,67 @@ module.exports = (session) => { }) .then(findArnold) .then((arnold) => arnold.$relatedQuery('parent')) - .then((meinhard) => expect(meinhard.name).to.equal('Meinhard')); + .then((meinhard) => expect(meinhard.name).toBe('Meinhard')); }); - it('unrelate', () => { - return findArnold() - .then((arnold) => { - // The filter should not affect unrelates because the unrelate - // query is directed at the owner, not the related model. - return arnold - .$relatedQuery('parent') - .context(modifyBelongsToOne((qb) => qb.where('name', 'Not Gustav'))) - .unrelate(); - }) - .then(findArnold) - .then((arnold) => arnold.$relatedQuery('parent')) - .then((parent) => expect(parent).to.eql(undefined)); + it('unrelate', async () => { + const arnold = await findArnold(); + const meinhard = await findMeinhard(); + await meinhard.$relatedQuery('parent').relate(arnold.id); + + // The filter selects the related items to unrelate, even though the + // unrelate query is directed at the owners. + const numUnrelated = await Person.relatedQuery('parent') + .for([arnold.id, meinhard.id]) + .context(modifyBelongsToOne((qb) => qb.where('name', 'Not Gustav'))) + .unrelate(); + expect(numUnrelated).toBe(0); + expect(await findParentNames()).toEqual(['Gustav', 'Arnold']); + + await Person.relatedQuery('parent') + .for([arnold.id, meinhard.id]) + .context(modifyBelongsToOne((qb) => qb.where('name', 'Gustav'))) + .unrelate(); + expect(await findParentNames()).toEqual([null, 'Arnold']); + + async function findParentNames() { + const persons = await Person.query() + .whereIn('name', ['Arnold', 'Meinhard']) + .withGraphFetched('parent') + .orderBy('name'); + return persons.map((person) => person.parent && person.parent.name); + } + }); + + it('unrelate should only clear the keys of the owner models that it unrelates', async () => { + const arnold = await findArnold(); + const meinhard = await findMeinhard(); + await meinhard.$relatedQuery('parent').relate(arnold.id); + const gustavId = arnold.parentId; + + expect( + await arnold + .$relatedQuery('parent') + .context(modifyBelongsToOne((qb) => qb.where('name', 'Not Gustav'))) + .unrelate(), + ).toBe(0); + expect(arnold.parentId).toBe(gustavId); + + expect( + await Person.relatedQuery('parent') + .for([arnold, meinhard]) + .context(modifyBelongsToOne((qb) => qb.where('name', 'Gustav'))) + .unrelate(), + ).toBe(1); + expect([arnold.parentId, meinhard.parentId]).toEqual([null, arnold.id]); + + expect( + await meinhard + .$relatedQuery('parent') + .context(modifyBelongsToOne((qb) => qb.where('name', 'Arnold'))) + .unrelate(), + ).toBe(1); + expect(meinhard.parentId).toBe(null); }); }); @@ -257,7 +302,7 @@ module.exports = (session) => { .$relatedQuery('pets') .context(modifyHasMany((qb) => qb.select('name').orderBy('name'))); }) - .then((pets) => expect(pets.map((it) => it.name)).to.eql(['Freud', 'Stalin'])); + .then((pets) => expect(pets.map((it) => it.name)).toEqual(['Freud', 'Stalin'])); }); it('update', () => { @@ -268,7 +313,7 @@ module.exports = (session) => { .context(modifyHasMany((qb) => qb.where('name', 'None of the pets'))) .update({ name: 'Updated' }); }) - .then((numUpdated) => expect(numUpdated).to.equal(0)) + .then((numUpdated) => expect(numUpdated).toBe(0)) .then(findArnold) .then((arnold) => { return arnold @@ -276,7 +321,7 @@ module.exports = (session) => { .context(modifyHasMany((qb) => qb.where('name', 'Freud'))) .update({ name: 'Updated' }); }) - .then((numUpdated) => expect(numUpdated).to.equal(1)); + .then((numUpdated) => expect(numUpdated).toBe(1)); }); it('delete', () => { @@ -287,7 +332,7 @@ module.exports = (session) => { .context(modifyHasMany((qb) => qb.where('name', 'None of the pets'))) .delete(); }) - .then((numDeleted) => expect(numDeleted).to.equal(0)) + .then((numDeleted) => expect(numDeleted).toBe(0)) .then(findArnold) .then((arnold) => { return arnold @@ -295,7 +340,7 @@ module.exports = (session) => { .context(modifyHasMany((qb) => qb.where('name', 'Stalin'))) .delete(); }) - .then((numDeleted) => expect(numDeleted).to.equal(1)); + .then((numDeleted) => expect(numDeleted).toBe(1)); }); it('insert', () => { @@ -309,7 +354,7 @@ module.exports = (session) => { }) .then(findArnold) .then((arnold) => arnold.$relatedQuery('pets').orderBy('name').select('name')) - .then((pets) => expect(pets.map((it) => it.name)).to.eql(['Cat', 'Freud', 'Stalin'])); + .then((pets) => expect(pets.map((it) => it.name)).toEqual(['Cat', 'Freud', 'Stalin'])); }); it('relate', () => { @@ -322,7 +367,7 @@ module.exports = (session) => { }) .then(findArnold) .then((arnold) => arnold.$relatedQuery('pets').orderBy('name').select('name')) - .then((pets) => expect(pets.map((it) => it.name)).to.eql(['Freud', 'Stalin'])) + .then((pets) => expect(pets.map((it) => it.name)).toEqual(['Freud', 'Stalin'])) .then(() => Promise.all([findArnold(), findRuffus()])) .then(([arnold, ruffus]) => { return arnold @@ -333,7 +378,7 @@ module.exports = (session) => { .then(findArnold) .then((arnold) => arnold.$relatedQuery('pets').orderBy('name').select('name')) .then((pets) => - expect(pets.map((it) => it.name)).to.eql(['Freud', 'Ruffus', 'Stalin']), + expect(pets.map((it) => it.name)).toEqual(['Freud', 'Ruffus', 'Stalin']), ); }); @@ -347,7 +392,7 @@ module.exports = (session) => { }) .then(findArnold) .then((arnold) => arnold.$relatedQuery('pets').orderBy('name').select('name')) - .then((pets) => expect(pets.map((it) => it.name)).to.eql(['Freud', 'Stalin'])) + .then((pets) => expect(pets.map((it) => it.name)).toEqual(['Freud', 'Stalin'])) .then(findArnold) .then((arnold) => { return arnold @@ -357,7 +402,7 @@ module.exports = (session) => { }) .then(findArnold) .then((arnold) => arnold.$relatedQuery('pets').orderBy('name').select('name')) - .then((pets) => expect(pets.map((it) => it.name)).to.eql(['Freud'])); + .then((pets) => expect(pets.map((it) => it.name)).toEqual(['Freud'])); }); }); @@ -370,7 +415,7 @@ module.exports = (session) => { .context(modifyManyToMany((qb) => qb.select('name').orderBy('name'))); }) .then((movies) => - expect(movies.map((it) => it.name)).to.eql(['Terminator', 'Terminator 2']), + expect(movies.map((it) => it.name)).toEqual(['Terminator', 'Terminator 2']), ); }); @@ -383,7 +428,7 @@ module.exports = (session) => { .context(modifyManyToMany((qb) => qb.where('name', 'None of the movies'))) .update({ name: 'Updated' }); }) - .then((numUpdated) => expect(numUpdated).to.equal(0)) + .then((numUpdated) => expect(numUpdated).toBe(0)) .then(findArnold) .then((arnold) => { return arnold @@ -391,7 +436,7 @@ module.exports = (session) => { .context(modifyManyToMany((qb) => qb.where('name', 'Terminator'))) .update({ name: 'Updated' }); }) - .then((numUpdated) => expect(numUpdated).to.equal(1)); + .then((numUpdated) => expect(numUpdated).toBe(1)); }); it('modifier with selects', () => { @@ -402,7 +447,7 @@ module.exports = (session) => { .context(modifyManyToMany((qb) => qb.where('name', 'None of the movies'))) .update({ name: 'Updated' }); }) - .then((numUpdated) => expect(numUpdated).to.equal(0)) + .then((numUpdated) => expect(numUpdated).toBe(0)) .then(findArnold) .then((arnold) => { return arnold @@ -410,7 +455,7 @@ module.exports = (session) => { .context(modifyManyToMany((qb) => qb.where('name', 'Terminator').select('name'))) .update({ name: 'Updated' }); }) - .then((numUpdated) => expect(numUpdated).to.equal(1)); + .then((numUpdated) => expect(numUpdated).toBe(1)); }); }); }); @@ -442,7 +487,7 @@ module.exports = (session) => { .context(modifyHasOne((qb) => qb.orderBy('name', 'desc').first())) .orderBy('person.name'); - expect(people.map((person) => [person.name, person.pet?.name ?? null])).to.eql([ + expect(people.map((person) => [person.name, person.pet?.name ?? null])).toEqual([ ['Arnold', 'Stalin'], ['Gustav', null], ['Meinhard', null], @@ -452,11 +497,7 @@ module.exports = (session) => { } }); - it('has many relation with distinctOn', async function () { - if (!session.isPostgres()) { - return this.skip(); - } - + it.skipIf(!session.isPostgres())('has many relation with distinctOn', async () => { // The order decides which of the pets named 'Freud' is selected. const meinhard = await findMeinhard(); await meinhard.$relatedQuery('pets').insert({ name: 'Freud' }); @@ -469,7 +510,7 @@ module.exports = (session) => { expect( people.map((person) => [person.name, person.pets.map((pet) => pet.name).sort()]), - ).to.eql([ + ).toEqual([ ['Arnold', ['Stalin']], ['Gustav', []], ['Meinhard', ['Freud', 'Ruffus']], @@ -485,7 +526,7 @@ module.exports = (session) => { it('many to many relation', () => {}); }); - after(() => { + afterAll(() => { return session.knex.schema .dropTableIfExists('personMovie') .dropTableIfExists('animal') diff --git a/tests/integration/relationOwnerGrouping.js b/tests/integration/relationOwnerGrouping.js index ef4ca627e..698d53b55 100644 --- a/tests/integration/relationOwnerGrouping.js +++ b/tests/integration/relationOwnerGrouping.js @@ -1,9 +1,9 @@ -const expect = require('expect.js'); +import { describe, it, expect, beforeEach } from 'vitest'; // The relation's owner condition must always be ANDed with the user's where // clauses as a group. Otherwise `orWhere` would find, update or delete rows of // other owners. See issues #2191 and #1909. -module.exports = (session) => { +export default (session) => { const { Model1, Model2 } = session.models; describe('relation owner condition grouping with orWhere (#2191)', () => { @@ -60,7 +60,7 @@ module.exports = (session) => { .where('model2_prop1', 'a') .orWhere('model2_prop1', 'b'); - expect(ids(pets)).to.eql([1, 2]); + expect(ids(pets)).toEqual([1, 2]); }); it('delete', async () => { @@ -70,8 +70,8 @@ module.exports = (session) => { .orWhere('model2_prop1', 'b') .delete(); - expect(numDeleted).to.equal(2); - expect(await model2Ids()).to.eql([3, 4, 5, 6, 7, 8, 9, 10]); + expect(numDeleted).toBe(2); + expect(await model2Ids()).toEqual([3, 4, 5, 6, 7, 8, 9, 10]); }); it('patch', async () => { @@ -81,8 +81,8 @@ module.exports = (session) => { .orWhere('model2_prop1', 'b') .patch({ model2Prop2: 100 }); - expect(numPatched).to.equal(2); - expect(await model2Ids((it) => it.model2_prop2 === 100)).to.eql([1, 2]); + expect(numPatched).toBe(2); + expect(await model2Ids((it) => it.model2_prop2 === 100)).toEqual([1, 2]); }); it('update', async () => { @@ -92,8 +92,8 @@ module.exports = (session) => { .orWhere('model2_prop1', 'b') .update({ model2Prop1: 'x' }); - expect(numUpdated).to.equal(2); - expect(await model2Ids((it) => it.model2_prop1 === 'x')).to.eql([1, 2]); + expect(numUpdated).toBe(2); + expect(await model2Ids((it) => it.model2_prop1 === 'x')).toEqual([1, 2]); }); it('patchAndFetchById', async () => { @@ -103,8 +103,8 @@ module.exports = (session) => { .orWhere('model2_prop1', 'b') .patchAndFetchById(2, { model2Prop2: 100 }); - expect(patched.idCol).to.equal(2); - expect(await model2Ids((it) => it.model2_prop2 === 100)).to.eql([2]); + expect(patched.idCol).toBe(2); + expect(await model2Ids((it) => it.model2_prop2 === 100)).toEqual([2]); }); it('unrelate', async () => { @@ -114,9 +114,9 @@ module.exports = (session) => { .orWhere('model2_prop1', 'b') .unrelate(); - expect(numUnrelated).to.equal(2); - expect(await model2Ids((it) => it.model1_id === 1)).to.eql([3]); - expect(await model2Ids((it) => it.model1_id === 2)).to.eql([4, 5]); + expect(numUnrelated).toBe(2); + expect(await model2Ids((it) => it.model1_id === 1)).toEqual([3]); + expect(await model2Ids((it) => it.model1_id === 2)).toEqual([4, 5]); }); it('static relatedQuery().for() delete', async () => { @@ -126,8 +126,8 @@ module.exports = (session) => { .orWhere('model2_prop1', 'b') .delete(); - expect(numDeleted).to.equal(2); - expect(await model2Ids()).to.eql([3, 4, 5, 6, 7, 8, 9, 10]); + expect(numDeleted).toBe(2); + expect(await model2Ids()).toEqual([3, 4, 5, 6, 7, 8, 9, 10]); }); }); @@ -138,7 +138,7 @@ module.exports = (session) => { .where('model1Prop1', 'a') .orWhere('model1Prop1', 'b'); - expect(ids([].concat(parents || []), 'id')).to.eql([3]); + expect(ids([].concat(parents || []), 'id')).toEqual([3]); }); it('patch', async () => { @@ -148,9 +148,9 @@ module.exports = (session) => { .orWhere('model1Prop1', 'b') .patch({ model1Prop2: 100 }); - expect(numPatched).to.equal(1); + expect(numPatched).toBe(1); const rows = await session.knex('Model1').where('model1Prop2', 100); - expect(ids(rows, 'id')).to.eql([3]); + expect(ids(rows, 'id')).toEqual([3]); }); }); @@ -161,7 +161,7 @@ module.exports = (session) => { .where('model2_prop1', 'a') .orWhere('model2_prop1', 'b'); - expect(ids(related)).to.eql([6, 7]); + expect(ids(related)).toEqual([6, 7]); }); it('delete', async () => { @@ -171,8 +171,8 @@ module.exports = (session) => { .orWhere('model2_prop1', 'b') .delete(); - expect(numDeleted).to.equal(2); - expect(await model2Ids()).to.eql([1, 2, 3, 4, 5, 8, 9, 10]); + expect(numDeleted).toBe(2); + expect(await model2Ids()).toEqual([1, 2, 3, 4, 5, 8, 9, 10]); }); it('patch', async () => { @@ -182,8 +182,8 @@ module.exports = (session) => { .orWhere('model2_prop1', 'b') .patch({ model2Prop2: 100 }); - expect(numPatched).to.equal(2); - expect(await model2Ids((it) => it.model2_prop2 === 100)).to.eql([6, 7]); + expect(numPatched).toBe(2); + expect(await model2Ids((it) => it.model2_prop2 === 100)).toEqual([6, 7]); }); it('unrelate', async () => { @@ -193,9 +193,9 @@ module.exports = (session) => { .orWhere('model2_prop1', 'b') .unrelate(); - expect(numUnrelated).to.equal(2); + expect(numUnrelated).toBe(2); const rows = await session.knex('Model1Model2'); - expect(ids(rows, 'model2Id')).to.eql([8, 9, 10]); + expect(ids(rows, 'model2Id')).toEqual([8, 9, 10]); }); }); @@ -211,7 +211,7 @@ module.exports = (session) => { .where('model1Prop1', 'nope') .orWhere('model1Prop1', 'owner 2'); - expect(related).to.equal(undefined); + expect(related).toBeUndefined(); }); }); }); diff --git a/tests/integration/schema.js b/tests/integration/schema.js index 4c1d38840..c2685ea55 100644 --- a/tests/integration/schema.js +++ b/tests/integration/schema.js @@ -1,7 +1,7 @@ -const expect = require('expect.js'); -const { Model } = require('../../'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; -module.exports = (session) => { +export default (session) => { // TODO igor PR test if (session.isPostgres()) { @@ -44,7 +44,7 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('Relatives') .dropTableIfExists('Animal') @@ -65,7 +65,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema .dropTableIfExists('Relatives') .dropTableIfExists('Animal') @@ -105,7 +105,7 @@ module.exports = (session) => { return Person.query(session.knex) .orderBy('id') .then((people) => { - expect(people).to.eql([ + expect(people).toEqual([ { id: 1, name: 'Arnold', @@ -130,7 +130,7 @@ module.exports = (session) => { parents: true, }) .then((people) => { - expect(people).to.eql([ + expect(people).toEqual([ { id: 1, name: 'Arnold', @@ -162,8 +162,8 @@ module.exports = (session) => { return Person.query(session.knex) .columnInfo() .then((info) => { - expect(info instanceof Model).to.equal(false); - expect(info).to.eql({ + expect(info instanceof Model).toBe(false); + expect(info).toEqual({ id: { type: 'integer', maxLength: null, @@ -186,7 +186,7 @@ module.exports = (session) => { .where('Person.name', 'Arnold') .withGraphJoined('pets'); - expect(people).to.eql([ + expect(people).toEqual([ { id: 1, name: 'Arnold', @@ -207,7 +207,7 @@ module.exports = (session) => { .toKnexQuery() .toQuery(); - expect(sql).to.not.contain('"Person".*'); + expect(sql).not.toContain('"Person".*'); }); it('many-to-many $relatedQuery', () => { @@ -217,7 +217,7 @@ module.exports = (session) => { return arnold.$relatedQuery('parents', session.knex); }) .then((parents) => { - expect(parents).to.eql([ + expect(parents).toEqual([ { id: 2, name: 'Mom', @@ -270,7 +270,7 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { return session.knex.schema .createSchema('homoSapiens') .then(() => { @@ -297,7 +297,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema .withSchema('canisFamiliar') .dropTableIfExists('Animal') @@ -332,7 +332,7 @@ module.exports = (session) => { it('simple find query (parent)', () => { return Person.query(session.knex).then((people) => { - expect(people).to.eql([ + expect(people).toEqual([ { id: 1, name: 'Arnold', @@ -343,7 +343,7 @@ module.exports = (session) => { it('simple find query (child)', () => { return Animal.query(session.knex).then((animals) => { - expect(animals).to.eql([ + expect(animals).toEqual([ { id: 1, name: 'Fluffy', @@ -357,7 +357,7 @@ module.exports = (session) => { return Person.query(session.knex) .withGraphJoined('pets') .then((people) => { - expect(people).to.eql([ + expect(people).toEqual([ { id: 1, name: 'Arnold', @@ -378,7 +378,7 @@ module.exports = (session) => { return Animal.query(session.knex) .withGraphJoined('owner') .then((animals) => { - expect(animals).to.eql([ + expect(animals).toEqual([ { id: 1, name: 'Fluffy', @@ -397,8 +397,8 @@ module.exports = (session) => { return Person.query(session.knex) .columnInfo() .then((info) => { - expect(info instanceof Model).to.equal(false); - expect(info).to.eql({ + expect(info instanceof Model).toBe(false); + expect(info).toEqual({ id: { type: 'integer', maxLength: null, diff --git a/tests/integration/snakeCase.js b/tests/integration/snakeCase.js index 3904d8956..73496fa9c 100644 --- a/tests/integration/snakeCase.js +++ b/tests/integration/snakeCase.js @@ -1,8 +1,8 @@ -const Knex = require('knex'); -const { Model, ref, snakeCaseMappers, knexSnakeCaseMappers } = require('../../'); -const expect = require('chai').expect; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import Knex from 'knex'; +import { Model, ref, snakeCaseMappers, knexSnakeCaseMappers } from 'objection'; -module.exports = (session) => { +export default (session) => { describe('snakeCaseMappers', () => { class Person extends Model { $formatDatabaseJson(json) { @@ -79,7 +79,7 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('person_movie') .dropTableIfExists('animal') @@ -173,7 +173,7 @@ module.exports = (session) => { return model.$relatedQuery('pets', session.knex).orderBy('animal_name'); }) .then((pets) => { - expect(pets).to.containSubset([ + expect(pets).toContainSubset([ { animalName: 'Hurtta', }, @@ -189,7 +189,7 @@ module.exports = (session) => { .joinRelated('parentPerson.parentPerson') .select('parentPerson:parentPerson.first_name as nestedRef') .then((result) => { - expect(result).to.containSubset([{ nestedRef: 'Matti' }]); + expect(result).toContainSubset([{ nestedRef: 'Matti' }]); }); }); @@ -203,7 +203,7 @@ module.exports = (session) => { }) .returning('*') .then((result) => { - expect(result).to.containSubset([ + expect(result).toContainSubset([ { firstName: 'Matti', parentId: null, @@ -222,9 +222,9 @@ module.exports = (session) => { describe('json field expressions', () => { const latitudeRef = 'person_address:cityCoordinates.latitudeCoordinate'; - class PreservingPerson extends Person { + class MappingPerson extends Person { static get columnNameMappers() { - return snakeCaseMappers({ preserveJsonKeys: true }); + return snakeCaseMappers({ preserveJsonKeys: false }); } } @@ -232,33 +232,33 @@ module.exports = (session) => { return session.knex('person').where('first_name', 'Matti').first(); } - it('patch maps json keys of field expressions by default', async () => { - await Person.query(session.knex) + it('patch maps json keys of field expressions with `preserveJsonKeys: false`', async () => { + await MappingPerson.query(session.knex) .where('first_name', 'Matti') .patch({ 'personAddress:personCity': 'Helsinki' }); const { person_address } = await fetchMatti(); - expect(person_address.personCity).to.equal('Jalasjärvi'); - expect(person_address.person_city).to.equal('Helsinki'); + expect(person_address.personCity).toBe('Jalasjärvi'); + expect(person_address.person_city).toBe('Helsinki'); }); - it('patch only maps the column part with `preserveJsonKeys: true`', async () => { - const numUpdated = await PreservingPerson.query(session.knex) + it('patch only maps the column part of field expressions by default', async () => { + const numUpdated = await Person.query(session.knex) .where(ref(latitudeRef), 61) .patch({ 'personAddress:cityCoordinates.latitudeCoordinate': 30 }); - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); const { person_address } = await fetchMatti(); - expect(person_address).to.eql({ + expect(person_address).toEqual({ personCity: 'Jalasjärvi', cityCoordinates: { latitudeCoordinate: 30, longitudeCoordinate: 23 }, }); - const result = await PreservingPerson.query(session.knex) + const result = await Person.query(session.knex) .select('first_name', ref(latitudeRef).castInt().as('latitude')) .whereJsonSupersetOf('person_address:cityCoordinates', { latitudeCoordinate: 30 }); - expect(result.map((it) => it.toJSON())).to.eql([{ firstName: 'Matti', latitude: 30 }]); + expect(result.map((it) => it.toJSON())).toEqual([{ firstName: 'Matti', latitude: 30 }]); }); it('knexSnakeCaseMappers never maps json keys of field expressions', async () => { @@ -279,9 +279,9 @@ module.exports = (session) => { .where(ref('personAddress:cityCoordinates.latitudeCoordinate'), 61) .patch({ 'personAddress:cityCoordinates.latitudeCoordinate': 30 }); - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); const { person_address } = await fetchMatti(); - expect(person_address.cityCoordinates).to.eql({ + expect(person_address.cityCoordinates).toEqual({ latitudeCoordinate: 30, longitudeCoordinate: 23, }); @@ -293,7 +293,7 @@ module.exports = (session) => { ) .whereNotNull('personAddress'); - expect(result.map((it) => it.toJSON())).to.eql([ + expect(result.map((it) => it.toJSON())).toEqual([ { firstName: 'Matti', latitudeValue: 30 }, ]); } finally { @@ -314,8 +314,8 @@ module.exports = (session) => { [method]('[parentPerson.parentPerson, pets, movies]') .orderBy('person.first_name') .then((people) => { - expect(people.length).to.equal(3); - expect(people).to.containSubset([ + expect(people.length).toBe(3); + expect(people).toContainSubset([ { rootFirstName: 'Seppo', @@ -361,7 +361,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema .dropTableIfExists('person_movie') .dropTableIfExists('animal') @@ -448,7 +448,7 @@ module.exports = (session) => { } } - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('PERSON_MOVIE') .dropTableIfExists('ANIMAL') @@ -520,7 +520,7 @@ module.exports = (session) => { return model.$relatedQuery('pets', session.knex).orderBy('ANIMAL_NAME'); }) .then((pets) => { - expect(pets).to.containSubset([ + expect(pets).toContainSubset([ { animalName: 'Hurtta', }, @@ -542,8 +542,8 @@ module.exports = (session) => { [method]('[parentPerson.parentPerson, pets, movies]') .orderBy('PERSON.FIRST_NAME') .then((people) => { - expect(people.length).to.equal(3); - expect(people).to.containSubset([ + expect(people.length).toBe(3); + expect(people).toContainSubset([ { rootFirstName: 'Seppo', @@ -589,7 +589,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema .dropTableIfExists('PERSON_MOVIe') .dropTableIfExists('ANIMAL') diff --git a/tests/integration/staticHooks.js b/tests/integration/staticHooks.js index 17be72a3b..3217cef25 100644 --- a/tests/integration/staticHooks.js +++ b/tests/integration/staticHooks.js @@ -1,9 +1,8 @@ -const expect = require('expect.js'); -const { expect: chaiExpect } = require('chai'); -const { Model } = require('../../'); -const mockKnexFactory = require('../../testUtils/mockKnex'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { Model } from 'objection'; +import mockKnexFactory from '../../testUtils/mockKnex.js'; -module.exports = (session) => { +export default (session) => { describe('static model hooks', () => { let knex; let queries = []; @@ -12,7 +11,7 @@ module.exports = (session) => { let Pet; let Movie; - before(() => { + beforeAll(() => { return session.knex.schema .dropTableIfExists('actorsMovies') .dropTableIfExists('movies') @@ -39,7 +38,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return session.knex.schema .dropTableIfExists('actorsMovies') .dropTableIfExists('movies') @@ -47,7 +46,7 @@ module.exports = (session) => { .dropTableIfExists('people'); }); - before(() => { + beforeAll(() => { knex = mockKnexFactory(session.knex, function (_, oldImpl, args) { queries.push(this.toSQL()); return oldImpl.apply(this, args); @@ -184,7 +183,7 @@ module.exports = (session) => { return Person.query() .findOne('name', 'Jennifer') .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ name: 'Jennifer', }); }); @@ -195,7 +194,7 @@ module.exports = (session) => { .findOne('name', 'Jennifer') .patch({ name: 'Jennier II' }) .then((result) => { - expect(result).to.eql(1); + expect(result).toBe(1); }); }); @@ -203,7 +202,7 @@ module.exports = (session) => { return Person.query() .insert({ name: 'Jennier II' }) .then((result) => { - expect(result.id).to.be.a('number'); + expect(result.id).toBeTypeOf('number'); }); }); @@ -212,7 +211,7 @@ module.exports = (session) => { .findOne('name', 'Jennifer') .delete() .then((result) => { - expect(result).to.eql(1); + expect(result).toBe(1); }); }); @@ -227,7 +226,7 @@ module.exports = (session) => { }) .modifyGraph('pets', (query) => query.orderBy('name', 'desc')) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ name: 'Jennifer', pets: [ { @@ -263,7 +262,7 @@ module.exports = (session) => { }) .orderBy('pets.name', 'desc') .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ name: 'Jennifer', pets: [ { @@ -352,9 +351,9 @@ module.exports = (session) => { Movie.beforeFind = createHookSpy(); return Movie.query().then((movies) => { - expect(movies.length).to.equal(2); + expect(movies.length).toBe(2); - chaiExpect(movies).to.containSubset([ + expect(movies).toContainSubset([ { name: 'Silver Linings Playbook', }, @@ -363,7 +362,7 @@ module.exports = (session) => { }, ]); - expect(Movie.beforeFind.calls.length).to.equal(1); + expect(Movie.beforeFind.calls.length).toBe(1); }); }); @@ -375,9 +374,9 @@ module.exports = (session) => { }); return Movie.query().then((movies) => { - expect(movies.length).to.equal(2); + expect(movies.length).toBe(2); - chaiExpect(movies).to.containSubset([ + expect(movies).toContainSubset([ { name: 'Silver Linings Playbook', }, @@ -386,41 +385,48 @@ module.exports = (session) => { }, ]); - expect(Movie.beforeFind.calls.length).to.equal(1); - expect(Movie.beforeFind.calls[0].itWorked).to.equal(true); + expect(Movie.beforeFind.calls.length).toBe(1); + expect(Movie.beforeFind.calls[0].itWorked).toBe(true); }); }); it('should have access to `context`', () => { Movie.beforeFind = createHookSpy(({ context }) => { - expect(context).to.eql({ a: 1 }); + expect(context).toEqual({ a: 1 }); }); return Movie.query() .context({ a: 1 }) .then(() => { - expect(Movie.beforeFind.calls.length).to.equal(1); + expect(Movie.beforeFind.calls.length).toBe(1); }); }); it('should have access to `transaction`', () => { Movie.beforeFind = createHookSpy(({ transaction }) => { - expect(transaction).to.equal(Movie.knex()); + expect(transaction).toBe(Movie.knex()); }); return Movie.query().then(() => { - expect(Movie.beforeFind.calls.length).to.equal(1); + expect(Movie.beforeFind.calls.length).toBe(1); }); }); + it('should get an undefined `result`', async () => { + Movie.beforeFind = createHookSpy(); + + await Movie.query(); + expect(Movie.beforeFind.calls[0].args.result).toBeUndefined(); + }); + it('should be able to cancel the query', () => { Movie.beforeFind = createHookSpy(({ cancelQuery }) => { cancelQuery(); }); return Movie.query().then((result) => { - expect(result).to.eql([]); - expect(queries.length).to.equal(0); + expect(result).toEqual([]); + expect(queries.length).toBe(0); }); }); @@ -430,8 +436,8 @@ module.exports = (session) => { }); return Movie.query().then((result) => { - expect(result).to.eql(['lol']); - expect(queries.length).to.equal(0); + expect(result).toEqual(['lol']); + expect(queries.length).toBe(0); }); }); }); @@ -442,9 +448,9 @@ module.exports = (session) => { .findOne({ name: 'Hungergames' }) .then((movie) => { Movie.beforeFind = createHookSpy(({ items }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Hungergames', }, @@ -454,8 +460,8 @@ module.exports = (session) => { return movie.$query(); }) .then((result) => { - expect(result.name).to.equal('Hungergames'); - expect(Movie.beforeFind.calls.length).to.equal(1); + expect(result.name).toBe('Hungergames'); + expect(Movie.beforeFind.calls.length).toBe(1); }); }); }); @@ -467,23 +473,23 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Movie.beforeFind = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - expect(relation).to.equal(Person.getRelation('movies')); + expect(relation).toBe(Person.getRelation('movies')); }); return person.$relatedQuery('movies'); }) .then((movies) => { - expect(movies.length).to.equal(2); + expect(movies.length).toBe(2); - chaiExpect(movies).to.containSubset([ + expect(movies).toContainSubset([ { name: 'Silver Linings Playbook', }, @@ -492,7 +498,7 @@ module.exports = (session) => { }, ]); - expect(Movie.beforeFind.calls.length).to.equal(1); + expect(Movie.beforeFind.calls.length).toBe(1); }); }); }); @@ -503,23 +509,23 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Pet.beforeFind = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - expect(relation).to.equal(Person.getRelation('pets')); + expect(relation).toBe(Person.getRelation('pets')); }); return person.$relatedQuery('pets'); }) .then((pets) => { - expect(pets.length).to.equal(2); + expect(pets.length).toBe(2); - chaiExpect(pets).to.containSubset([ + expect(pets).toContainSubset([ { name: 'Doggo', species: 'dog', @@ -530,7 +536,7 @@ module.exports = (session) => { }, ]); - expect(Pet.beforeFind.calls.length).to.equal(1); + expect(Pet.beforeFind.calls.length).toBe(1); }); }); }); @@ -541,25 +547,25 @@ module.exports = (session) => { .findOne({ name: 'Doggo' }) .then((pet) => { Person.beforeFind = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Doggo', }, ]); - expect(relation).to.equal(Pet.getRelation('owner')); + expect(relation).toBe(Pet.getRelation('owner')); }); return pet.$relatedQuery('owner'); }) .then((person) => { - chaiExpect(person).to.containSubset({ + expect(person).toMatchObject({ name: 'Jennifer', }); - expect(Person.beforeFind.calls.length).to.equal(1); + expect(Person.beforeFind.calls.length).toBe(1); }); }); }); @@ -568,9 +574,9 @@ module.exports = (session) => { describe('eager', () => { it('should have access to all parents and relation', () => { Pet.beforeFind = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(2); + expect(items.length).toBe(2); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, @@ -579,7 +585,7 @@ module.exports = (session) => { }, ]); - expect(relation).to.equal(Person.getRelation('pets')); + expect(relation).toBe(Person.getRelation('pets')); }); Person.beforeFind = createHookSpy(({ items, relation }) => { @@ -588,8 +594,8 @@ module.exports = (session) => { return; } - expect(items.length).to.equal(4); - chaiExpect(items).to.containSubset([ + expect(items.length).toBe(4); + expect(items).toContainSubset([ { name: 'Doggo', }, @@ -604,13 +610,13 @@ module.exports = (session) => { }, ]); - expect(relation).to.equal(Pet.getRelation('owner')); + expect(relation).toBe(Pet.getRelation('owner')); }); Movie.beforeFind = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(2); + expect(items.length).toBe(2); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, @@ -619,7 +625,7 @@ module.exports = (session) => { }, ]); - expect(relation).to.equal(Person.getRelation('movies')); + expect(relation).toBe(Person.getRelation('movies')); }); return Person.query() @@ -630,9 +636,9 @@ module.exports = (session) => { }, }) .then(() => { - expect(Movie.beforeFind.calls.length).to.equal(1); - expect(Pet.beforeFind.calls.length).to.equal(1); - expect(Person.beforeFind.calls.length).to.equal(2); + expect(Movie.beforeFind.calls.length).toBe(1); + expect(Pet.beforeFind.calls.length).toBe(1); + expect(Person.beforeFind.calls.length).toBe(2); }); }); }); @@ -696,9 +702,9 @@ module.exports = (session) => { Movie.afterFind = createHookSpy(); return Movie.query().then((movies) => { - expect(movies.length).to.equal(2); + expect(movies.length).toBe(2); - chaiExpect(movies).to.containSubset([ + expect(movies).toContainSubset([ { name: 'Silver Linings Playbook', }, @@ -707,7 +713,7 @@ module.exports = (session) => { }, ]); - expect(Movie.afterFind.calls.length).to.equal(1); + expect(Movie.afterFind.calls.length).toBe(1); }); }); @@ -717,7 +723,7 @@ module.exports = (session) => { }); return Movie.query().then((result) => { - chaiExpect(result).to.containSubset([ + expect(result).toContainSubset([ 'some', 'crap', [ @@ -729,7 +735,7 @@ module.exports = (session) => { }, ], ]); - expect(Movie.afterFind.calls.length).to.equal(1); + expect(Movie.afterFind.calls.length).toBe(1); }); }); @@ -741,9 +747,9 @@ module.exports = (session) => { }); return Movie.query().then((movies) => { - expect(movies.length).to.equal(2); + expect(movies.length).toBe(2); - chaiExpect(movies).to.containSubset([ + expect(movies).toContainSubset([ { name: 'Silver Linings Playbook', }, @@ -752,30 +758,30 @@ module.exports = (session) => { }, ]); - expect(Movie.afterFind.calls.length).to.equal(1); - expect(Movie.afterFind.calls[0].itWorked).to.equal(true); + expect(Movie.afterFind.calls.length).toBe(1); + expect(Movie.afterFind.calls[0].itWorked).toBe(true); }); }); it('should have access to `context`', () => { Movie.afterFind = createHookSpy(({ context }) => { - expect(context).to.eql({ a: 1 }); + expect(context).toEqual({ a: 1 }); }); return Movie.query() .context({ a: 1 }) .then(() => { - expect(Movie.afterFind.calls.length).to.equal(1); + expect(Movie.afterFind.calls.length).toBe(1); }); }); it('should have access to `transaction`', () => { Movie.afterFind = createHookSpy(({ transaction }) => { - expect(transaction).to.equal(Movie.knex()); + expect(transaction).toBe(Movie.knex()); }); return Movie.query().then(() => { - expect(Movie.afterFind.calls.length).to.equal(1); + expect(Movie.afterFind.calls.length).toBe(1); }); }); }); @@ -786,9 +792,9 @@ module.exports = (session) => { .findOne({ name: 'Hungergames' }) .then((movie) => { Movie.afterFind = createHookSpy(({ items }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Hungergames', }, @@ -798,8 +804,8 @@ module.exports = (session) => { return movie.$query(); }) .then((result) => { - expect(result.name).to.equal('Hungergames'); - expect(Movie.afterFind.calls.length).to.equal(1); + expect(result.name).toBe('Hungergames'); + expect(Movie.afterFind.calls.length).toBe(1); }); }); }); @@ -811,23 +817,23 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Movie.afterFind = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - expect(relation).to.equal(Person.getRelation('movies')); + expect(relation).toBe(Person.getRelation('movies')); }); return person.$relatedQuery('movies'); }) .then((movies) => { - expect(movies.length).to.equal(2); + expect(movies.length).toBe(2); - chaiExpect(movies).to.containSubset([ + expect(movies).toContainSubset([ { name: 'Silver Linings Playbook', }, @@ -836,7 +842,7 @@ module.exports = (session) => { }, ]); - expect(Movie.afterFind.calls.length).to.equal(1); + expect(Movie.afterFind.calls.length).toBe(1); }); }); }); @@ -847,23 +853,23 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Pet.afterFind = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - expect(relation).to.equal(Person.getRelation('pets')); + expect(relation).toBe(Person.getRelation('pets')); }); return person.$relatedQuery('pets'); }) .then((pets) => { - expect(pets.length).to.equal(2); + expect(pets.length).toBe(2); - chaiExpect(pets).to.containSubset([ + expect(pets).toContainSubset([ { name: 'Doggo', species: 'dog', @@ -874,7 +880,7 @@ module.exports = (session) => { }, ]); - expect(Pet.afterFind.calls.length).to.equal(1); + expect(Pet.afterFind.calls.length).toBe(1); }); }); }); @@ -885,36 +891,46 @@ module.exports = (session) => { .findOne({ name: 'Doggo' }) .then((pet) => { Person.afterFind = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Doggo', }, ]); - expect(relation).to.equal(Pet.getRelation('owner')); + expect(relation).toBe(Pet.getRelation('owner')); }); return pet.$relatedQuery('owner'); }) .then((person) => { - chaiExpect(person).to.containSubset({ + expect(person).toMatchObject({ name: 'Jennifer', }); - expect(Person.afterFind.calls.length).to.equal(1); + expect(Person.afterFind.calls.length).toBe(1); }); }); + + it('should get an undefined `result` if there is no related model', async () => { + const pet = await Pet.query().insert({ name: 'Stray' }); + Person.afterFind = createHookSpy(); + + const owner = await pet.$relatedQuery('owner'); + expect(owner).toBeUndefined(); + expect(Person.afterFind.calls.length).toBe(1); + expect(Person.afterFind.calls[0].args.result).toBeUndefined(); + }); }); }); describe('eager', () => { it('should have access to all parents and relation', () => { Pet.afterFind = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(2); + expect(items.length).toBe(2); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, @@ -923,7 +939,7 @@ module.exports = (session) => { }, ]); - expect(relation).to.equal(Person.getRelation('pets')); + expect(relation).toBe(Person.getRelation('pets')); }); Person.afterFind = createHookSpy(({ items, relation }) => { @@ -932,8 +948,8 @@ module.exports = (session) => { return; } - expect(items.length).to.equal(4); - chaiExpect(items).to.containSubset([ + expect(items.length).toBe(4); + expect(items).toContainSubset([ { name: 'Doggo', }, @@ -948,13 +964,13 @@ module.exports = (session) => { }, ]); - expect(relation).to.equal(Pet.getRelation('owner')); + expect(relation).toBe(Pet.getRelation('owner')); }); Movie.afterFind = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(2); + expect(items.length).toBe(2); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, @@ -963,7 +979,7 @@ module.exports = (session) => { }, ]); - expect(relation).to.equal(Person.getRelation('movies')); + expect(relation).toBe(Person.getRelation('movies')); }); return Person.query() @@ -974,9 +990,9 @@ module.exports = (session) => { }, }) .then(() => { - expect(Movie.afterFind.calls.length).to.equal(1); - expect(Pet.afterFind.calls.length).to.equal(1); - expect(Person.afterFind.calls.length).to.equal(2); + expect(Movie.afterFind.calls.length).toBe(1); + expect(Pet.afterFind.calls.length).toBe(1); + expect(Person.afterFind.calls.length).toBe(2); }); }); }); @@ -1049,8 +1065,8 @@ module.exports = (session) => { return Movie.query() .update({ name: 'Updated' }) .then((numUpdated) => { - expect(numUpdated).to.equal(3); - expect(Movie.beforeUpdate.calls.length).to.equal(1); + expect(numUpdated).toBe(3); + expect(Movie.beforeUpdate.calls.length).toBe(1); }); }); @@ -1064,42 +1080,42 @@ module.exports = (session) => { return Movie.query() .update({ name: 'Updated' }) .then((numUpdated) => { - expect(numUpdated).to.equal(3); - expect(Movie.beforeUpdate.calls.length).to.equal(1); - expect(Movie.beforeUpdate.calls[0].itWorked).to.equal(true); + expect(numUpdated).toBe(3); + expect(Movie.beforeUpdate.calls.length).toBe(1); + expect(Movie.beforeUpdate.calls[0].itWorked).toBe(true); }); }); it('should have access to `context`', () => { Movie.beforeUpdate = createHookSpy(({ context }) => { - expect(context).to.eql({ a: 1 }); + expect(context).toEqual({ a: 1 }); }); return Movie.query() .update({ name: 'Updated' }) .context({ a: 1 }) .then(() => { - expect(Movie.beforeUpdate.calls.length).to.equal(1); + expect(Movie.beforeUpdate.calls.length).toBe(1); }); }); it('should have access to `transaction`', () => { Movie.beforeUpdate = createHookSpy(({ transaction }) => { - expect(transaction).to.equal(Movie.knex()); + expect(transaction).toBe(Movie.knex()); }); return Movie.query() .update({ name: 'Updated' }) .then(() => { - expect(Movie.beforeUpdate.calls.length).to.equal(1); + expect(Movie.beforeUpdate.calls.length).toBe(1); }); }); it('should have access to `inputItems`', () => { Movie.beforeUpdate = createHookSpy(({ inputItems }) => { - expect(inputItems.length).to.equal(1); - expect(inputItems[0] instanceof Movie).to.equal(true); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems.length).toBe(1); + expect(inputItems[0] instanceof Movie).toBe(true); + expect(inputItems).toContainSubset([ { name: 'Updated', }, @@ -1109,7 +1125,7 @@ module.exports = (session) => { return Movie.query() .update({ name: 'Updated' }) .then(() => { - expect(Movie.beforeUpdate.calls.length).to.equal(1); + expect(Movie.beforeUpdate.calls.length).toBe(1); }); }); @@ -1119,8 +1135,8 @@ module.exports = (session) => { .select('name') .forUpdate() .then((moviesToBeUpdated) => { - chaiExpect(moviesToBeUpdated).to.have.length(1); - chaiExpect(moviesToBeUpdated).containSubset([ + expect(moviesToBeUpdated).toHaveLength(1); + expect(moviesToBeUpdated).toContainSubset([ { name: 'Hungergames', }, @@ -1133,9 +1149,9 @@ module.exports = (session) => { .update({ name: 'Updated' }) .where('name', 'like', '%gam%') .then(() => { - expect(Movie.beforeUpdate.calls.length).to.equal(1); - expect(Movie.beforeUpdate.calls[0].queryWasAwaited).to.equal(true); - expect(queries.length).to.equal(2); + expect(Movie.beforeUpdate.calls.length).toBe(1); + expect(Movie.beforeUpdate.calls[0].queryWasAwaited).toBe(true); + expect(queries.length).toBe(2); }); }); @@ -1143,8 +1159,8 @@ module.exports = (session) => { Movie.beforeUpdate = createHookSpy(async ({ asFindQuery }, call) => { const moviesToBeUpdated = await asFindQuery().select('name').forUpdate(); - chaiExpect(moviesToBeUpdated).to.have.length(1); - chaiExpect(moviesToBeUpdated).containSubset([ + expect(moviesToBeUpdated).toHaveLength(1); + expect(moviesToBeUpdated).toContainSubset([ { name: 'Hungergames', }, @@ -1154,39 +1170,39 @@ module.exports = (session) => { }); const hungerGames = await Movie.query().findOne('name', 'like', '%gam%'); - expect(queries.length).to.equal(1); + expect(queries.length).toBe(1); await Movie.query().patchAndFetchById(hungerGames.id, { name: 'Updated' }); - expect(Movie.beforeUpdate.calls.length).to.equal(1); - expect(Movie.beforeUpdate.calls[0].queryWasAwaited).to.equal(true); + expect(Movie.beforeUpdate.calls.length).toBe(1); + expect(Movie.beforeUpdate.calls[0].queryWasAwaited).toBe(true); // findOne + patch + fetch + asFindQuery() - expect(queries.length).to.equal(4); + expect(queries.length).toBe(4); }); it('should be able to access modelOptions in beforeUpdate when using patchAndFetchById', async () => { Movie.beforeUpdate = createHookSpy(({ modelOptions }) => { - chaiExpect(modelOptions).to.deep.equal({ patch: true }); + expect(modelOptions).toEqual({ patch: true }); }); const hungerGames = await Movie.query().findOne('name', 'like', '%gam%'); - expect(queries.length).to.equal(1); + expect(queries.length).toBe(1); await Movie.query().patchAndFetchById(hungerGames.id, { name: 'Updated' }); - expect(Movie.beforeUpdate.calls.length).to.equal(1); + expect(Movie.beforeUpdate.calls.length).toBe(1); }); it('should populate modelOptions with old data when using upsertGraph', async () => { Movie.beforeUpdate = createHookSpy(({ modelOptions }) => { - expect(modelOptions).to.have.property('old'); + expect(modelOptions).toHaveProperty('old'); - chaiExpect(modelOptions.old).containSubset({ name: 'Hungergames' }); + expect(modelOptions.old).toMatchObject({ name: 'Hungergames' }); }); const hungerGames = await Movie.query().findOne('name', 'like', '%gam%'); - expect(queries.length).to.equal(1); + expect(queries.length).toBe(1); await Movie.query().upsertGraph({ id: hungerGames.id, name: 'Updated' }); - expect(Movie.beforeUpdate.calls.length).to.equal(1); + expect(Movie.beforeUpdate.calls.length).toBe(1); }); it('should be able to cancel the query', () => { @@ -1197,11 +1213,55 @@ module.exports = (session) => { return Movie.query() .update({ name: 'Updated' }) .then((numUpdated) => { - expect(numUpdated).to.eql(0); - expect(queries.length).to.equal(0); + expect(numUpdated).toBe(0); + expect(queries.length).toBe(0); }); }); + it('should be able to cancel the queries of upsertGraph', async () => { + Pet.beforeUpdate = createHookSpy(({ cancelQuery }) => { + cancelQuery(); + }); + + const person = await Person.query() + .findOne({ name: 'Jennifer' }) + .withGraphFetched('pets'); + const pets = person.pets.map(({ id, name }) => ({ id, name: `${name} 2` })); + const result = await Person.query().upsertGraph({ id: person.id, pets }); + + expect(result.pets).toHaveLength(2); + expect(Pet.beforeUpdate.calls.length).toBe(2); + const current = await person.$relatedQuery('pets').orderBy('name'); + expect(current.map((pet) => pet.name)).toEqual(['Cato', 'Doggo']); + }); + + it('should be able to cancel the queries of upsertGraph through relations', async () => { + Movie.beforeUpdate = createHookSpy(({ cancelQuery }) => { + cancelQuery(); + }); + Person.beforeUpdate = createHookSpy(({ cancelQuery }) => { + cancelQuery(); + }); + + const person = await Person.query() + .findOne({ name: 'Jennifer' }) + .withGraphFetched('[movies, pets]'); + const [pet] = person.pets; + const movies = person.movies.map(({ id, name }) => ({ id, name: `${name} 2` })); + + await Person.query().upsertGraph({ id: person.id, movies }); + await Pet.query().upsertGraph({ id: pet.id, owner: { id: person.id, name: 'Jen' } }); + + expect(Movie.beforeUpdate.calls.length).toBe(2); + expect(Person.beforeUpdate.calls.length).toBe(1); + const current = await person.$relatedQuery('movies').orderBy('name'); + expect(current.map((movie) => movie.name)).toEqual([ + 'Hungergames', + 'Silver Linings Playbook', + ]); + expect((await person.$query()).name).toBe('Jennifer'); + }); + it('should be able to cancel the query with a value', () => { Movie.beforeUpdate = createHookSpy(({ cancelQuery }) => { cancelQuery(['lol']); @@ -1210,8 +1270,8 @@ module.exports = (session) => { return Movie.query() .update({ name: 'Updated' }) .then((result) => { - expect(result).to.eql(['lol']); - expect(queries.length).to.equal(0); + expect(result).toEqual(['lol']); + expect(queries.length).toBe(0); }); }); @@ -1225,8 +1285,8 @@ module.exports = (session) => { const result = await Movie.query().update({ name: 'Updated' }); - expect(result).to.eql(['lol']); - expect(found.map((movie) => movie.name)).to.eql(['Hungergames']); + expect(result).toEqual(['lol']); + expect(found.map((movie) => movie.name)).toEqual(['Hungergames']); }); }); @@ -1236,16 +1296,16 @@ module.exports = (session) => { .findOne({ name: 'Silver Linings Playbook' }) .then((movie) => { Movie.beforeUpdate = createHookSpy(({ items, inputItems }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Silver Linings Playbook', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { name: 'Updated', }, @@ -1255,8 +1315,8 @@ module.exports = (session) => { return movie.$query().patch({ name: 'Updated' }); }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(Movie.beforeUpdate.calls.length).to.equal(1); + expect(numUpdated).toBe(1); + expect(Movie.beforeUpdate.calls.length).toBe(1); }); }); @@ -1271,10 +1331,10 @@ module.exports = (session) => { .select('name') .forUpdate() .then((moviesToBeUpdated) => { - chaiExpect(moviesToBeUpdated).to.have.length(1); + expect(moviesToBeUpdated).toHaveLength(1); // Note: moviesToBeUpdated must be an array even though $query() // would normally produce a single item. - chaiExpect(moviesToBeUpdated).containSubset([ + expect(moviesToBeUpdated).toContainSubset([ { name: 'Silver Linings Playbook', }, @@ -1286,10 +1346,10 @@ module.exports = (session) => { return movie.$query().patch({ name: 'Updated' }); }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(Movie.beforeUpdate.calls.length).to.equal(1); - expect(Movie.beforeUpdate.calls[0].queryWasAwaited).to.equal(true); - expect(queries.length).to.equal(2); + expect(numUpdated).toBe(1); + expect(Movie.beforeUpdate.calls.length).toBe(1); + expect(Movie.beforeUpdate.calls[0].queryWasAwaited).toBe(true); + expect(queries.length).toBe(2); }); }); }); @@ -1301,73 +1361,72 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Movie.beforeUpdate = createHookSpy(({ items, inputItems, relation }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { name: 'Updated', }, ]); - expect(relation).to.equal(Person.getRelation('movies')); + expect(relation).toBe(Person.getRelation('movies')); }); return person.$relatedQuery('movies').update({ name: 'Updated' }); }) .then((numUpdated) => { - expect(numUpdated).to.equal(2); - expect(Movie.beforeUpdate.calls.length).to.equal(1); + expect(numUpdated).toBe(2); + expect(Movie.beforeUpdate.calls.length).toBe(1); }); }); - it('should be able to fetch the rows about to be updated`', function () { - // knex puts the lock of `forUpdate()` after the join on SQL Server, - // instead of after the table. - if (session.isMsSql()) { - return this.skip(); - } - - return Person.query() - .findOne({ name: 'Jennifer' }) - .then((person) => { - queries = []; - - Movie.beforeUpdate = createHookSpy(({ asFindQuery }, call) => { - return asFindQuery() - .select('name') - .forUpdate() - .then((moviesToBeUpdated) => { - expect(moviesToBeUpdated.length).to.equal(2); - - chaiExpect(moviesToBeUpdated).containSubset([ - { - name: 'Silver Linings Playbook', - }, - { - name: 'Hungergames', - }, - ]); + it.skipIf(session.isMsSql())( + 'should be able to fetch the rows about to be updated`', + () => { + // knex puts the lock of `forUpdate()` after the join on SQL Server, + // instead of after the table. + return Person.query() + .findOne({ name: 'Jennifer' }) + .then((person) => { + queries = []; + + Movie.beforeUpdate = createHookSpy(({ asFindQuery }, call) => { + return asFindQuery() + .select('name') + .forUpdate() + .then((moviesToBeUpdated) => { + expect(moviesToBeUpdated.length).toBe(2); + + expect(moviesToBeUpdated).toContainSubset([ + { + name: 'Silver Linings Playbook', + }, + { + name: 'Hungergames', + }, + ]); + + call.queryWasAwaited = true; + }); + }); - call.queryWasAwaited = true; - }); + return person.$relatedQuery('movies').patch({ name: 'Updated' }); + }) + .then((numUpdated) => { + expect(numUpdated).toBe(2); + expect(Movie.beforeUpdate.calls.length).toBe(1); + expect(Movie.beforeUpdate.calls[0].queryWasAwaited).toBe(true); + expect(queries.length).toBe(2); }); - - return person.$relatedQuery('movies').patch({ name: 'Updated' }); - }) - .then((numUpdated) => { - expect(numUpdated).to.equal(2); - expect(Movie.beforeUpdate.calls.length).to.equal(1); - expect(Movie.beforeUpdate.calls[0].queryWasAwaited).to.equal(true); - expect(queries.length).to.equal(2); - }); - }); + }, + ); }); describe('has many', () => { @@ -1376,29 +1435,29 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Pet.beforeUpdate = createHookSpy(({ items, inputItems, relation }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { species: 'Frog', }, ]); - expect(relation).to.equal(Person.getRelation('pets')); + expect(relation).toBe(Person.getRelation('pets')); }); return person.$relatedQuery('pets').patch({ species: 'Frog' }); }) .then((numUpdated) => { - expect(numUpdated).to.equal(2); - expect(Pet.beforeUpdate.calls.length).to.equal(1); + expect(numUpdated).toBe(2); + expect(Pet.beforeUpdate.calls.length).toBe(1); }); }); @@ -1413,9 +1472,9 @@ module.exports = (session) => { .select('name') .forUpdate() .then((petsToBeUpdated) => { - expect(petsToBeUpdated.length).to.equal(2); + expect(petsToBeUpdated.length).toBe(2); - chaiExpect(petsToBeUpdated).containSubset([ + expect(petsToBeUpdated).toContainSubset([ { name: 'Doggo', }, @@ -1431,10 +1490,10 @@ module.exports = (session) => { return person.$relatedQuery('pets').patch({ name: 'Updated' }); }) .then((numUpdated) => { - expect(numUpdated).to.equal(2); - expect(Pet.beforeUpdate.calls.length).to.equal(1); - expect(Pet.beforeUpdate.calls[0].queryWasAwaited).to.equal(true); - expect(queries.length).to.equal(2); + expect(numUpdated).toBe(2); + expect(Pet.beforeUpdate.calls.length).toBe(1); + expect(Pet.beforeUpdate.calls[0].queryWasAwaited).toBe(true); + expect(queries.length).toBe(2); }); }); @@ -1442,8 +1501,8 @@ module.exports = (session) => { Pet.beforeUpdate = createHookSpy(async ({ asFindQuery }, call) => { const petsToBeRelated = await asFindQuery().select('name').forUpdate(); - chaiExpect(petsToBeRelated).to.have.length(2); - chaiExpect(petsToBeRelated).containSubset([ + expect(petsToBeRelated).toHaveLength(2); + expect(petsToBeRelated).toContainSubset([ { name: 'Hamsto', }, @@ -1461,17 +1520,17 @@ module.exports = (session) => { queries = []; await jennifer.$relatedQuery('pets').relate([hamsto.id, croco.id]); - expect(Pet.beforeUpdate.calls.length).to.equal(1); - expect(Pet.beforeUpdate.calls[0].queryWasAwaited).to.equal(true); - expect(queries.length).to.equal(2); + expect(Pet.beforeUpdate.calls.length).toBe(1); + expect(Pet.beforeUpdate.calls[0].queryWasAwaited).toBe(true); + expect(queries.length).toBe(2); }); it('should be able to fetch the rows about to be updated when unrelating', async () => { Pet.beforeUpdate = createHookSpy(async ({ asFindQuery }, call) => { const petsToBeUnrelated = await asFindQuery().select('name').forUpdate(); - chaiExpect(petsToBeUnrelated).to.have.length(2); - chaiExpect(petsToBeUnrelated).containSubset([ + expect(petsToBeUnrelated).toHaveLength(2); + expect(petsToBeUnrelated).toContainSubset([ { name: 'Doggo', }, @@ -1487,9 +1546,9 @@ module.exports = (session) => { queries = []; await jennifer.$relatedQuery('pets').unrelate(); - expect(Pet.beforeUpdate.calls.length).to.equal(1); - expect(Pet.beforeUpdate.calls[0].queryWasAwaited).to.equal(true); - expect(queries.length).to.equal(2); + expect(Pet.beforeUpdate.calls.length).toBe(1); + expect(Pet.beforeUpdate.calls[0].queryWasAwaited).toBe(true); + expect(queries.length).toBe(2); }); }); @@ -1499,29 +1558,29 @@ module.exports = (session) => { .findOne({ name: 'Doggo' }) .then((pet) => { Person.beforeUpdate = createHookSpy(({ items, inputItems, relation }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Doggo', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { name: 'New Owner', }, ]); - expect(relation).to.equal(Pet.getRelation('owner')); + expect(relation).toBe(Pet.getRelation('owner')); }); return pet.$relatedQuery('owner').patch({ name: 'New Owner' }); }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(Person.beforeUpdate.calls.length).to.equal(1); + expect(numUpdated).toBe(1); + expect(Person.beforeUpdate.calls.length).toBe(1); }); }); @@ -1536,9 +1595,9 @@ module.exports = (session) => { .select('name') .forUpdate() .then((peopleToBeUpdated) => { - expect(peopleToBeUpdated.length).to.equal(1); + expect(peopleToBeUpdated.length).toBe(1); - chaiExpect(peopleToBeUpdated).containSubset([ + expect(peopleToBeUpdated).toContainSubset([ { name: 'Jennifer', }, @@ -1551,10 +1610,10 @@ module.exports = (session) => { return pet.$relatedQuery('owner').patch({ name: 'Updated' }); }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(Person.beforeUpdate.calls.length).to.equal(1); - expect(Person.beforeUpdate.calls[0].queryWasAwaited).to.equal(true); - expect(queries.length).to.equal(2); + expect(numUpdated).toBe(1); + expect(Person.beforeUpdate.calls.length).toBe(1); + expect(Person.beforeUpdate.calls[0].queryWasAwaited).toBe(true); + expect(queries.length).toBe(2); }); }); }); @@ -1628,22 +1687,22 @@ module.exports = (session) => { return Movie.query() .update({ name: 'Updated' }) .then((numUpdated) => { - expect(numUpdated).to.equal(3); - expect(Movie.afterUpdate.calls.length).to.equal(1); + expect(numUpdated).toBe(3); + expect(Movie.afterUpdate.calls.length).toBe(1); }); }); it('should be able to change the result', () => { Movie.afterUpdate = createHookSpy(({ result }) => { return { - numUpdated: result[0], + numUpdated: result, }; }); return Movie.query() .update({ name: 'Updated' }) .then((result) => { - expect(result).to.eql({ numUpdated: 3 }); + expect(result).toEqual({ numUpdated: 3 }); }); }); @@ -1657,42 +1716,42 @@ module.exports = (session) => { return Movie.query() .update({ name: 'Updated' }) .then((numUpdated) => { - expect(numUpdated).to.equal(3); - expect(Movie.afterUpdate.calls.length).to.equal(1); - expect(Movie.afterUpdate.calls[0].itWorked).to.equal(true); + expect(numUpdated).toBe(3); + expect(Movie.afterUpdate.calls.length).toBe(1); + expect(Movie.afterUpdate.calls[0].itWorked).toBe(true); }); }); it('should have access to `context`', () => { Movie.afterUpdate = createHookSpy(({ context }) => { - expect(context).to.eql({ a: 1 }); + expect(context).toEqual({ a: 1 }); }); return Movie.query() .update({ name: 'Updated' }) .context({ a: 1 }) .then(() => { - expect(Movie.afterUpdate.calls.length).to.equal(1); + expect(Movie.afterUpdate.calls.length).toBe(1); }); }); it('should have access to `transaction`', () => { Movie.afterUpdate = createHookSpy(({ transaction }) => { - expect(transaction).to.equal(Movie.knex()); + expect(transaction).toBe(Movie.knex()); }); return Movie.query() .update({ name: 'Updated' }) .then(() => { - expect(Movie.afterUpdate.calls.length).to.equal(1); + expect(Movie.afterUpdate.calls.length).toBe(1); }); }); it('should have access to `inputItems`', () => { Movie.afterUpdate = createHookSpy(({ inputItems }) => { - expect(inputItems.length).to.equal(1); - expect(inputItems[0] instanceof Movie).to.equal(true); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems.length).toBe(1); + expect(inputItems[0] instanceof Movie).toBe(true); + expect(inputItems).toContainSubset([ { name: 'Updated', }, @@ -1702,7 +1761,7 @@ module.exports = (session) => { return Movie.query() .update({ name: 'Updated' }) .then(() => { - expect(Movie.afterUpdate.calls.length).to.equal(1); + expect(Movie.afterUpdate.calls.length).toBe(1); }); }); }); @@ -1713,16 +1772,18 @@ module.exports = (session) => { .findOne({ name: 'Silver Linings Playbook' }) .then((movie) => { Movie.afterUpdate = createHookSpy(({ items, inputItems }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + // The hook runs last, after the instance has been updated: + expect(items).toEqual([movie]); + expect(items).toContainSubset([ { - name: 'Silver Linings Playbook', + name: 'Updated', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { name: 'Updated', }, @@ -1732,8 +1793,8 @@ module.exports = (session) => { return movie.$query().patch({ name: 'Updated' }); }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(Movie.afterUpdate.calls.length).to.equal(1); + expect(numUpdated).toBe(1); + expect(Movie.afterUpdate.calls.length).toBe(1); }); }); }); @@ -1745,29 +1806,29 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Movie.afterUpdate = createHookSpy(({ items, inputItems, relation }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { name: 'Updated', }, ]); - expect(relation).to.equal(Person.getRelation('movies')); + expect(relation).toBe(Person.getRelation('movies')); }); return person.$relatedQuery('movies').update({ name: 'Updated' }); }) .then((numUpdated) => { - expect(numUpdated).to.equal(2); - expect(Movie.afterUpdate.calls.length).to.equal(1); + expect(numUpdated).toBe(2); + expect(Movie.afterUpdate.calls.length).toBe(1); }); }); @@ -1782,9 +1843,9 @@ module.exports = (session) => { await person.$relatedQuery('movies').findById(movie.id).patch({ name: 'Updated' }); - expect(Movie.afterUpdate.calls.length).to.equal(1); - chaiExpect(found).to.containSubset([{ name: 'Updated' }]); - expect(found.length).to.equal(1); + expect(Movie.afterUpdate.calls.length).toBe(1); + expect(found).toContainSubset([{ name: 'Updated' }]); + expect(found.length).toBe(1); }); it('`asFindQuery` should return the updated rows with upsertGraph', async () => { @@ -1805,9 +1866,9 @@ module.exports = (session) => { ), }); - expect(Movie.afterUpdate.calls.length).to.equal(1); - chaiExpect(found).to.containSubset([{ name: 'Updated' }]); - expect(found.length).to.equal(1); + expect(Movie.afterUpdate.calls.length).toBe(1); + expect(found).toContainSubset([{ name: 'Updated' }]); + expect(found.length).toBe(1); }); }); @@ -1817,29 +1878,29 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Pet.afterUpdate = createHookSpy(({ items, inputItems, relation }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { species: 'Frog', }, ]); - expect(relation).to.equal(Person.getRelation('pets')); + expect(relation).toBe(Person.getRelation('pets')); }); return person.$relatedQuery('pets').patch({ species: 'Frog' }); }) .then((numUpdated) => { - expect(numUpdated).to.equal(2); - expect(Pet.afterUpdate.calls.length).to.equal(1); + expect(numUpdated).toBe(2); + expect(Pet.afterUpdate.calls.length).toBe(1); }); }); }); @@ -1850,29 +1911,29 @@ module.exports = (session) => { .findOne({ name: 'Doggo' }) .then((pet) => { Person.afterUpdate = createHookSpy(({ items, inputItems, relation }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Doggo', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { name: 'New Owner', }, ]); - expect(relation).to.equal(Pet.getRelation('owner')); + expect(relation).toBe(Pet.getRelation('owner')); }); return pet.$relatedQuery('owner').patch({ name: 'New Owner' }); }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(Person.afterUpdate.calls.length).to.equal(1); + expect(numUpdated).toBe(1); + expect(Person.afterUpdate.calls.length).toBe(1); }); }); }); @@ -1947,8 +2008,8 @@ module.exports = (session) => { .delete() .where('name', 'A Star is Born') .then((numDeleted) => { - expect(numDeleted).to.equal(1); - expect(Movie.beforeDelete.calls.length).to.equal(1); + expect(numDeleted).toBe(1); + expect(Movie.beforeDelete.calls.length).toBe(1); }); }); @@ -1963,15 +2024,15 @@ module.exports = (session) => { .delete() .where('name', 'A Star is Born') .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(Movie.beforeDelete.calls.length).to.equal(1); - expect(Movie.beforeDelete.calls[0].itWorked).to.equal(true); + expect(numUpdated).toBe(1); + expect(Movie.beforeDelete.calls.length).toBe(1); + expect(Movie.beforeDelete.calls[0].itWorked).toBe(true); }); }); it('should have access to `context`', () => { Movie.beforeDelete = createHookSpy(({ context }) => { - expect(context).to.eql({ a: 1 }); + expect(context).toEqual({ a: 1 }); }); return Movie.query() @@ -1979,20 +2040,20 @@ module.exports = (session) => { .where('name', 'A Star is Born') .context({ a: 1 }) .then(() => { - expect(Movie.beforeDelete.calls.length).to.equal(1); + expect(Movie.beforeDelete.calls.length).toBe(1); }); }); it('should have access to `transaction`', () => { Movie.beforeDelete = createHookSpy(({ transaction }) => { - expect(transaction).to.equal(Movie.knex()); + expect(transaction).toBe(Movie.knex()); }); return Movie.query() .delete() .where('name', 'A Star is Born') .then(() => { - expect(Movie.beforeDelete.calls.length).to.equal(1); + expect(Movie.beforeDelete.calls.length).toBe(1); }); }); @@ -2002,8 +2063,8 @@ module.exports = (session) => { .select('name') .forUpdate() .then((moviesToBeDeleted) => { - chaiExpect(moviesToBeDeleted).to.have.length(1); - chaiExpect(moviesToBeDeleted).containSubset([ + expect(moviesToBeDeleted).toHaveLength(1); + expect(moviesToBeDeleted).toContainSubset([ { name: 'A Star is Born', }, @@ -2016,9 +2077,9 @@ module.exports = (session) => { .delete() .where('name', 'A Star is Born') .then(() => { - expect(Movie.beforeDelete.calls.length).to.equal(1); - expect(Movie.beforeDelete.calls[0].queryWasAwaited).to.equal(true); - expect(queries.length).to.equal(2); + expect(Movie.beforeDelete.calls.length).toBe(1); + expect(Movie.beforeDelete.calls[0].queryWasAwaited).toBe(true); + expect(queries.length).toBe(2); }); }); @@ -2031,8 +2092,8 @@ module.exports = (session) => { .delete() .where('name', 'A Star is Born') .then((numDeleted) => { - expect(numDeleted).to.eql(0); - expect(queries.length).to.equal(0); + expect(numDeleted).toBe(0); + expect(queries.length).toBe(0); }); }); @@ -2045,8 +2106,8 @@ module.exports = (session) => { .delete() .where('name', 'A Star is Born') .then((result) => { - expect(result).to.eql(['lol']); - expect(queries.length).to.equal(0); + expect(result).toEqual(['lol']); + expect(queries.length).toBe(0); }); }); @@ -2062,14 +2123,14 @@ module.exports = (session) => { .findOne({ name: 'Silver Linings Playbook' }) .delete(); - expect(numPatched).to.equal(1); - expect(queries.length).to.equal(1); - expect(queries[0].bindings).to.eql(['deleted', 'Silver Linings Playbook']); + expect(numPatched).toBe(1); + expect(queries.length).toBe(1); + expect(queries[0].bindings).toEqual(['deleted', 'Silver Linings Playbook']); if (session.isMySql()) { - expect(queries[0].sql).to.equal('update `movies` set `name` = ? where `name` = ?'); + expect(queries[0].sql).toBe('update `movies` set `name` = ? where `name` = ?'); } else if (session.isPostgres()) { - expect(queries[0].sql).to.equal('update "movies" set "name" = ? where "name" = ?'); + expect(queries[0].sql).toBe('update "movies" set "name" = ? where "name" = ?'); } }); }); @@ -2080,9 +2141,9 @@ module.exports = (session) => { .findOne({ name: 'Silver Linings Playbook' }) .then((movie) => { Movie.beforeDelete = createHookSpy(({ items, inputItems }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Silver Linings Playbook', }, @@ -2092,8 +2153,8 @@ module.exports = (session) => { return movie.$query().delete(); }) .then((numDeleted) => { - expect(numDeleted).to.equal(1); - expect(Movie.beforeDelete.calls.length).to.equal(1); + expect(numDeleted).toBe(1); + expect(Movie.beforeDelete.calls.length).toBe(1); }); }); @@ -2108,10 +2169,10 @@ module.exports = (session) => { .select('name') .forUpdate() .then((moviesToBeDeleted) => { - chaiExpect(moviesToBeDeleted).to.have.length(1); + expect(moviesToBeDeleted).toHaveLength(1); // Note: moviesToBeDeleted must be an array even though $query() // would normally produce a single item. - chaiExpect(moviesToBeDeleted).containSubset([ + expect(moviesToBeDeleted).toContainSubset([ { name: 'Silver Linings Playbook', }, @@ -2123,10 +2184,10 @@ module.exports = (session) => { return movie.$query().delete(); }) .then((numDeleted) => { - expect(numDeleted).to.equal(1); - expect(Movie.beforeDelete.calls.length).to.equal(1); - expect(Movie.beforeDelete.calls[0].queryWasAwaited).to.equal(true); - expect(queries.length).to.equal(2); + expect(numDeleted).toBe(1); + expect(Movie.beforeDelete.calls.length).toBe(1); + expect(Movie.beforeDelete.calls[0].queryWasAwaited).toBe(true); + expect(queries.length).toBe(2); }); }); }); @@ -2138,66 +2199,83 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Movie.beforeDelete = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - expect(relation).to.equal(Person.getRelation('movies')); + expect(relation).toBe(Person.getRelation('movies')); }); return person.$relatedQuery('movies').delete(); }) .then((numDeleted) => { - expect(numDeleted).to.equal(2); - expect(Movie.beforeDelete.calls.length).to.equal(1); + expect(numDeleted).toBe(2); + expect(Movie.beforeDelete.calls.length).toBe(1); }); }); - it('should be able to fetch the rows about to be deleted`', function () { - // knex puts the lock of `forUpdate()` after the join on SQL Server, - // instead of after the table. - if (session.isMsSql()) { - return this.skip(); - } - - return Person.query() - .findOne({ name: 'Jennifer' }) - .then((person) => { - queries = []; - - Movie.beforeDelete = createHookSpy(({ asFindQuery }, call) => { - return asFindQuery() - .select('name') - .forUpdate() - .then((moviesToBeDeleted) => { - expect(moviesToBeDeleted.length).to.equal(2); - - chaiExpect(moviesToBeDeleted).containSubset([ - { - name: 'Silver Linings Playbook', - }, - { - name: 'Hungergames', - }, - ]); + it('should not expose the owner join aliases of many-to-many relations in `items` of upsertGraph', async () => { + const person = await Person.query().findOne({ name: 'Jennifer' }); + const silver = await Movie.query().findOne({ name: 'Silver Linings Playbook' }); + + Movie.beforeDelete = createHookSpy(({ items }) => { + expect(items.length).toBe(1); + expect(items[0].movies.length).toBe(2); + + for (const movie of items[0].movies) { + expect(movie).not.toHaveProperty('objectiontmpjoin0'); + } + }); + + await Person.query().upsertGraph({ id: person.id, movies: [{ id: silver.id }] }); + + expect(Movie.beforeDelete.calls.length).toBe(1); + }); + + it.skipIf(session.isMsSql())( + 'should be able to fetch the rows about to be deleted`', + () => { + // knex puts the lock of `forUpdate()` after the join on SQL Server, + // instead of after the table. + return Person.query() + .findOne({ name: 'Jennifer' }) + .then((person) => { + queries = []; + + Movie.beforeDelete = createHookSpy(({ asFindQuery }, call) => { + return asFindQuery() + .select('name') + .forUpdate() + .then((moviesToBeDeleted) => { + expect(moviesToBeDeleted.length).toBe(2); + + expect(moviesToBeDeleted).toContainSubset([ + { + name: 'Silver Linings Playbook', + }, + { + name: 'Hungergames', + }, + ]); + + call.queryWasAwaited = true; + }); + }); - call.queryWasAwaited = true; - }); + return person.$relatedQuery('movies').delete(); + }) + .then((numDeleted) => { + expect(numDeleted).toBe(2); + expect(Movie.beforeDelete.calls.length).toBe(1); + expect(Movie.beforeDelete.calls[0].queryWasAwaited).toBe(true); + expect(queries.length).toBe(2); }); - - return person.$relatedQuery('movies').delete(); - }) - .then((numDeleted) => { - expect(numDeleted).to.equal(2); - expect(Movie.beforeDelete.calls.length).to.equal(1); - expect(Movie.beforeDelete.calls[0].queryWasAwaited).to.equal(true); - expect(queries.length).to.equal(2); - }); - }); + }, + ); }); describe('has many', () => { @@ -2206,22 +2284,22 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Pet.beforeDelete = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - expect(relation).to.equal(Person.getRelation('pets')); + expect(relation).toBe(Person.getRelation('pets')); }); return person.$relatedQuery('pets').delete(); }) .then((numDeleted) => { - expect(numDeleted).to.equal(2); - expect(Pet.beforeDelete.calls.length).to.equal(1); + expect(numDeleted).toBe(2); + expect(Pet.beforeDelete.calls.length).toBe(1); }); }); @@ -2236,9 +2314,9 @@ module.exports = (session) => { .select('name') .forUpdate() .then((moviesToBeDeleted) => { - expect(moviesToBeDeleted.length).to.equal(2); + expect(moviesToBeDeleted.length).toBe(2); - chaiExpect(moviesToBeDeleted).containSubset([ + expect(moviesToBeDeleted).toContainSubset([ { name: 'Doggo', }, @@ -2254,10 +2332,10 @@ module.exports = (session) => { return person.$relatedQuery('pets').delete(); }) .then((numDeleted) => { - expect(numDeleted).to.equal(2); - expect(Pet.beforeDelete.calls.length).to.equal(1); - expect(Pet.beforeDelete.calls[0].queryWasAwaited).to.equal(true); - expect(queries.length).to.equal(2); + expect(numDeleted).toBe(2); + expect(Pet.beforeDelete.calls.length).toBe(1); + expect(Pet.beforeDelete.calls[0].queryWasAwaited).toBe(true); + expect(queries.length).toBe(2); }); }); }); @@ -2268,22 +2346,22 @@ module.exports = (session) => { .findOne({ name: 'Doggo' }) .then((pet) => { Person.beforeDelete = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Doggo', }, ]); - expect(relation).to.equal(Pet.getRelation('owner')); + expect(relation).toBe(Pet.getRelation('owner')); }); return pet.$relatedQuery('owner').delete(); }) .then((numDeleted) => { - expect(numDeleted).to.equal(1); - expect(Person.beforeDelete.calls.length).to.equal(1); + expect(numDeleted).toBe(1); + expect(Person.beforeDelete.calls.length).toBe(1); }); }); @@ -2298,9 +2376,9 @@ module.exports = (session) => { .select('name') .forUpdate() .then((peopleToBeDeleted) => { - expect(peopleToBeDeleted.length).to.equal(1); + expect(peopleToBeDeleted.length).toBe(1); - chaiExpect(peopleToBeDeleted).containSubset([ + expect(peopleToBeDeleted).toContainSubset([ { name: 'Jennifer', }, @@ -2313,10 +2391,10 @@ module.exports = (session) => { return pet.$relatedQuery('owner').delete(); }) .then((numDeleted) => { - expect(numDeleted).to.equal(1); - expect(Person.beforeDelete.calls.length).to.equal(1); - expect(Person.beforeDelete.calls[0].queryWasAwaited).to.equal(true); - expect(queries.length).to.equal(2); + expect(numDeleted).toBe(1); + expect(Person.beforeDelete.calls.length).toBe(1); + expect(Person.beforeDelete.calls[0].queryWasAwaited).toBe(true); + expect(queries.length).toBe(2); }); }); }); @@ -2390,15 +2468,15 @@ module.exports = (session) => { return Movie.query() .delete() .then((numDeleted) => { - expect(numDeleted).to.equal(3); - expect(Movie.afterDelete.calls.length).to.equal(1); + expect(numDeleted).toBe(3); + expect(Movie.afterDelete.calls.length).toBe(1); }); }); it('should be able to change the result', () => { Movie.afterDelete = createHookSpy(({ result }) => { return { - numDeleted: result[0], + numDeleted: result, }; }); @@ -2406,7 +2484,7 @@ module.exports = (session) => { .delete() .where('name', 'Hungergames') .then((result) => { - expect(result).to.eql({ numDeleted: 1 }); + expect(result).toEqual({ numDeleted: 1 }); }); }); @@ -2421,15 +2499,15 @@ module.exports = (session) => { .delete() .where('name', 'Hungergames') .then((numDeleted) => { - expect(numDeleted).to.equal(1); - expect(Movie.afterDelete.calls.length).to.equal(1); - expect(Movie.afterDelete.calls[0].itWorked).to.equal(true); + expect(numDeleted).toBe(1); + expect(Movie.afterDelete.calls.length).toBe(1); + expect(Movie.afterDelete.calls[0].itWorked).toBe(true); }); }); it('should have access to `context`', () => { Movie.afterDelete = createHookSpy(({ context }) => { - expect(context).to.eql({ a: 1 }); + expect(context).toEqual({ a: 1 }); }); return Movie.query() @@ -2437,20 +2515,20 @@ module.exports = (session) => { .where('name', 'Hungergames') .context({ a: 1 }) .then(() => { - expect(Movie.afterDelete.calls.length).to.equal(1); + expect(Movie.afterDelete.calls.length).toBe(1); }); }); it('should have access to `transaction`', () => { Movie.afterDelete = createHookSpy(({ transaction }) => { - expect(transaction).to.equal(Movie.knex()); + expect(transaction).toBe(Movie.knex()); }); return Movie.query() .delete() .where('name', 'Hungergames') .then(() => { - expect(Movie.afterDelete.calls.length).to.equal(1); + expect(Movie.afterDelete.calls.length).toBe(1); }); }); }); @@ -2461,9 +2539,9 @@ module.exports = (session) => { .findOne({ name: 'Silver Linings Playbook' }) .then((movie) => { Movie.afterDelete = createHookSpy(({ items }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Silver Linings Playbook', }, @@ -2473,8 +2551,8 @@ module.exports = (session) => { return movie.$query().delete(); }) .then((numDeleted) => { - expect(numDeleted).to.equal(1); - expect(Movie.afterDelete.calls.length).to.equal(1); + expect(numDeleted).toBe(1); + expect(Movie.afterDelete.calls.length).toBe(1); }); }); }); @@ -2486,22 +2564,22 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Movie.afterDelete = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - expect(relation).to.equal(Person.getRelation('movies')); + expect(relation).toBe(Person.getRelation('movies')); }); return person.$relatedQuery('movies').delete(); }) .then((numDeleted) => { - expect(numDeleted).to.equal(2); - expect(Movie.afterDelete.calls.length).to.equal(1); + expect(numDeleted).toBe(2); + expect(Movie.afterDelete.calls.length).toBe(1); }); }); @@ -2518,10 +2596,10 @@ module.exports = (session) => { .where('movies.name', 'Hungergames') .delete(); - expect(numDeleted).to.equal(1); - expect(Movie.afterDelete.calls.length).to.equal(1); + expect(numDeleted).toBe(1); + expect(Movie.afterDelete.calls.length).toBe(1); // The deleted row is gone. The remaining related row must not match. - expect(found).to.eql([]); + expect(found).toEqual([]); }); }); @@ -2531,22 +2609,22 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Pet.afterDelete = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - expect(relation).to.equal(Person.getRelation('pets')); + expect(relation).toBe(Person.getRelation('pets')); }); return person.$relatedQuery('pets').delete(); }) .then((numDeleted) => { - expect(numDeleted).to.equal(2); - expect(Pet.afterDelete.calls.length).to.equal(1); + expect(numDeleted).toBe(2); + expect(Pet.afterDelete.calls.length).toBe(1); }); }); }); @@ -2557,22 +2635,22 @@ module.exports = (session) => { .findOne({ name: 'Doggo' }) .then((pet) => { Person.afterDelete = createHookSpy(({ items, relation }) => { - expect(items.length).to.equal(1); + expect(items.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Doggo', }, ]); - expect(relation).to.equal(Pet.getRelation('owner')); + expect(relation).toBe(Pet.getRelation('owner')); }); return pet.$relatedQuery('owner').delete(); }) .then((numDeleted) => { - expect(numDeleted).to.equal(1); - expect(Person.afterDelete.calls.length).to.equal(1); + expect(numDeleted).toBe(1); + expect(Person.afterDelete.calls.length).toBe(1); }); }); }); @@ -2646,8 +2724,8 @@ module.exports = (session) => { return Movie.query() .insert({ name: 'Inserted' }) .then((movie) => { - expect(movie.id).to.be.a('number'); - expect(Movie.beforeInsert.calls.length).to.equal(1); + expect(movie.id).toBeTypeOf('number'); + expect(Movie.beforeInsert.calls.length).toBe(1); }); }); @@ -2661,42 +2739,42 @@ module.exports = (session) => { return Movie.query() .insert({ name: 'Inserted' }) .then((movie) => { - expect(movie.id).to.be.a('number'); - expect(Movie.beforeInsert.calls.length).to.equal(1); - expect(Movie.beforeInsert.calls[0].itWorked).to.equal(true); + expect(movie.id).toBeTypeOf('number'); + expect(Movie.beforeInsert.calls.length).toBe(1); + expect(Movie.beforeInsert.calls[0].itWorked).toBe(true); }); }); it('should have access to `context`', () => { Movie.beforeInsert = createHookSpy(({ context }) => { - expect(context).to.eql({ a: 1 }); + expect(context).toEqual({ a: 1 }); }); return Movie.query() .insert({ name: 'Inserted' }) .context({ a: 1 }) .then(() => { - expect(Movie.beforeInsert.calls.length).to.equal(1); + expect(Movie.beforeInsert.calls.length).toBe(1); }); }); it('should have access to `transaction`', () => { Movie.beforeInsert = createHookSpy(({ transaction }) => { - expect(transaction).to.equal(Movie.knex()); + expect(transaction).toBe(Movie.knex()); }); return Movie.query() .insert({ name: 'Inserted' }) .then(() => { - expect(Movie.beforeInsert.calls.length).to.equal(1); + expect(Movie.beforeInsert.calls.length).toBe(1); }); }); it('should have access to `inputItems`', async () => { Movie.beforeInsert = createHookSpy(({ inputItems }) => { - expect(inputItems.length).to.equal(1); - expect(inputItems[0] instanceof Movie).to.equal(true); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems.length).toBe(1); + expect(inputItems[0] instanceof Movie).toBe(true); + expect(inputItems).toContainSubset([ { name: 'Inserted', }, @@ -2705,7 +2783,7 @@ module.exports = (session) => { await Movie.query().insert({ name: 'Inserted' }); await Movie.query().insertAndFetch({ name: 'Inserted' }); - expect(Movie.beforeInsert.calls.length).to.equal(2); + expect(Movie.beforeInsert.calls.length).toBe(2); }); it('should be able to cancel the query', () => { @@ -2716,9 +2794,9 @@ module.exports = (session) => { return Movie.query() .insert({ name: 'Inserted' }) .then((result) => { - expect(result.name).to.equal('Inserted'); - expect(result.id).to.equal(undefined); - expect(queries.length).to.equal(0); + expect(result.name).toBe('Inserted'); + expect(result.id).toBeUndefined(); + expect(queries.length).toBe(0); }); }); @@ -2730,8 +2808,8 @@ module.exports = (session) => { return Movie.query() .insert({ name: 'Inserted' }) .then((result) => { - expect(result.lol).to.equal(true); - expect(queries.length).to.equal(0); + expect(result.lol).toBe(true); + expect(queries.length).toBe(0); }); }); }); @@ -2741,16 +2819,16 @@ module.exports = (session) => { const movie = Movie.fromJson({ name: 'Inserted' }); Movie.beforeInsert = createHookSpy(({ items, inputItems }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Inserted', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { name: 'Inserted', }, @@ -2761,8 +2839,8 @@ module.exports = (session) => { .$query() .insert() .then((result) => { - expect(result.id).to.be.a('number'); - expect(Movie.beforeInsert.calls.length).to.equal(1); + expect(result.id).toBeTypeOf('number'); + expect(Movie.beforeInsert.calls.length).toBe(1); }); }); }); @@ -2774,29 +2852,29 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Movie.beforeInsert = createHookSpy(({ items, inputItems, relation }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { name: 'Inserted', }, ]); - expect(relation).to.equal(Person.getRelation('movies')); + expect(relation).toBe(Person.getRelation('movies')); }); return person.$relatedQuery('movies').insert({ name: 'Inserted' }); }) .then((inserted) => { - expect(inserted.id).to.be.a('number'); - expect(Movie.beforeInsert.calls.length).to.equal(1); + expect(inserted.id).toBeTypeOf('number'); + expect(Movie.beforeInsert.calls.length).toBe(1); }); }); @@ -2813,9 +2891,9 @@ module.exports = (session) => { return person.$relatedQuery('movies').insert({ name: 'Inserted' }); }) .then((inserted) => { - expect(inserted.id).to.equal(undefined); - expect(Movie.beforeInsert.calls.length).to.equal(1); - expect(queries.length).to.equal(0); + expect(inserted.id).toBeUndefined(); + expect(Movie.beforeInsert.calls.length).toBe(1); + expect(queries.length).toBe(0); }); }); }); @@ -2826,30 +2904,30 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Pet.beforeInsert = createHookSpy(({ items, inputItems, relation }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { species: 'Frog', }, ]); - expect(relation).to.equal(Person.getRelation('pets')); + expect(relation).toBe(Person.getRelation('pets')); }); return person.$relatedQuery('pets').insert({ species: 'Frog' }); }) .then((inserted) => { - expect(inserted.id).to.be.a('number'); - expect(inserted.species).to.equal('Frog'); - expect(Pet.beforeInsert.calls.length).to.equal(1); + expect(inserted.id).toBeTypeOf('number'); + expect(inserted.species).toBe('Frog'); + expect(Pet.beforeInsert.calls.length).toBe(1); }); }); }); @@ -2862,39 +2940,39 @@ module.exports = (session) => { Pet.beforeUpdate = createHookSpy(async ({ asFindQuery }, call) => { const pets = await asFindQuery().select('name'); - expect(pets).to.have.length(1); - expect(pets[0].name).to.equal('Doggo'); + expect(pets).toHaveLength(1); + expect(pets[0].name).toBe('Doggo'); call.queryWasAwaited = true; }); Person.beforeInsert = createHookSpy(({ items, inputItems, relation }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Doggo', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { name: 'New Owner', }, ]); - expect(relation).to.equal(Pet.getRelation('owner')); + expect(relation).toBe(Pet.getRelation('owner')); }); return pet.$relatedQuery('owner').insert({ name: 'New Owner' }); }) .then((inserted) => { - expect(inserted.id).to.be.a('number'); - expect(inserted.name).to.equal('New Owner'); - expect(Person.beforeInsert.calls.length).to.equal(1); - expect(Pet.beforeUpdate.calls.length).to.equal(1); - expect(Pet.beforeUpdate.calls[0].queryWasAwaited).to.equal(true); + expect(inserted.id).toBeTypeOf('number'); + expect(inserted.name).toBe('New Owner'); + expect(Person.beforeInsert.calls.length).toBe(1); + expect(Pet.beforeUpdate.calls.length).toBe(1); + expect(Pet.beforeUpdate.calls[0].queryWasAwaited).toBe(true); }); }); @@ -2911,10 +2989,10 @@ module.exports = (session) => { return pet.$relatedQuery('owner').insert({ name: 'New Owner' }); }) .then((inserted) => { - expect(inserted.id).to.equal(undefined); - expect(inserted.name).to.equal('New Owner'); - expect(Person.beforeInsert.calls.length).to.equal(1); - expect(queries.length).to.equal(0); + expect(inserted.id).toBeUndefined(); + expect(inserted.name).toBe('New Owner'); + expect(Person.beforeInsert.calls.length).toBe(1); + expect(queries.length).toBe(0); }); }); }); @@ -2988,24 +3066,24 @@ module.exports = (session) => { return Movie.query() .insert({ name: 'Inserted' }) .then((movie) => { - expect(movie.id).to.be.a('number'); - expect(Movie.afterInsert.calls.length).to.equal(1); + expect(movie.id).toBeTypeOf('number'); + expect(Movie.afterInsert.calls.length).toBe(1); }); }); it('should be able to change the result', () => { Movie.afterInsert = createHookSpy(({ result }) => { return { - someId: result[0].id, - someName: result[0].name, + someId: result.id, + someName: result.name, }; }); return Movie.query() .insert({ name: 'Inserted' }) .then((result) => { - expect(result.someId).to.be.a('number'); - expect(result.someName).to.equal('Inserted'); + expect(result.someId).toBeTypeOf('number'); + expect(result.someName).toBe('Inserted'); }); }); @@ -3019,41 +3097,41 @@ module.exports = (session) => { return Movie.query() .insert({ name: 'Inserted' }) .then(() => { - expect(Movie.afterInsert.calls.length).to.equal(1); - expect(Movie.afterInsert.calls[0].itWorked).to.equal(true); + expect(Movie.afterInsert.calls.length).toBe(1); + expect(Movie.afterInsert.calls[0].itWorked).toBe(true); }); }); it('should have access to `context`', () => { Movie.afterInsert = createHookSpy(({ context }) => { - expect(context).to.eql({ a: 1 }); + expect(context).toEqual({ a: 1 }); }); return Movie.query() .insert({ name: 'Inserted' }) .context({ a: 1 }) .then(() => { - expect(Movie.afterInsert.calls.length).to.equal(1); + expect(Movie.afterInsert.calls.length).toBe(1); }); }); it('should have access to `transaction`', () => { Movie.afterInsert = createHookSpy(({ transaction }) => { - expect(transaction).to.equal(Movie.knex()); + expect(transaction).toBe(Movie.knex()); }); return Movie.query() .insert({ name: 'Inserted' }) .then(() => { - expect(Movie.afterInsert.calls.length).to.equal(1); + expect(Movie.afterInsert.calls.length).toBe(1); }); }); it('should have access to `inputItems`', () => { Movie.afterInsert = createHookSpy(({ inputItems }) => { - expect(inputItems.length).to.equal(1); - expect(inputItems[0] instanceof Movie).to.equal(true); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems.length).toBe(1); + expect(inputItems[0] instanceof Movie).toBe(true); + expect(inputItems).toContainSubset([ { name: 'Inserted', }, @@ -3063,7 +3141,7 @@ module.exports = (session) => { return Movie.query() .insert({ name: 'Inserted' }) .then(() => { - expect(Movie.afterInsert.calls.length).to.equal(1); + expect(Movie.afterInsert.calls.length).toBe(1); }); }); }); @@ -3073,16 +3151,16 @@ module.exports = (session) => { const movie = Movie.fromJson({ name: 'Inserted' }); Movie.afterInsert = createHookSpy(({ items, inputItems }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Inserted', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { name: 'Inserted', }, @@ -3093,8 +3171,8 @@ module.exports = (session) => { .$query() .insert() .then((movie) => { - expect(movie.id).to.be.a('number'); - expect(Movie.afterInsert.calls.length).to.equal(1); + expect(movie.id).toBeTypeOf('number'); + expect(Movie.afterInsert.calls.length).toBe(1); }); }); }); @@ -3106,29 +3184,29 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Movie.afterInsert = createHookSpy(({ items, inputItems, relation }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { name: 'Inserted', }, ]); - expect(relation).to.equal(Person.getRelation('movies')); + expect(relation).toBe(Person.getRelation('movies')); }); return person.$relatedQuery('movies').insert({ name: 'Inserted' }); }) .then((inserted) => { - expect(inserted.id).to.be.a('number'); - expect(Movie.afterInsert.calls.length).to.equal(1); + expect(inserted.id).toBeTypeOf('number'); + expect(Movie.afterInsert.calls.length).toBe(1); }); }); }); @@ -3139,30 +3217,30 @@ module.exports = (session) => { .findOne({ name: 'Jennifer' }) .then((person) => { Pet.afterInsert = createHookSpy(({ items, inputItems, relation }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Jennifer', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { name: 'Lol', species: 'Frog', }, ]); - expect(relation).to.equal(Person.getRelation('pets')); + expect(relation).toBe(Person.getRelation('pets')); }); return person.$relatedQuery('pets').insert({ name: 'Lol', species: 'Frog' }); }) .then((inserted) => { - expect(inserted.id).to.be.a('number'); - expect(Pet.afterInsert.calls.length).to.equal(1); + expect(inserted.id).toBeTypeOf('number'); + expect(Pet.afterInsert.calls.length).toBe(1); }); }); }); @@ -3173,34 +3251,571 @@ module.exports = (session) => { .findOne({ name: 'Doggo' }) .then((pet) => { Person.afterInsert = createHookSpy(({ items, inputItems, relation }) => { - expect(items.length).to.equal(1); - expect(inputItems.length).to.equal(1); + expect(items.length).toBe(1); + expect(inputItems.length).toBe(1); - chaiExpect(items).to.containSubset([ + expect(items).toContainSubset([ { name: 'Doggo', }, ]); - chaiExpect(inputItems).to.containSubset([ + expect(inputItems).toContainSubset([ { name: 'New Owner', }, ]); - expect(relation).to.equal(Pet.getRelation('owner')); + expect(relation).toBe(Pet.getRelation('owner')); }); return pet.$relatedQuery('owner').insert({ name: 'New Owner' }); }) .then((inserted) => { - expect(inserted.id).to.be.a('number'); - expect(Person.afterInsert.calls.length).to.equal(1); + expect(inserted.id).toBeTypeOf('number'); + expect(Person.afterInsert.calls.length).toBe(1); }); }); }); }); }); + + describe('result', () => { + let jennifer; + let brad; + let calls; + + beforeEach(async () => { + [jennifer, brad] = await Person.query().insertGraph([ + { + name: 'Jennifer', + pets: [ + { name: 'Doggo', species: 'dog' }, + { name: 'Cato', species: 'cat' }, + ], + movies: [{ name: 'Silver Linings Playbook' }], + }, + { name: 'Brad' }, + ]); + + calls = []; + + // Log the calls to the after hooks with their results, in call order. + for (const modelClass of [Person, Pet, Movie]) { + for (const name of ['afterFind', 'afterInsert', 'afterUpdate', 'afterDelete']) { + modelClass[name] = createHookSpy(({ result }) => { + calls.push({ hook: `${modelClass.tableName}.${name}`, result }); + }); + } + } + }); + + it('should be the model with `first()`', async () => { + const person = await Person.query().orderBy('name', 'desc').first(); + + expect(person.name).toBe('Jennifer'); + expect(calls).toEqual([{ hook: 'people.afterFind', result: person }]); + }); + + it('should be the model with `findById()`', async () => { + const person = await Person.query().findById(jennifer.id); + + expect(person.name).toBe('Jennifer'); + expect(calls).toEqual([{ hook: 'people.afterFind', result: person }]); + }); + + it('should be undefined with `findById()` if nothing is found', async () => { + const person = await Person.query().findById(jennifer.id + brad.id); + + expect(person).toBeUndefined(); + expect(calls).toEqual([{ hook: 'people.afterFind', result: undefined }]); + }); + + it('should be the models with `page()`', async () => { + const page = await Person.query().orderBy('name', 'desc').page(0, 1); + + expect(page.total).toBe(2); + expect(page.results.map((person) => person.name)).toEqual(['Jennifer']); + expect(calls).toEqual([{ hook: 'people.afterFind', result: page.results }]); + }); + + it('should be used as the input of `page()`', async () => { + Person.afterFind = ({ result }) => result.map((person) => person.name); + + const page = await Person.query().orderBy('name', 'desc').page(0, 1); + expect(page).toEqual({ results: ['Jennifer'], total: 2 }); + }); + + it('should be returned before `runAfter()` callbacks are called', async () => { + Person.afterFind = ({ result }) => result.name; + const results = []; + + const name = await Person.query() + .context({ runAfter: (result) => results.push(result) && result }) + .runAfter((result) => results.push(result) && result.toUpperCase()) + .findById(jennifer.id); + + expect(name).toBe('JENNIFER'); + expect(results).toEqual(['Jennifer', 'Jennifer']); + }); + + it('should be used by `throwIfNotFound()`', async () => { + Person.afterFind = () => []; + + await expect(Person.query().findById(jennifer.id).throwIfNotFound()).rejects.toThrow( + Person.NotFoundError, + ); + }); + + it('should be the inserted model with `insert()`', async () => { + const person = await Person.query().insert({ name: 'Ada' }); + + expect(person.id).toBeTypeOf('number'); + expect(calls).toEqual([{ hook: 'people.afterInsert', result: person }]); + }); + + it('should be the inserted models with `insert()` and an array', async () => { + const people = await Person.query().insert([{ name: 'Ada' }]); + + expect(people).toHaveLength(1); + expect(calls).toEqual([{ hook: 'people.afterInsert', result: people }]); + }); + + it('should be the fetched model with `insertAndFetch()`, after the fetch', async () => { + const person = await Person.query().insertAndFetch({ name: 'Ada' }); + + expect(person.id).toBeTypeOf('number'); + expect(calls).toEqual([ + { hook: 'people.afterFind', result: [person] }, + { hook: 'people.afterInsert', result: person }, + ]); + }); + + it('should be the number of updated rows with `patch()`', async () => { + const numUpdated = await Person.query().patch({ name: 'Updated' }); + + expect(numUpdated).toBe(2); + expect(calls).toEqual([{ hook: 'people.afterUpdate', result: 2 }]); + }); + + it('should be the fetched model with `patchAndFetchById()`, after the fetch', async () => { + const person = await Person.query().patchAndFetchById(jennifer.id, { name: 'Updated' }); + + expect(person.name).toBe('Updated'); + expect(calls).toEqual([ + { hook: 'people.afterFind', result: person }, + { hook: 'people.afterUpdate', result: person }, + ]); + }); + + it('should be undefined with `patchAndFetchById()` if nothing is updated', async () => { + const id = jennifer.id + brad.id; + const person = await Person.query().patchAndFetchById(id, { name: 'Updated' }); + + expect(person).toBeUndefined(); + expect(calls).toEqual([{ hook: 'people.afterUpdate', result: undefined }]); + }); + + it('should be the fetched model with `$query().patchAndFetch()`', async () => { + const person = await jennifer.$query().patchAndFetch({ name: 'Updated' }); + + expect(person.name).toBe('Updated'); + expect(calls).toEqual([ + { hook: 'people.afterFind', result: person }, + { hook: 'people.afterUpdate', result: person }, + ]); + }); + + it('should throw if `afterFind()` returns no model for `patchAndFetchById()`', async () => { + Person.afterFind = ({ result }) => result.name; + + await expect( + Person.query().patchAndFetchById(jennifer.id, { name: 'Updated' }), + ).rejects.toThrow( + 'Unable to merge the result of Person.afterFind() in patchAndFetch(): expected a model.', + ); + }); + + it('should throw if `afterFind()` returns no models for `insertAndFetch()`', async () => { + Person.afterFind = ({ result }) => result.map((person) => person.name); + + await expect(Person.query().insertAndFetch({ name: 'Ada' })).rejects.toThrow( + 'Unable to merge the result of Person.afterFind() in insertAndFetch(): ' + + 'expected an array of models.', + ); + }); + + it('should be the number of deleted rows with `delete()`', async () => { + const numDeleted = await Pet.query().delete().where('name', 'Doggo'); + + expect(numDeleted).toBe(1); + expect(calls).toEqual([{ hook: 'pets.afterDelete', result: 1 }]); + }); + + it('should be the related models with `$relatedQuery()`', async () => { + const pets = await jennifer.$relatedQuery('pets').orderBy('name', 'desc'); + + expect(pets.map((pet) => pet.name)).toEqual(['Doggo', 'Cato']); + expect(calls).toEqual([{ hook: 'pets.afterFind', result: pets }]); + }); + + it('should be the related model with `$relatedQuery()` of a to-one relation', async () => { + const pet = await Pet.query().findOne({ name: 'Doggo' }); + calls = []; + + const owner = await pet.$relatedQuery('owner'); + expect(owner.name).toBe('Jennifer'); + expect(calls).toEqual([{ hook: 'people.afterFind', result: owner }]); + }); + + it('should be the related models of all owners in `withGraphFetched()`', async () => { + const people = await Person.query() + .withGraphFetched('pets') + .modifyGraph('pets', (builder) => builder.orderBy('name', 'desc').first()) + .orderBy('name', 'desc'); + + expect(people.map((person) => person.pets.map((pet) => pet.name))).toEqual([['Doggo'], []]); + expect(calls).toEqual([ + { hook: 'pets.afterFind', result: [people[0].pets[0]] }, + { hook: 'people.afterFind', result: people }, + ]); + }); + + it('should be the inserted model with `$relatedQuery().insert()`', async () => { + const pet = await brad.$relatedQuery('pets').insert({ name: 'Rob' }); + + expect(calls).toEqual([{ hook: 'pets.afterInsert', result: pet }]); + }); + + it('should be the value of `cancelQuery()` in the shape of the query', async () => { + Person.beforeFind = ({ cancelQuery }) => cancelQuery(); + + expect(await Person.query().first()).toBeUndefined(); + expect(await Person.query().findById(jennifer.id)).toBeUndefined(); + expect(await Person.query()).toEqual([]); + expect(calls).toEqual([ + { hook: 'people.afterFind', result: undefined }, + { hook: 'people.afterFind', result: undefined }, + { hook: 'people.afterFind', result: [] }, + ]); + }); + + it('should be the value of `resolve()` and `none()` in the shape of the query', async () => { + const person = Person.fromJson({ id: 1, name: 'Resolved' }); + + expect(await Person.query().resolve([person]).first()).toBe(person); + expect(await Person.query().none()).toEqual([]); + expect(calls).toEqual([ + { hook: 'people.afterFind', result: person }, + { hook: 'people.afterFind', result: [] }, + ]); + }); + + it('should merge a single row given to `cancelQuery()` into the inserted model', async () => { + Person.beforeInsert = ({ cancelQuery }) => cancelQuery({ id: 42 }); + queries = []; + + const person = await Person.query().insert({ name: 'Ada' }); + expect(person).toEqual(Person.fromJson({ id: 42, name: 'Ada' })); + expect(calls).toEqual([{ hook: 'people.afterInsert', result: person }]); + expect(queries).toHaveLength(0); + }); + + it('should not call the hooks of the root class for the outer `upsertGraph()` query', async () => { + Person.beforeFind = createHookSpy(); + + await Person.query().upsertGraph({ id: brad.id, name: 'Bradley' }); + + // The query that fetches the current graph calls the find hooks. + expect(Person.beforeFind.calls).toHaveLength(1); + expect(calls).toEqual([ + { hook: 'people.afterFind', result: [expect.objectContaining({ id: brad.id })] }, + { hook: 'people.afterUpdate', result: 1 }, + ]); + }); + + it('should not call the hooks of the root class for the outer `insertGraph()` query', async () => { + const [person] = await Person.query().insertGraph([ + { name: 'Ada', pets: [{ name: 'Rob' }] }, + ]); + + expect(calls).toEqual([ + { hook: 'people.afterInsert', result: [person] }, + { hook: 'pets.afterInsert', result: person.pets }, + ]); + }); + + it('should not call the find hooks of the root class in `fetchGraph()`', async () => { + Person.beforeFind = createHookSpy(); + + const person = await Person.fetchGraph(jennifer.$clone(), 'pets'); + + expect(person.pets).toHaveLength(2); + expect(Person.beforeFind.calls).toHaveLength(0); + expect(calls).toEqual([{ hook: 'pets.afterFind', result: person.pets }]); + }); + + describe('withGraphFetched()', () => { + it('should assign the related models that `afterFind()` returns', async () => { + Pet.afterFind = ({ result }) => result.filter((pet) => pet.species === 'dog'); + Movie.afterFind = () => []; + + const people = await Person.query() + .withGraphFetched('[pets, movies]') + .orderBy('name', 'desc'); + + expect(people.map((person) => person.pets.map((pet) => pet.name))).toEqual([ + ['Doggo'], + [], + ]); + expect(people.map((person) => person.movies)).toEqual([[], []]); + }); + + it('should keep the order of the related models that `afterFind()` returns', async () => { + Pet.afterFind = ({ result }) => [...result].sort((a, b) => a.name.localeCompare(b.name)); + + const person = await Person.query() + .findById(jennifer.id) + .withGraphFetched('pets') + .modifyGraph('pets', (builder) => builder.orderBy('name', 'desc')); + + expect(person.pets.map((pet) => pet.name)).toEqual(['Cato', 'Doggo']); + }); + + it('should assign null to to-one relations if `afterFind()` filters the model', async () => { + Person.afterFind = () => []; + + const pet = await Pet.query().findOne({ name: 'Doggo' }).withGraphFetched('owner'); + expect(pet.owner).toBeNull(); + }); + + it('should throw if `afterFind()` returns other objects than the related models', async () => { + Pet.afterFind = ({ result }) => result.map((pet) => ({ ...pet })); + + await expect(Person.query().withGraphFetched('pets')).rejects.toThrow( + 'Unable to assign the result to relation "pets" of Person: afterFind() and ' + + 'runAfter() can only return the related models that the query found, filtered ' + + 'or reordered.', + ); + }); + + it('should assign what `runAfter()` returns', async () => { + const person = await Person.query() + .findById(jennifer.id) + .withGraphFetched('pets') + .modifyGraph('pets', (builder) => + builder.runAfter((pets) => pets.filter((pet) => pet.species === 'cat')), + ); + + expect(person.pets.map((pet) => pet.name)).toEqual(['Cato']); + }); + + it('should assign what `runAfter` callbacks of the context return', async () => { + const runAfter = (result, builder) => + builder.modelClass() === Pet ? result.filter((pet) => pet.species === 'dog') : result; + + const person = await Person.query() + .findById(jennifer.id) + .withGraphFetched('pets') + .context({ runAfter }); + + expect(person.pets.map((pet) => pet.name)).toEqual(['Doggo']); + }); + + it('should throw if `runAfter()` returns other objects than the related models', async () => { + await expect( + Person.query() + .withGraphFetched('pets') + .modifyGraph('pets', (builder) => builder.runAfter((pets) => pets.length)), + ).rejects.toThrow('Unable to assign the result to relation "pets" of Person'); + }); + + it('should assign the models of `page()` with `relatedFindQueryMutates`', async () => { + Person.relatedFindQueryMutates = true; + + const page = await jennifer.$relatedQuery('pets').orderBy('name').page(0, 1); + expect(page.total).toBe(2); + expect(jennifer.pets).toEqual(page.results); + expect(jennifer.pets.map((pet) => pet.name)).toEqual(['Cato']); + }); + + it('should assign what `afterFind()` returns with `relatedFindQueryMutates`', async () => { + Person.relatedFindQueryMutates = true; + Pet.afterFind = ({ result }) => result.filter((pet) => pet.species === 'cat'); + + const pets = await jennifer.$relatedQuery('pets'); + expect(pets.map((pet) => pet.name)).toEqual(['Cato']); + expect(jennifer.pets).toEqual(pets); + }); + }); + + describe('withGraphJoined()', () => { + it('should call `afterFind()` of the joined relations bottom-up', async () => { + const people = await Person.query() + .withGraphJoined('[pets.owner, movies]') + .orderBy('people.name', 'desc'); + + const [jennifer, brad] = people; + const pets = jennifer.pets; + const owners = pets.map((pet) => pet.owner); + + expect(calls.map(({ hook }) => hook)).toEqual([ + 'movies.afterFind', + 'people.afterFind', + 'pets.afterFind', + 'people.afterFind', + ]); + expect(calls[0].result).toEqual(jennifer.movies); + expect(calls[1].result).toEqual(owners); + expect(calls[2].result).toEqual(pets); + expect(calls[3].result).toEqual(people); + expect(brad.pets).toEqual([]); + }); + + it('should pass the relation and the owners', async () => { + Pet.afterFind = createHookSpy(); + + const people = await Person.query().withGraphJoined('pets'); + const { args } = Pet.afterFind.calls[0]; + + expect(Pet.afterFind.calls).toHaveLength(1); + expect(args.relation).toBe(Person.getRelation('pets')); + expect(args.items).toEqual(people); + expect(args.inputItems).toEqual([]); + expect(() => args.asFindQuery()).toThrow( + 'asFindQuery() is not available for relations fetched with withGraphJoined().', + ); + expect(() => args.cancelQuery()).toThrow( + 'cancelQuery() is not available for relations fetched with withGraphJoined().', + ); + }); + + it('should assign the related models that `afterFind()` returns', async () => { + Pet.afterFind = ({ result }) => result.filter((pet) => pet.species === 'dog'); + Person.afterFind = ({ relation }) => (relation ? [] : undefined); + + const person = await Person.query().findById(jennifer.id).withGraphJoined('pets.owner'); + + expect(person.pets.map((pet) => pet.name)).toEqual(['Doggo']); + expect(person.pets[0].owner).toBeNull(); + }); + + it('should keep the order of the related models that `afterFind()` returns', async () => { + Pet.afterFind = ({ result }) => [...result].sort((a, b) => a.name.localeCompare(b.name)); + + const person = await Person.query() + .findById(jennifer.id) + .withGraphJoined('pets') + .orderBy('pets.name', 'desc'); + + expect(person.pets.map((pet) => pet.name)).toEqual(['Cato', 'Doggo']); + }); + + it('should throw if `afterFind()` returns other objects than the related models', async () => { + Pet.afterFind = ({ result }) => result.map((pet) => ({ ...pet })); + + await expect(Person.query().withGraphJoined('pets')).rejects.toThrow( + 'Unable to assign the result of Pet.afterFind() to the relation "pets" of Person: ' + + 'return the models you got, filtered or reordered.', + ); + }); + }); + + describe('relate() and unrelate()', () => { + let ActorMovie; + let Actor; + + beforeEach(() => { + ActorMovie = class extends Model { + static get tableName() { + return 'actorsMovies'; + } + }; + + Actor = class extends Person { + static get relationMappings() { + return { + movies: { + relation: Model.ManyToManyRelation, + modelClass: Movie, + join: { + from: 'people.id', + through: { + modelClass: ActorMovie, + from: 'actorsMovies.personId', + to: 'actorsMovies.movieId', + }, + to: 'movies.id', + }, + }, + }; + } + }; + + ActorMovie.knex(knex); + }); + + // The hooks of the query that relate() or unrelate() runs get the + // relation and the owners of the relate or unrelate query. + const spyOn = (modelClass, name) => { + modelClass[name] = createHookSpy(({ result, relation, items }) => { + calls.push({ hook: `${modelClass.tableName}.${name}`, result, relation, items }); + }); + }; + + it('should pass the relation and the owners to the related `afterUpdate()` of has many', async () => { + spyOn(Pet, 'afterUpdate'); + const pet = await Pet.query().insert({ name: 'Rob' }); + calls = []; + + expect(await brad.$relatedQuery('pets').relate(pet.id)).toBe(1); + expect(await brad.$relatedQuery('pets').unrelate()).toBe(1); + + const relation = Person.getRelation('pets'); + expect(calls).toEqual([ + { hook: 'pets.afterUpdate', result: 1, relation, items: [brad] }, + { hook: 'pets.afterUpdate', result: 1, relation, items: [brad] }, + ]); + }); + + it('should pass the relation and the owners to the owner `afterUpdate()` of belongs to one', async () => { + spyOn(Pet, 'afterUpdate'); + const pet = await Pet.query().insert({ name: 'Rob' }); + calls = []; + + expect(await pet.$relatedQuery('owner').relate(brad.id)).toBe(1); + expect(await pet.$relatedQuery('owner').unrelate()).toBe(1); + + const relation = Pet.getRelation('owner'); + expect(calls).toEqual([ + { hook: 'pets.afterUpdate', result: 1, relation, items: [pet] }, + { hook: 'pets.afterUpdate', result: 1, relation, items: [pet] }, + ]); + }); + + it('should pass the relation and the owners to the join model hooks of many to many', async () => { + spyOn(ActorMovie, 'afterInsert'); + spyOn(ActorMovie, 'afterDelete'); + const actor = Actor.fromJson({ id: brad.id, name: 'Brad' }); + const movie = await Movie.query().insert({ name: 'Fight Club' }); + calls = []; + + expect(await actor.$relatedQuery('movies').relate(movie.id)).toBe(1); + expect(await actor.$relatedQuery('movies').unrelate()).toBe(1); + + const relation = Actor.getRelation('movies'); + expect(calls).toEqual([ + { + hook: 'actorsMovies.afterInsert', + result: [expect.objectContaining({ personId: brad.id, movieId: movie.id })], + relation, + items: [actor], + }, + { hook: 'actorsMovies.afterDelete', result: 1, relation, items: [actor] }, + ]); + }); + }); + }); }); }; diff --git a/tests/integration/toKnexQuery.js b/tests/integration/toKnexQuery.js index c375d74da..fd55ef3e5 100644 --- a/tests/integration/toKnexQuery.js +++ b/tests/integration/toKnexQuery.js @@ -1,14 +1,13 @@ -const expectJs = require('expect.js'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; -const { expect } = require('chai'); -const { Model, val, raw, initialize } = require('../../'); +import { Model, val, raw, initialize } from 'objection'; -module.exports = (session) => { +export default (session) => { describe(`toKnexQuery`, () => { const { knex } = session; let Person; - before(async () => { + beforeAll(async () => { await knex.schema.dropTableIfExists('persons'); await session.createTable('persons', (table, { increments }) => { increments('id'); @@ -17,7 +16,7 @@ module.exports = (session) => { }); }); - after(() => { + afterAll(() => { return knex.schema.dropTableIfExists('persons'); }); @@ -91,13 +90,13 @@ module.exports = (session) => { }); it('should fail with an informational error when withGraphJoined is used before warm up', () => { - expectJs(() => { + expect(() => { Person.query(knex).withGraphJoined('children').toKnexQuery(); - }).to.throwException((err) => { - expect(err.message).to.equal( - `table metadata has not been fetched for table 'persons'. Are you trying to call toKnexQuery() for a withGraphJoined query? To make sure the table metadata is fetched see the objection.initialize function.`, - ); - }); + }).toThrow( + expect.objectContaining({ + message: `table metadata has not been fetched for table 'persons'. Are you trying to call toKnexQuery() for a withGraphJoined query? To make sure the table metadata is fetched see the objection.initialize function.`, + }), + ); }); it('should fail with a informational error when withGraphJoined is used before warm up', async () => { @@ -127,8 +126,8 @@ module.exports = (session) => { function testSql({ query, sql, bindings }) { const result = query.toKnexQuery().toSQL(); - expect(normalizeSql(result.sql)).to.equal(sql); - expect(result.bindings).to.eql(bindings); + expect(normalizeSql(result.sql)).toBe(sql); + expect(result.bindings).toEqual(bindings); } function normalizeSql(sql) { diff --git a/tests/integration/transactions.js b/tests/integration/transactions.js index 746f80dfc..65ce9405f 100644 --- a/tests/integration/transactions.js +++ b/tests/integration/transactions.js @@ -1,9 +1,9 @@ -const expect = require('expect.js'); -const transaction = require('../../').transaction; -const knexUtils = require('../../lib/utils/knexUtils'); -const { delay, range } = require('../../testUtils/testUtils'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { transaction } from 'objection'; +import * as knexUtils from '../../lib/utils/knexUtils.js'; +import { delay, range } from '../../testUtils/testUtils.js'; -module.exports = (session) => { +export default (session) => { let Model1 = session.models.Model1; let Model2 = session.models.Model2; @@ -14,67 +14,54 @@ module.exports = (session) => { const noop = () => {}; - before(() => { - // Disable unhandled exception logging. Some of the tests _should_ leak an exception - // but we don't want them to appear in the log. - session.addUnhandledRejectionHandler(noop); + beforeAll(() => { + // Some of the tests _should_ leak an exception, but we don't want vitest to report them. + // vitest leaves unhandled rejections alone when there's another listener for them. + process.on('unhandledRejection', noop); }); - after(() => { - session.removeUnhandledRejectionHandler(noop); + afterAll(() => { + process.off('unhandledRejection', noop); }); - it('should resolve an empty transaction', (done) => { - transaction(Model1, Model2, () => { + it('should resolve an empty transaction', () => { + return transaction(Model1, Model2, () => { return { a: 1 }; }).then((result) => { - expect(result).to.eql({ a: 1 }); - done(); + expect(result).toEqual({ a: 1 }); }); }); - it('should fail without models', (done) => { - transaction(() => { - return { a: 1 }; - }) - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - done(); - }); + it('should fail without models', () => { + return expect( + transaction(() => { + return { a: 1 }; + }), + ).rejects.toThrow(); }); - it('should fail if one of the model classes is not a subclass of Model', (done) => { - transaction( - Model1, - function () {}, - () => { - return { a: 1 }; - }, - ) - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - done(); - }); + it('should fail if one of the model classes is not a subclass of Model', () => { + return expect( + transaction( + Model1, + function () {}, + () => { + return { a: 1 }; + }, + ), + ).rejects.toThrow(); }); - it('should fail if all ModelClasses are not bound to the same knex connection', (done) => { - transaction(Model1, Model2.bindKnex({}), () => { - return { a: 1 }; - }) - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - done(); - }); + it('should fail if all ModelClasses are not bound to the same knex connection', () => { + return expect( + transaction(Model1, Model2.bindKnex({}), () => { + return { a: 1 }; + }), + ).rejects.toThrow(); }); - it('should commit transaction if no errors occur (1)', (done) => { - transaction(Model1, Model2, (Model1, Model2) => { + it('should commit transaction if no errors occur (1)', () => { + return transaction(Model1, Model2, (Model1, Model2) => { return Model1.query() .insert({ model1Prop1: 'test 1' }) .then(() => { @@ -85,20 +72,18 @@ module.exports = (session) => { }); }) .then((result) => { - expect(result.model2Prop1).to.equal('test 3'); + expect(result.model2Prop1).toBe('test 3'); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(2); - expect(rows.map((it) => it.model1Prop1).sort()).to.eql(['test 1', 'test 2']); + expect(rows).toHaveLength(2); + expect(rows.map((it) => it.model1Prop1).sort()).toEqual(['test 1', 'test 2']); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(1); - expect(rows[0].model2_prop1).to.equal('test 3'); - done(); - }) - .catch(done); + expect(rows).toHaveLength(1); + expect(rows[0].model2_prop1).toBe('test 3'); + }); }); it('should commit transaction if no errors occur (Model.transaction)', async () => { @@ -108,15 +93,15 @@ module.exports = (session) => { return Model2.query(trx).insert({ model2Prop1: 'test 3' }); }); - expect(result.model2Prop1).to.equal('test 3'); + expect(result.model2Prop1).toBe('test 3'); let rows = await session.knex('Model1'); - expect(rows).to.have.length(2); - expect(rows.map((it) => it.model1Prop1).sort()).to.eql(['test 1', 'test 2']); + expect(rows).toHaveLength(2); + expect(rows.map((it) => it.model1Prop1).sort()).toEqual(['test 1', 'test 2']); rows = await session.knex('model2'); - expect(rows).to.have.length(1); - expect(rows[0].model2_prop1).to.equal('test 3'); + expect(rows).toHaveLength(1); + expect(rows[0].model2_prop1).toBe('test 3'); }); it('should commit transaction if no errors occur (Model.transaction with two args)', async () => { @@ -126,19 +111,19 @@ module.exports = (session) => { return Model2.query(trx).insert({ model2Prop1: 'test 3' }); }); - expect(result.model2Prop1).to.equal('test 3'); + expect(result.model2Prop1).toBe('test 3'); let rows = await session.knex('Model1'); - expect(rows).to.have.length(2); - expect(rows.map((it) => it.model1Prop1).sort()).to.eql(['test 1', 'test 2']); + expect(rows).toHaveLength(2); + expect(rows.map((it) => it.model1Prop1).sort()).toEqual(['test 1', 'test 2']); rows = await session.knex('model2'); - expect(rows).to.have.length(1); - expect(rows[0].model2_prop1).to.equal('test 3'); + expect(rows).toHaveLength(1); + expect(rows[0].model2_prop1).toBe('test 3'); }); - it('should commit transaction if no errors occur (2)', (done) => { - transaction(Model1, (Model1) => { + it('should commit transaction if no errors occur (2)', () => { + return transaction(Model1, (Model1) => { return Model1.query().insertGraph([ { model1Prop1: 'a', @@ -166,18 +151,16 @@ module.exports = (session) => { ]); }) .then(([rows1, rows2, rows3]) => { - expect(rows1).to.have.length(3); - expect(rows1.map((it) => it.model1Prop1)).to.eql(['a', 'b', 'd']); - expect(rows2).to.have.length(1); - expect(rows2[0].model2_prop1).to.equal('c'); - expect(rows3).to.have.length(1); - done(); - }) - .catch(done); + expect(rows1).toHaveLength(3); + expect(rows1.map((it) => it.model1Prop1)).toEqual(['a', 'b', 'd']); + expect(rows2).toHaveLength(1); + expect(rows2[0].model2_prop1).toBe('c'); + expect(rows3).toHaveLength(1); + }); }); - it('should commit transaction if no errors occur (3)', (done) => { - Model1.knex() + it('should commit transaction if no errors occur (3)', () => { + return Model1.knex() .transaction((trx) => { return Model1.query(trx).insertGraph([ { @@ -206,18 +189,16 @@ module.exports = (session) => { ]); }) .then(([rows1, rows2, rows3]) => { - expect(rows1).to.have.length(3); - expect(rows1.map((it) => it.model1Prop1)).to.eql(['a', 'b', 'd']); - expect(rows2).to.have.length(1); - expect(rows2[0].model2_prop1).to.equal('c'); - expect(rows3).to.have.length(1); - done(); - }) - .catch(done); + expect(rows1).toHaveLength(3); + expect(rows1.map((it) => it.model1Prop1)).toEqual(['a', 'b', 'd']); + expect(rows2).toHaveLength(1); + expect(rows2[0].model2_prop1).toBe('c'); + expect(rows3).toHaveLength(1); + }); }); - it('should rollback if an error occurs (1)', (done) => { - transaction(Model1, Model2, (Model1, Model2) => { + it('should rollback if an error occurs (1)', () => { + return transaction(Model1, Model2, (Model1, Model2) => { return Model1.query() .insert({ model1Prop1: 'test 1' }) .then(() => { @@ -231,18 +212,16 @@ module.exports = (session) => { }); }) .catch((err) => { - expect(err.message).to.equal('whoops'); + expect(err.message).toBe('whoops'); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(0); + expect(rows).toHaveLength(0); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(0); - done(); - }) - .catch(done); + expect(rows).toHaveLength(0); + }); }); it('should rollback if an error occurs (Model.transaction)', async () => { @@ -257,18 +236,18 @@ module.exports = (session) => { throw new Error('should not get here'); } catch (err) { - expect(err.message).to.equal('whoops'); + expect(err.message).toBe('whoops'); let rows = await session.knex('Model1'); - expect(rows).to.have.length(0); + expect(rows).toHaveLength(0); rows = await session.knex('model2'); - expect(rows).to.have.length(0); + expect(rows).toHaveLength(0); } }); - it('should rollback if an error occurs (2)', (done) => { - transaction(Model1, (Model1) => { + it('should rollback if an error occurs (2)', () => { + return transaction(Model1, (Model1) => { return Model1.query() .insert({ model1Prop1: 'test 1' }) .then((model) => { @@ -279,22 +258,20 @@ module.exports = (session) => { }); }) .catch((err) => { - expect(err.message).to.equal('whoops'); + expect(err.message).toBe('whoops'); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(0); + expect(rows).toHaveLength(0); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(0); - done(); - }) - .catch(done); + expect(rows).toHaveLength(0); + }); }); - it('should rollback if an error occurs (3)', (done) => { - transaction(Model1, (Model1) => { + it('should rollback if an error occurs (3)', () => { + return transaction(Model1, (Model1) => { return Model1.query() .insertGraph([ { @@ -319,7 +296,7 @@ module.exports = (session) => { }); }) .catch((err) => { - expect(err.message).to.equal('whoops'); + expect(err.message).toBe('whoops'); return Promise.all([ session.knex('Model1'), @@ -328,16 +305,14 @@ module.exports = (session) => { ]); }) .then(([rows1, rows2, rows3]) => { - expect(rows1).to.have.length(0); - expect(rows2).to.have.length(0); - expect(rows3).to.have.length(0); - done(); - }) - .catch(done); + expect(rows1).toHaveLength(0); + expect(rows2).toHaveLength(0); + expect(rows3).toHaveLength(0); + }); }); - it('should rollback if an error occurs (4)', (done) => { - Model1.knex() + it('should rollback if an error occurs (4)', () => { + return Model1.knex() .transaction((trx) => { return Model1.query(trx) .insertGraph([ @@ -375,11 +350,11 @@ module.exports = (session) => { return Model1.query(trx) .findById(models[0].id) .then((it) => it.$fetchGraph('model1Relation1', { transaction: trx })) - .then((it) => expect(it.model1Relation1.model1Prop1).to.equal('b')) + .then((it) => expect(it.model1Relation1.model1Prop1).toBe('b')) .then(() => models); }) .then((models) => { - expect(models[0].$query(trx).knex()).to.be(trx); + expect(models[0].$query(trx).knex()).toBe(trx); }) .then(() => { throw new Error('whoops'); @@ -387,7 +362,7 @@ module.exports = (session) => { }) .catch((err) => { console.log(err); - expect(err.message).to.equal('whoops'); + expect(err.message).toBe('whoops'); return Promise.all([ session.knex('Model1'), @@ -396,16 +371,14 @@ module.exports = (session) => { ]); }) .then(([rows1, rows2, rows3]) => { - expect(rows1).to.have.length(0); - expect(rows2).to.have.length(0); - expect(rows3).to.have.length(0); - done(); - }) - .catch(done); + expect(rows1).toHaveLength(0); + expect(rows2).toHaveLength(0); + expect(rows3).toHaveLength(0); + }); }); - it('should rollback if an error occurs (5)', (done) => { - transaction(Model1.knex(), (trx) => { + it('should rollback if an error occurs (5)', () => { + return transaction(Model1.knex(), (trx) => { return Model1.query(trx) .insertGraph([ { @@ -442,18 +415,18 @@ module.exports = (session) => { return Model1.query(trx) .findById(models[0].id) .then((it) => it.$fetchGraph('model1Relation1', { transaction: trx })) - .then((it) => expect(it.model1Relation1.model1Prop1).to.equal('b')) + .then((it) => expect(it.model1Relation1.model1Prop1).toBe('b')) .then(() => models); }) .then((models) => { - expect(models[0].$query(trx).knex()).to.be(trx); + expect(models[0].$query(trx).knex()).toBe(trx); }) .then(() => { throw new Error('whoops'); }); }) .catch((err) => { - expect(err.message).to.equal('whoops'); + expect(err.message).toBe('whoops'); return Promise.all([ session.knex('Model1'), @@ -462,16 +435,14 @@ module.exports = (session) => { ]); }) .then(([rows1, rows2, rows3]) => { - expect(rows1).to.have.length(0); - expect(rows2).to.have.length(0); - expect(rows3).to.have.length(0); - done(); - }) - .catch(done); + expect(rows1).toHaveLength(0); + expect(rows2).toHaveLength(0); + expect(rows3).toHaveLength(0); + }); }); - it('should rollback if the rollback method is called (no return)', (done) => { - transaction(Model1.knex(), (trx) => { + it('should rollback if the rollback method is called (no return)', () => { + return transaction(Model1.knex(), (trx) => { Model1.query(trx) .insertGraph([ { @@ -505,14 +476,14 @@ module.exports = (session) => { .then(() => models); }) .then((models) => { - expect(models[0].$query(trx).knex()).to.be(trx); + expect(models[0].$query(trx).knex()).toBe(trx); }) .then(() => { trx.rollback(new Error('whoops')); }); }) .catch((err) => { - expect(err.message).to.equal('whoops'); + expect(err.message).toBe('whoops'); return Promise.all([ session.knex('Model1'), @@ -521,17 +492,14 @@ module.exports = (session) => { ]); }) .then(([rows1, rows2, rows3]) => { - expect(rows1).to.have.length(0); - expect(rows2).to.have.length(0); - expect(rows3).to.have.length(0); - - done(); - }) - .catch(done); + expect(rows1).toHaveLength(0); + expect(rows2).toHaveLength(0); + expect(rows3).toHaveLength(0); + }); }); - it('should rollback if the rollback method is called (with return)', (done) => { - transaction(Model1.knex(), (trx) => { + it('should rollback if the rollback method is called (with return)', () => { + return transaction(Model1.knex(), (trx) => { return Model1.query(trx) .insertGraph([ { @@ -556,7 +524,7 @@ module.exports = (session) => { }); }) .catch((err) => { - expect(err.message).to.equal('whoops'); + expect(err.message).toBe('whoops'); return Promise.all([ session.knex('Model1'), @@ -565,17 +533,14 @@ module.exports = (session) => { ]); }) .then(([rows1, rows2, rows3]) => { - expect(rows1).to.have.length(0); - expect(rows2).to.have.length(0); - expect(rows3).to.have.length(0); - - done(); - }) - .catch(done); + expect(rows1).toHaveLength(0); + expect(rows2).toHaveLength(0); + expect(rows3).toHaveLength(0); + }); }); - it('should skip queries after rollback', (done) => { - transaction(Model1, (Model1) => { + it('should skip queries after rollback', () => { + return transaction(Model1, (Model1) => { return Model1.query() .insert({ model1Prop1: '123' }) .then(() => { @@ -595,64 +560,54 @@ module.exports = (session) => { }); }) .then((rows) => { - expect(rows).to.have.length(0); + expect(rows).toHaveLength(0); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(0); - done(); - }) - .catch(done); + expect(rows).toHaveLength(0); + }); }); - it('bound model class should accept unbound model instances', (done) => { + it('bound model class should accept unbound model instances', () => { let unboundModel = Model1.fromJson({ model1Prop1: '123' }); - transaction(Model1, (Model1) => { + return transaction(Model1, (Model1) => { return Model1.query().insert(unboundModel); }) .then((inserted) => { - expect(inserted.model1Prop1).to.equal('123'); + expect(inserted.model1Prop1).toBe('123'); return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(1); - expect(rows[0].model1Prop1).to.equal('123'); - done(); - }) - .catch(done); + expect(rows).toHaveLength(1); + expect(rows[0].model1Prop1).toBe('123'); + }); }); - it('last argument should be the knex transaction object', (done) => { - transaction(Model1, Model2, (Model1, Model2, trx) => { - expect(trx).to.equal(Model1.knex()); - }) - .then(() => { - done(); - }) - .catch(done); + it('last argument should be the knex transaction object', () => { + return transaction(Model1, Model2, (Model1, Model2, trx) => { + expect(trx).toBe(Model1.knex()); + }); }); - it('if knex instance is passed, should be equivalent to knex.transaction()', (done) => { - transaction(Model1.knex(), (trx) => { + it('if knex instance is passed, should be equivalent to knex.transaction()', () => { + return transaction(Model1.knex(), (trx) => { return trx('Model1').insert({ model1Prop1: '1' }); }) .then(() => { return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(1); - expect(rows[0].model1Prop1).to.equal('1'); - done(); - }) - .catch(done); + expect(rows).toHaveLength(1); + expect(rows[0].model1Prop1).toBe('1'); + }); }); describe('transaction.start() / Model.startTransaction()', () => { - it('should commit transaction when the commit method is called', (done) => { + it('should commit transaction when the commit method is called', () => { let trx; - transaction + return transaction .start(Model1) .then((trans) => { trx = trans; @@ -671,22 +626,20 @@ module.exports = (session) => { return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(2); - expect(rows.map((it) => it.model1Prop1).sort()).to.eql(['test 1', 'test 2']); + expect(rows).toHaveLength(2); + expect(rows.map((it) => it.model1Prop1).sort()).toEqual(['test 1', 'test 2']); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(1); - expect(rows[0].model2_prop1).to.equal('test 3'); - done(); - }) - .catch(done); + expect(rows).toHaveLength(1); + expect(rows[0].model2_prop1).toBe('test 3'); + }); }); - it('should commit transaction when the commit method is called (Model.startTransaction())', (done) => { + it('should commit transaction when the commit method is called (Model.startTransaction())', () => { let trx; - Model1.startTransaction() + return Model1.startTransaction() .then((trans) => { trx = trans; return Model1.bindKnex(trx).query().insert({ model1Prop1: 'test 1' }); @@ -704,19 +657,17 @@ module.exports = (session) => { return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(2); - expect(rows.map((it) => it.model1Prop1).sort()).to.eql(['test 1', 'test 2']); + expect(rows).toHaveLength(2); + expect(rows.map((it) => it.model1Prop1).sort()).toEqual(['test 1', 'test 2']); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(1); - expect(rows[0].model2_prop1).to.equal('test 3'); - done(); - }) - .catch(done); + expect(rows).toHaveLength(1); + expect(rows[0].model2_prop1).toBe('test 3'); + }); }); - it('commit should work with yield (and thus async/await)', async function () { + it('commit should work with yield (and thus async/await)', async () => { const trx = await transaction.start(Model1.knex()); await Model1.query(trx).insert({ model1Prop1: 'test 1' }); @@ -727,14 +678,14 @@ module.exports = (session) => { const model1Rows = await session.knex('Model1'); const model2Rows = await session.knex('model2'); - expect(model1Rows).to.have.length(2); - expect(model1Rows.map((it) => it.model1Prop1).sort()).to.eql(['test 1', 'test 2']); + expect(model1Rows).toHaveLength(2); + expect(model1Rows.map((it) => it.model1Prop1).sort()).toEqual(['test 1', 'test 2']); - expect(model2Rows).to.have.length(1); - expect(model2Rows[0].model2_prop1).to.equal('test 3'); + expect(model2Rows).toHaveLength(1); + expect(model2Rows[0].model2_prop1).toBe('test 3'); }); - it('commit should work with yield (and thus async/await) (Model.startTransaction())', async function () { + it('commit should work with yield (and thus async/await) (Model.startTransaction())', async () => { const trx = await Model1.startTransaction(); await Model1.query(trx).insert({ model1Prop1: 'test 1' }); @@ -745,14 +696,14 @@ module.exports = (session) => { const model1Rows = await session.knex('Model1'); const model2Rows = await session.knex('model2'); - expect(model1Rows).to.have.length(2); - expect(model1Rows.map((it) => it.model1Prop1).sort()).to.eql(['test 1', 'test 2']); + expect(model1Rows).toHaveLength(2); + expect(model1Rows.map((it) => it.model1Prop1).sort()).toEqual(['test 1', 'test 2']); - expect(model2Rows).to.have.length(1); - expect(model2Rows[0].model2_prop1).to.equal('test 3'); + expect(model2Rows).toHaveLength(1); + expect(model2Rows[0].model2_prop1).toBe('test 3'); }); - it('rollback should work with yield (and thus async/await)', async function () { + it('rollback should work with yield (and thus async/await)', async () => { const trx = await transaction.start(Model1.knex()); await Model1.query(trx).insert({ model1Prop1: 'test 1' }); @@ -763,13 +714,13 @@ module.exports = (session) => { const model1Rows = await session.knex('Model1'); const model2Rows = await session.knex('model2'); - expect(model1Rows).to.have.length(0); - expect(model2Rows).to.have.length(0); + expect(model1Rows).toHaveLength(0); + expect(model2Rows).toHaveLength(0); }); - it('should work when a knex connection is passed instead of a model', (done) => { + it('should work when a knex connection is passed instead of a model', () => { let trx; - transaction + return transaction .start(Model1.knex()) .then((trans) => { trx = trans; @@ -788,21 +739,19 @@ module.exports = (session) => { return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(2); - expect(rows.map((it) => it.model1Prop1).sort()).to.eql(['test 1', 'test 2']); + expect(rows).toHaveLength(2); + expect(rows.map((it) => it.model1Prop1).sort()).toEqual(['test 1', 'test 2']); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(1); - expect(rows[0].model2_prop1).to.equal('test 3'); - done(); - }) - .catch(done); + expect(rows).toHaveLength(1); + expect(rows[0].model2_prop1).toBe('test 3'); + }); }); - it('should rollback transaction when the rollback method is called', (done) => { + it('should rollback transaction when the rollback method is called', () => { let trx; - transaction + return transaction .start(Model1) .then((trans) => { trx = trans; @@ -821,25 +770,16 @@ module.exports = (session) => { return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(0); + expect(rows).toHaveLength(0); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(0); - done(); - }) - .catch(done); + expect(rows).toHaveLength(0); + }); }); - it('should fail if neither a model or a knex connection is passed', (done) => { - transaction - .start({}) - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - done(); - }); + it('should fail if neither a model or a knex connection is passed', () => { + return expect(transaction.start({})).rejects.toThrow(); }); }); @@ -858,8 +798,8 @@ module.exports = (session) => { await Model1.query(trx).insert({ model1Prop1: 'test 1' }); }); - expect(await committed).to.equal(true); - expect(await session.knex('Model1')).to.have.length(1); + expect(await committed).toBe(true); + expect(await session.knex('Model1')).toHaveLength(1); }); it('should reject on rollback (Model.transaction)', async () => { @@ -870,8 +810,8 @@ module.exports = (session) => { throw new Error('rollback'); }).catch(noop); - expect(await committed).to.equal(false); - expect(await session.knex('Model1')).to.have.length(0); + expect(await committed).toBe(false); + expect(await session.knex('Model1')).toHaveLength(0); }); it('should resolve after commit (Model.startTransaction)', async () => { @@ -880,8 +820,8 @@ module.exports = (session) => { await Model1.query(trx).insert({ model1Prop1: 'test 1' }); await trx.commit(); - expect(await committed).to.equal(true); - expect(await session.knex('Model1')).to.have.length(1); + expect(await committed).toBe(true); + expect(await session.knex('Model1')).toHaveLength(1); }); it('should reject on rollback with an error (Model.startTransaction)', async () => { @@ -890,14 +830,14 @@ module.exports = (session) => { await Model1.query(trx).insert({ model1Prop1: 'test 1' }); await trx.rollback(new Error('rollback')); - expect(await committed).to.equal(false); - expect(await session.knex('Model1')).to.have.length(0); + expect(await committed).toBe(false); + expect(await session.knex('Model1')).toHaveLength(0); }); }); describe('model.$knex()', () => { - it("model.$knex() methods should return the model's transaction", (done) => { - transaction + it("model.$knex() methods should return the model's transaction", () => { + return transaction .start(Model1) .then((trx) => { return Model1.bindTransaction(trx).query().insert({ model1Prop1: 'test 1' }); @@ -915,14 +855,12 @@ module.exports = (session) => { return session.knex('Model1'); }) .then((rows) => { - expect(rows).to.have.length(0); + expect(rows).toHaveLength(0); return session.knex('model2'); }) .then((rows) => { - expect(rows).to.have.length(0); - done(); - }) - .catch(done); + expect(rows).toHaveLength(0); + }); }); }); }); diff --git a/tests/integration/unrelate.js b/tests/integration/unrelate.js index de9105842..05cad151a 100644 --- a/tests/integration/unrelate.js +++ b/tests/integration/unrelate.js @@ -1,22 +1,13 @@ -const expect = require('expect.js'); -const chai = require('chai'); +import { describe, it, expect, beforeEach } from 'vitest'; -module.exports = (session) => { +export default (session) => { let Model1 = session.models.Model1; let Model2 = session.models.Model2; describe('Model unrelate queries', () => { describe('.$query()', () => { - it('should reject the query', (done) => { - Model1.fromJson({ id: 1 }) - .$query() - .unrelate() - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - done(); - }); + it('should reject the query', () => { + return expect(Model1.fromJson({ id: 1 }).$query().unrelate()).rejects.toThrow(); }); }); @@ -59,34 +50,32 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation1').unrelate(); }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex(Model1.getTableName()).orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(4); - expect(rows[0].model1Id).to.equal(null); - expect(rows[1].model1Id).to.equal(null); - expect(rows[2].model1Id).to.equal(4); - expect(rows[3].model1Id).to.equal(null); + expect(rows).toHaveLength(4); + expect(rows[0].model1Id).toBeNull(); + expect(rows[1].model1Id).toBeNull(); + expect(rows[2].model1Id).toBe(4); + expect(rows[3].model1Id).toBeNull(); }); }); - it('should fail if arguments are given', (done) => { - Model1.query() + it('should fail if arguments are given', () => { + return Model1.query() .findById(1) .then((model) => { return model.$relatedQuery('model1Relation1').unrelate(1); }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( `Don't pass arguments to unrelate(). You should use it like this: unrelate().where('foo', 'bar').andWhere(...)`, ); - done(); - }) - .catch(done); + }); }); }); @@ -136,15 +125,15 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation2').unrelate().where('id_col', 2); }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex(Model2.getTableName()).orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(4); - expect(rows[0].model1_id).to.equal(1); - expect(rows[1].model1_id).to.equal(null); - expect(rows[2].model1_id).to.equal(1); - expect(rows[3].model1_id).to.equal(2); + expect(rows).toHaveLength(4); + expect(rows[0].model1_id).toBe(1); + expect(rows[1].model1_id).toBeNull(); + expect(rows[2].model1_id).toBe(1); + expect(rows[3].model1_id).toBe(2); }); }); @@ -156,34 +145,32 @@ module.exports = (session) => { return model.$relatedQuery('model1Relation2').unrelate().where('id_col', '>', 1); }) .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex(Model2.getTableName()).orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(4); - expect(rows[0].model1_id).to.equal(1); - expect(rows[1].model1_id).to.equal(null); - expect(rows[2].model1_id).to.equal(null); - expect(rows[3].model1_id).to.equal(2); + expect(rows).toHaveLength(4); + expect(rows[0].model1_id).toBe(1); + expect(rows[1].model1_id).toBeNull(); + expect(rows[2].model1_id).toBeNull(); + expect(rows[3].model1_id).toBe(2); }); }); - it('should fail if arguments are given', (done) => { - Model1.query() + it('should fail if arguments are given', () => { + return Model1.query() .findById(1) .then((model) => { return model.$relatedQuery('model1Relation2').unrelate([1, 2]); }) .then((numUpdated) => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( `Don't pass arguments to unrelate(). You should use it like this: unrelate().where('foo', 'bar').andWhere(...)`, ); - done(); - }) - .catch(done); + }); }); }); @@ -245,15 +232,15 @@ module.exports = (session) => { return model.$relatedQuery('model2Relation1').unrelate().where('Model1.id', 4); }) .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 4)).to.have.length(0); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).to.have.length(1); + expect(rows).toHaveLength(3); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 4)).toHaveLength(0); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).toHaveLength(1); }); }); @@ -267,34 +254,32 @@ module.exports = (session) => { .where('model1Prop1', '>', 'blaa 1'); }) .then((numDeleted) => { - expect(numDeleted).to.equal(2); + expect(numDeleted).toBe(2); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 4)).to.have.length(0); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).to.have.length(0); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).to.have.length(1); + expect(rows).toHaveLength(2); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 4)).toHaveLength(0); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).toHaveLength(0); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).toHaveLength(1); }); }); - it('should fail if arguments are given', (done) => { - Model2.query() + it('should fail if arguments are given', () => { + return Model2.query() .findById(1) .then((model) => { return model.$relatedQuery('model2Relation1').unrelate([1, 2]); }) .then((numUpdated) => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( `Don't pass arguments to unrelate(). You should use it like this: unrelate().where('foo', 'bar').andWhere(...)`, ); - done(); - }) - .catch(done); + }); }); }); @@ -352,12 +337,12 @@ module.exports = (session) => { return model.$relatedQuery('model2Relation2').unrelate(); }) .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1Model2One'); }) .then((rows) => { - expect(rows).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).to.have.length(1); + expect(rows).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).toHaveLength(1); }); }); }); @@ -400,12 +385,12 @@ module.exports = (session) => { .for(1) .unrelate() .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex(Model1.getTableName()).orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(4); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(4); + expect(rows).toContainSubset([ { id: 1, model1Id: null }, { id: 2, model1Id: null }, { id: 3, model1Id: 4 }, @@ -419,12 +404,12 @@ module.exports = (session) => { .for([1, 3]) .unrelate() .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex(Model1.getTableName()).orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(4); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(4); + expect(rows).toContainSubset([ { id: 1, model1Id: null }, { id: 2, model1Id: null }, { id: 3, model1Id: null }, @@ -438,12 +423,12 @@ module.exports = (session) => { .for(Model1.query().findByIds([1, 3])) .unrelate() .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex(Model1.getTableName()).orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(4); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(4); + expect(rows).toContainSubset([ { id: 1, model1Id: null }, { id: 2, model1Id: null }, { id: 3, model1Id: null }, @@ -452,44 +437,38 @@ module.exports = (session) => { }); }); - // Filters don't work on mysql when the related table and the - // owner table are the same. - if (!session.isMySql()) { - it('should unrelate using multiple ids and a filter', () => { - return Model1.relatedQuery('model1Relation1') - .for([1, 3]) - .unrelate() - .where('model1Prop1', '!=', 'hello 2') - .then((numUpdated) => { - expect(numUpdated).to.equal(1); - return session.knex(Model1.getTableName()).orderBy('id'); - }) - .then((rows) => { - expect(rows).to.have.length(4); - chai.expect(rows).containSubset([ - { id: 1, model1Id: 2 }, - { id: 2, model1Id: null }, - { id: 3, model1Id: null }, - { id: 4, model1Id: null }, - ]); - }); - }); - } - - it('should fail if arguments are given', (done) => { - Model1.relatedQuery('model1Relation1') + it('should unrelate using multiple ids and a filter', () => { + return Model1.relatedQuery('model1Relation1') + .for([1, 3]) + .unrelate() + .where('model1Prop1', '!=', 'hello 2') + .then((numUpdated) => { + expect(numUpdated).toBe(1); + return session.knex(Model1.getTableName()).orderBy('id'); + }) + .then((rows) => { + expect(rows).toHaveLength(4); + expect(rows).toContainSubset([ + { id: 1, model1Id: 2 }, + { id: 2, model1Id: null }, + { id: 3, model1Id: null }, + { id: 4, model1Id: null }, + ]); + }); + }); + + it('should fail if arguments are given', () => { + return Model1.relatedQuery('model1Relation1') .for(1) .unrelate(1) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( `Don't pass arguments to unrelate(). You should use it like this: unrelate().where('foo', 'bar').andWhere(...)`, ); - done(); - }) - .catch(done); + }); }); }); @@ -537,12 +516,12 @@ module.exports = (session) => { .unrelate() .whereIn('id_col', [2, 4]) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex(Model2.getTableName()).orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(4); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(4); + expect(rows).toContainSubset([ { id_col: 1, model1_id: 1 }, { id_col: 2, model1_id: null }, { id_col: 3, model1_id: 1 }, @@ -557,12 +536,12 @@ module.exports = (session) => { .unrelate() .whereIn('id_col', [2, 4]) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex(Model2.getTableName()).orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(4); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(4); + expect(rows).toContainSubset([ { id_col: 1, model1_id: 1 }, { id_col: 2, model1_id: null }, { id_col: 3, model1_id: 1 }, @@ -577,12 +556,12 @@ module.exports = (session) => { .unrelate() .whereIn('id_col', [2, 4]) .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex(Model2.getTableName()).orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(4); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(4); + expect(rows).toContainSubset([ { id_col: 1, model1_id: 1 }, { id_col: 2, model1_id: null }, { id_col: 3, model1_id: 1 }, @@ -597,12 +576,12 @@ module.exports = (session) => { .unrelate() .where('id_col', '>', 1) .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex(Model2.getTableName()).orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(4); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(4); + expect(rows).toContainSubset([ { id_col: 1, model1_id: 1 }, { id_col: 2, model1_id: null }, { id_col: 3, model1_id: null }, @@ -611,20 +590,18 @@ module.exports = (session) => { }); }); - it('should fail if arguments are given', (done) => { - Model1.relatedQuery('model1Relation2') + it('should fail if arguments are given', () => { + return Model1.relatedQuery('model1Relation2') .for(1) .unrelate([1, 2]) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( `Don't pass arguments to unrelate(). You should use it like this: unrelate().where('foo', 'bar').andWhere(...)`, ); - done(); - }) - .catch(done); + }); }); }); @@ -684,12 +661,12 @@ module.exports = (session) => { .unrelate() .whereIn('Model1.id', [4, 6]) .then((numDeleted) => { - expect(numDeleted).to.equal(1); + expect(numDeleted).toBe(1); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(3); + expect(rows).toContainSubset([ { model2Id: 1, model1Id: 3 }, { model2Id: 1, model1Id: 5 }, { model2Id: 2, model1Id: 6 }, @@ -703,12 +680,12 @@ module.exports = (session) => { .unrelate() .whereIn('Model1.id', [4, 6]) .then((numDeleted) => { - expect(numDeleted).to.equal(2); + expect(numDeleted).toBe(2); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(2); + expect(rows).toContainSubset([ { model2Id: 1, model1Id: 3 }, { model2Id: 1, model1Id: 5 }, ]); @@ -721,12 +698,12 @@ module.exports = (session) => { .unrelate() .whereIn('Model1.id', [4, 6]) .then((numDeleted) => { - expect(numDeleted).to.equal(2); + expect(numDeleted).toBe(2); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); - chai.expect(rows).containSubset([ + expect(rows).toHaveLength(2); + expect(rows).toContainSubset([ { model2Id: 1, model1Id: 3 }, { model2Id: 1, model1Id: 5 }, ]); @@ -739,34 +716,32 @@ module.exports = (session) => { .unrelate() .where('model1Prop1', '>', 'blaa 1') .then((numDeleted) => { - expect(numDeleted).to.equal(2); + expect(numDeleted).toBe(2); return session.knex('Model1Model2').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).to.have.length(1); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 4)).to.have.length(0); - expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).to.have.length(0); - expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).to.have.length(1); + expect(rows).toHaveLength(2); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 3)).toHaveLength(1); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 4)).toHaveLength(0); + expect(rows.filter((it) => it.model2Id === 1 && it.model1Id === 5)).toHaveLength(0); + expect(rows.filter((it) => it.model2Id === 2 && it.model1Id === 6)).toHaveLength(1); }); }); - it('should fail if arguments are given', (done) => { - Model2.query() + it('should fail if arguments are given', () => { + return Model2.query() .findById(1) .then((model) => { return model.$relatedQuery('model2Relation1').unrelate([1, 2]); }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( `Don't pass arguments to unrelate(). You should use it like this: unrelate().where('foo', 'bar').andWhere(...)`, ); - done(); - }) - .catch(done); + }); }); }); }); diff --git a/tests/integration/update.js b/tests/integration/update.js index 0e6f0e3aa..23e464b6b 100644 --- a/tests/integration/update.js +++ b/tests/integration/update.js @@ -1,10 +1,9 @@ -const expect = require('expect.js'); -const { inheritModel } = require('../../lib/model/inheritModel'); -const { expectPartialEqual: expectPartEql, delay } = require('./../../testUtils/testUtils'); -const { ValidationError, raw } = require('../../'); -const { resetWarnings } = require('../../lib/utils/warnings'); +import { describe, it, expect, beforeEach } from 'vitest'; +import { inheritModel } from '../../lib/model/inheritModel.js'; +import { expectPartialEqual as expectPartEql, delay } from '../../testUtils/testUtils.js'; +import { ValidationError, raw } from 'objection'; -module.exports = (session) => { +export default (session) => { let Model1 = session.models.Model1; let Model2 = session.models.Model2; @@ -46,13 +45,13 @@ module.exports = (session) => { .update(model) .where('id', '=', 2) .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$afterUpdateCalled).to.equal(1); + expect(numUpdated).toBe(1); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$afterUpdateCalled).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -64,11 +63,11 @@ module.exports = (session) => { .update({ model1Prop1: null, model1Prop2: 100 }) .where('id', '=', 1) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: null }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -80,11 +79,11 @@ module.exports = (session) => { .update({ model1Prop1: '', model1Prop2: 100 }) .where('id', '=', 1) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: '' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -96,11 +95,11 @@ module.exports = (session) => { .update({ model1Prop1: 'updated text' }) .where('id', '=', 2) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -114,11 +113,11 @@ module.exports = (session) => { .update(model) .where('id_col', '=', 1) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'updated text', model2_prop2: 2 }); expectPartEql(rows[1], { id_col: 2, model2_prop1: 'text 2', model2_prop2: 1 }); }); @@ -129,52 +128,33 @@ module.exports = (session) => { .update({ model1Prop1: 'updated text' }) .where('model1Prop1', '<', 'hello 3') .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); }); }); - it('should warn about an offset, as knex leaves it out', async () => { - // The warning is only observable through `console.warn()`. - const warnings = []; - const originalWarn = console.warn; - console.warn = (message) => warnings.push(message); - resetWarnings(); + it('should throw for an offset, as knex leaves it out', async () => { + const message = 'offset() is not supported in update queries, as knex leaves it out.'; + const query = () => Model1.query().update({ model1Prop1: 'updated text' }).offset(1); - try { - const query = () => Model1.query().update({ model1Prop1: 'updated text' }).offset(1); - - // Building the query doesn't warn, only executing it. - query().toKnexQuery().toString(); - expect(warnings).to.have.length(0); - - await query(); - expect(warnings).to.eql([ - '[objection] offset() has no effect in update queries, so no rows are skipped, and on ' + - 'SQL Server limit() is ignored too. This will throw an error in objection 4.0.', - ]); + // Building the query doesn't throw, only executing it. + query().toKnexQuery().toString(); + await expect(query()).rejects.toThrow(message); + await expect( + Model1.query().patch({ model1Prop1: 'patched text' }).offset(1), + ).rejects.toThrow(message); - resetWarnings(); - await Model1.query().patch({ model1Prop1: 'patched text' }).offset(1); - expect(warnings).to.have.length(2); - - // An update without an offset doesn't warn. - resetWarnings(); - await Model1.query().patch({ model1Prop1: 'patched text' }); - expect(warnings).to.have.length(2); - } finally { - console.warn = originalWarn; - resetWarnings(); - } + const rows = await session.knex('Model1').orderBy('id'); + expect(rows.map((row) => row.model1Prop1)).toEqual(['hello 1', 'hello 2', 'hello 3']); }); - it('should validate', (done) => { + it('should validate', () => { let ModelWithSchema = subClassWithSchema(Model1, { type: 'object', properties: { @@ -184,15 +164,15 @@ module.exports = (session) => { }, }); - ModelWithSchema.query() + return ModelWithSchema.query() .update({ model1Prop1: 666 }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(ValidationError); - expect(err.type).to.equal('ModelValidation'); - expect(err.data).to.eql({ + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('ModelValidation'); + expect(err.data).toEqual({ model1Prop1: [ { message: 'must be string', @@ -206,17 +186,15 @@ module.exports = (session) => { return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', ]); - done(); - }) - .catch(done); + }); }); - it('should validate required properties', (done) => { + it('should validate required properties', () => { let ModelWithSchema = subClassWithSchema(Model1, { type: 'object', required: ['model1Prop2'], @@ -227,24 +205,22 @@ module.exports = (session) => { }, }); - ModelWithSchema.query() + return ModelWithSchema.query() .update({ model1Prop1: 'text' }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(ValidationError); + expect(err).toBeInstanceOf(ValidationError); return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', ]); - done(); - }) - .catch(done); + }); }); it('should pass validation if query properties are passed in for required', () => { @@ -265,11 +241,11 @@ module.exports = (session) => { return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql(['hello 1', 'hello 3', 'text']); + expect(rows.map((it) => it.model1Prop1).sort()).toEqual(['hello 1', 'hello 3', 'text']); }); }); - it('should use `Model.createValidationError` to create the error', (done) => { + it('should use `Model.createValidationError` to create the error', () => { class MyError extends Error { constructor({ data }) { super('MyError'); @@ -290,14 +266,14 @@ module.exports = (session) => { return new MyError(props); }; - ModelWithSchema.query() + return ModelWithSchema.query() .update({ model1Prop1: 666 }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(MyError); - expect(err.errors).to.eql({ + expect(err).toBeInstanceOf(MyError); + expect(err.errors).toEqual({ model1Prop1: [ { message: 'must be string', @@ -312,14 +288,12 @@ module.exports = (session) => { return session.knex(Model1.getTableName()); }) .then((rows) => { - expect(rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(rows.map((it) => it.model1Prop1).sort()).toEqual([ 'hello 1', 'hello 2', 'hello 3', ]); - done(); - }) - .catch(done); + }); }); }); @@ -346,14 +320,14 @@ module.exports = (session) => { const model = Model1.fromJson({ model1Prop1: 'updated text' }); const numUpdated = await Model1.query().updateById(2, model); - expect(numUpdated).to.equal(1); - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$beforeUpdateOptions).to.eql({}); - expect(model.$afterUpdateCalled).to.equal(1); - expect(model.$afterUpdateOptions).to.eql({}); + expect(numUpdated).toBe(1); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$beforeUpdateOptions).toEqual({}); + expect(model.$afterUpdateCalled).toBe(1); + expect(model.$afterUpdateOptions).toEqual({}); const rows = await session.knex('Model1').orderBy('id'); - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text', model1Prop2: 2 }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -361,7 +335,7 @@ module.exports = (session) => { it('should return 0 if the model does not exist', async () => { const numUpdated = await Model1.query().updateById(1000, { model1Prop1: 'updated text' }); - expect(numUpdated).to.equal(0); + expect(numUpdated).toBe(0); }); it('should throw a NotFoundError with throwIfNotFound() if the model does not exist', async () => { @@ -373,7 +347,7 @@ module.exports = (session) => { error = err; } - expect(error).to.be.a(Model1.NotFoundError); + expect(error).toBeInstanceOf(Model1.NotFoundError); }); it('should throw a clear error if undefined is passed as id', async () => { @@ -385,10 +359,10 @@ module.exports = (session) => { error = err; } - expect(error.message).to.equal('undefined was passed to updateById'); + expect(error.message).toBe('undefined was passed to updateById'); const rows = await session.knex('Model1').where('model1Prop1', 'updated text'); - expect(rows).to.have.length(0); + expect(rows).toHaveLength(0); }); }); @@ -428,8 +402,8 @@ module.exports = (session) => { return Model1.query() .updateAndFetchById(2, model) .then((fetchedModel) => { - expect(fetchedModel).to.equal(model); - expect(fetchedModel).eql({ + expect(fetchedModel).toBe(model); + expect(fetchedModel).toEqual({ id: 2, model1Prop1: 'updated text', model1Prop2: null, @@ -442,7 +416,7 @@ module.exports = (session) => { return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(3); + expect(rows).toHaveLength(3); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -455,11 +429,11 @@ module.exports = (session) => { .updateAndFetchById(2, model) .select('id', 'model1Prop1'); - expect(fetchedModel).to.equal(model); - expect(fetchedModel.id).to.equal(2); - expect(fetchedModel.model1Prop1).to.equal('updated text'); - expect(fetchedModel).to.not.have.property('model1Prop2'); - expect(fetchedModel).to.not.have.property('model1Id'); + expect(fetchedModel).toBe(model); + expect(fetchedModel.id).toBe(2); + expect(fetchedModel.model1Prop1).toBe('updated text'); + expect(fetchedModel).not.toHaveProperty('model1Prop2'); + expect(fetchedModel).not.toHaveProperty('model1Id'); }); }); @@ -484,12 +458,12 @@ module.exports = (session) => { .$query() .update({ model1Prop1: 'updated text' }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(model.model1Prop1).to.eql('updated text'); + expect(numUpdated).toBe(1); + expect(model.model1Prop1).toBe('updated text'); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); }); @@ -502,13 +476,13 @@ module.exports = (session) => { .$query() .update() .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$afterUpdateCalled).to.equal(1); + expect(numUpdated).toBe(1); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$afterUpdateCalled).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); }); @@ -521,14 +495,14 @@ module.exports = (session) => { .$query() .update(model) .then(() => { - expect(model.$beforeUpdateCalled).to.equal(1); - expect(model.$beforeUpdateOptions).to.eql({ old: { id: 1 } }); - expect(model.$afterUpdateCalled).to.equal(1); - expect(model.$afterUpdateOptions).to.eql({ old: { id: 1 } }); + expect(model.$beforeUpdateCalled).toBe(1); + expect(model.$beforeUpdateOptions).toEqual({ old: { id: 1 } }); + expect(model.$afterUpdateCalled).toBe(1); + expect(model.$afterUpdateOptions).toEqual({ old: { id: 1 } }); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); }); @@ -563,13 +537,13 @@ module.exports = (session) => { .$query() .update({ model1Prop1: 'updated text' }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); - expect(before.id).to.equal(1); - expect(after.id).to.equal(1); + expect(numUpdated).toBe(1); + expect(before.id).toBe(1); + expect(after.id).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); }); @@ -589,11 +563,11 @@ module.exports = (session) => { .$query() .update() .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'updated text' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); }); @@ -621,10 +595,10 @@ module.exports = (session) => { .$query() .updateAndFetch({ model1Prop2: 10, undefinedShouldBeIgnored: undefined }) .then((updated) => { - expect(updated.id).to.equal(1); - expect(updated.model1Id).to.equal(null); - expect(updated.model1Prop1).to.equal('hello 1'); - expect(updated.model1Prop2).to.equal(10); + expect(updated.id).toBe(1); + expect(updated.model1Id).toBeNull(); + expect(updated.model1Prop1).toBe('hello 1'); + expect(updated.model1Prop2).toBe(10); expectPartEql(model, { id: 1, model1Prop1: 'hello 1', @@ -634,7 +608,7 @@ module.exports = (session) => { return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(2); + expect(rows).toHaveLength(2); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1', model1Prop2: 10 }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2', model1Prop2: null }); }); @@ -679,11 +653,11 @@ module.exports = (session) => { .$relatedQuery('model1Relation1') .update({ model1Prop1: 'updated text' }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'updated text' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -696,11 +670,11 @@ module.exports = (session) => { .$relatedQuery('model1Relation1') .update({ model1Prop1: 'updated text', model1Prop2: 1000 }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('id'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'hello 3' }); @@ -773,11 +747,11 @@ module.exports = (session) => { .update({ model2Prop1: 'updated text' }) .where('id_col', 2) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(6); + expect(rows).toHaveLength(6); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(rows[1], { id_col: 2, @@ -798,11 +772,11 @@ module.exports = (session) => { .where('model2_prop2', '<', 6) .where('model2_prop1', 'like', 'text %') .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex('model2').orderBy('id_col'); }) .then((rows) => { - expect(rows).to.have.length(6); + expect(rows).toHaveLength(6); expectPartEql(rows[0], { id_col: 1, model2_prop1: 'text 1' }); expectPartEql(rows[1], { id_col: 2, @@ -897,11 +871,11 @@ module.exports = (session) => { .update({ model1Prop1: 'updated text' }) .where('Model1.id', 5) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(8); + expect(rows).toHaveLength(8); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -920,11 +894,11 @@ module.exports = (session) => { .where('model1Prop1', 'like', 'blaa 4') .orWhere('model1Prop1', 'like', 'blaa 6') .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(8); + expect(rows).toHaveLength(8); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -942,11 +916,11 @@ module.exports = (session) => { .update({ model1Prop1: 'updated text', model1Prop2: 123 }) .where('model1Prop2', '<', 6) .then((numUpdated) => { - expect(numUpdated).to.equal(2); + expect(numUpdated).toBe(2); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(8); + expect(rows).toHaveLength(8); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'blaa 1' }); @@ -1010,11 +984,11 @@ module.exports = (session) => { .$relatedQuery('model2Relation2') .update({ model1Prop1: 'updated text' }) .then((numUpdated) => { - expect(numUpdated).to.equal(1); + expect(numUpdated).toBe(1); return session.knex('Model1').orderBy('Model1.id'); }) .then((rows) => { - expect(rows).to.have.length(4); + expect(rows).toHaveLength(4); expectPartEql(rows[0], { id: 1, model1Prop1: 'hello 1' }); expectPartEql(rows[1], { id: 2, model1Prop1: 'hello 2' }); expectPartEql(rows[2], { id: 3, model1Prop1: 'updated text' }); diff --git a/tests/integration/upsertGraph.js b/tests/integration/upsertGraph.js index 9765020f5..fdae3fb53 100644 --- a/tests/integration/upsertGraph.js +++ b/tests/integration/upsertGraph.js @@ -1,12 +1,11 @@ -const expect = require('expect.js'); -const chai = require('chai'); -const { raw, transaction, ValidationError } = require('../../'); -const { createRejectionReflection } = require('../../testUtils/testUtils'); -const { FetchStrategy } = require('../../lib/queryBuilder/graph/GraphOptions'); -const mockKnexFactory = require('../../testUtils/mockKnex'); -const { map: promiseMap } = require('../../lib/utils/promiseUtils'); - -module.exports = (session) => { +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { raw, transaction, ValidationError } from 'objection'; +import { createRejectionReflection, delay } from '../../testUtils/testUtils.js'; +import { FetchStrategy } from '../../lib/queryBuilder/graph/GraphOptions.js'; +import mockKnexFactory from '../../testUtils/mockKnex.js'; +import { map as promiseMap } from '../../lib/utils/promiseUtils/index.js'; + +export default (session) => { const Model1 = session.unboundModels.Model1; const Model2 = session.unboundModels.Model2; const NONEXISTENT_ID = 1000; @@ -91,6 +90,447 @@ module.exports = (session) => { ]); }); + it('should call $afterFind only on the fetched roots', async () => { + const upserted = await Model1.query(session.knex).upsertGraph( + { id: 2, model1Prop1: 'x' }, + { fetchStrategy }, + ); + const fetched = await Model1.query(session.knex).upsertGraphAndFetch( + { id: 2, model1Prop1: 'y' }, + { fetchStrategy }, + ); + + expect(upserted.$afterFindCalled).toBeUndefined(); + expect(fetched.$afterFindCalled).toBe(1); + }); + + it('should return models that write their unchanged props in later patches (#371)', async () => { + const result = await Model1.query(session.knex).upsertGraph( + { id: 2, model1Relation2: [{ idCol: 1, model2Prop1: 'changed' }] }, + { fetchStrategy }, + ); + + // The upsert leaves out the unchanged foreign key: + const [related] = result.model1Relation2; + related.model1Id = null; + await related.$query(session.knex).patch(); + + const [row] = await session.knex('model2').where('id_col', 1); + expect(row).toMatchObject({ model1_id: null, model2_prop1: 'changed' }); + }); + + it('should give back the unchanged props when a patch fails (#371)', async () => { + const upsert = Model1.fromJson({ + id: 2, + model1Relation2: [ + { + idCol: 1, + model2Prop1: 'changed', + model2Relation1: [ + { id: 4, model1Prop1: 'changed' }, + { id: 5, model1Prop1: 'changed' }, + ], + }, + { + idCol: 2, + model2Prop1: 'changed', + model2Relation1: [ + { id: 6, model1Prop1: 'changed' }, + { id: 7, model1Prop1: 'changed' }, + ], + }, + ], + }); + let hasFailed = false; + + // Fail the first patch, before all others have started: + await Model1.query(session.knex) + .upsertGraph(upsert, { fetchStrategy }) + .context({ + runBefore(result, builder) { + if (builder.isUpdate() && !hasFailed) { + hasFailed = true; + throw new Error('failed patch'); + } + return result; + }, + }) + .catch(() => {}); + + const models = upsert.model1Relation2.flatMap((model) => [model, ...model.model2Relation1]); + expect(hasFailed).toBe(true); + // Only the join table extras stay left out, as they aren't columns: + const extras = ['aliasedExtra']; + expect(models.map((model) => model.$omitFromDatabaseJson())).toEqual([ + undefined, + extras, + extras, + undefined, + extras, + extras, + ]); + }); + + it('should throw for a #ref into a branch that the graph does not write', async () => { + const err = await Model1.query(session.knex) + .upsertGraph( + { + id: 1, + // Ignored with its branch because of `noRelate`: + model1Relation1: { id: 3, model1Relation3: [{ '#id': 'm', model2Prop1: 'm' }] }, + model1Relation3: [{ '#ref': 'm' }], + }, + { relate: true, noRelate: ['model1Relation1'], allowRefs: true, fetchStrategy }, + ) + .catch((err) => err); + + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toBe( + "the reference at 'model1Relation3[0]' points to a model that the graph doesn't write before it", + ); + expect(await session.knex('Model1Model2').where('model1Id', 1)).toEqual([]); + }); + + it('should throw for a #ref{} to the id of a new model in a branch that the graph does not write', async () => { + const err = await Model1.query(session.knex) + .upsertGraph( + { + id: 1, + // Ignored with its branch because of `noRelate`: + model1Relation1: { id: 3, model1Relation3: [{ '#id': 'm', model2Prop1: 'm' }] }, + model1Relation2: [{ model2Prop1: '#ref{m.idCol}' }], + }, + { relate: true, noRelate: ['model1Relation1'], allowRefs: true, fetchStrategy }, + ) + .catch((err) => err); + + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toBe( + "the reference at 'model1Relation2[0]' points to a model that the graph doesn't write before it", + ); + }); + + it('should name the root as the place of a reference', async () => { + const err = await Model1.query(session.knex) + .upsertGraph( + { + id: 1, + model1Prop1: '#ref{m.idCol}', + // Ignored with its branch because of `noRelate`: + model1Relation1: { id: 3, model1Relation3: [{ '#id': 'm', model2Prop1: 'm' }] }, + }, + { relate: true, noRelate: ['model1Relation1'], allowRefs: true, fetchStrategy }, + ) + .catch((err) => err); + + expect(err.message).toBe( + "the reference at the root points to a model that the graph doesn't write before it", + ); + }); + + it('should throw for references to the ids of new models that the graph skips', async () => { + for (const graph of [ + { + id: 1, + model1Relation2: [{ '#id': 'c', model2Prop1: 'skipped' }], + model1Relation1: { model1Prop1: 'of #ref{c.idCol}' }, + }, + { + id: 1, + model1Relation2: [{ '#id': 'c', model2Prop1: 'skipped' }], + model1Relation3: [{ '#ref': 'c' }], + }, + ]) { + const err = await Model1.query(session.knex) + .upsertGraph(graph, { allowRefs: true, noInsert: ['model1Relation2'], fetchStrategy }) + .catch((err) => err); + + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toMatch(/points to a model that the graph doesn't write before it/); + } + }); + + it('should read the values that the graph gives models that it ignores', async () => { + await Model1.query(session.knex).upsertGraph( + { + id: 1, + model1Relation1: { '#id': 'c', id: 3, model1Prop1: 'ignored' }, + model1Relation2: [{ model2Prop1: 'of #ref{c.id} #ref{c.model1Prop1}' }], + }, + { allowRefs: true, relate: true, noRelate: ['model1Relation1'], fetchStrategy }, + ); + + const model = await Model1.query(session.knex).upsertGraph( + { + id: 1, + model1Relation2: [{ '#id': 'd', model2Prop1: 'skipped' }], + model1Relation1: { model1Prop1: 'of #ref{d.model2Prop1}' }, + }, + { allowRefs: true, noInsert: ['model1Relation2'], noDelete: true, fetchStrategy }, + ); + + const rows = await session.knex('model2').where('model1_id', 1); + expect(rows.map((row) => row.model2_prop1)).toEqual(['of 3 ignored']); + expect(model.model1Relation1.model1Prop1).toBe('of skipped'); + }); + + it('should read the props that the graph leaves out like for written models', async () => { + for (const options of [{ noRelate: ['model1Relation1'] }, {}]) { + await Model1.query(session.knex).upsertGraph( + { + id: 1, + model1Prop1: '#ref{c.model1Prop1}', + model1Relation1: { '#id': 'c', id: 3 }, + }, + { allowRefs: true, relate: true, noDelete: true, fetchStrategy, ...options }, + ); + + expect(await session.knex('Model1').where('id', 1).first()).toMatchObject({ + model1Prop1: 'root 1', + }); + } + }); + + it('should accept references between models that the graph ignores', async () => { + await Model1.query(session.knex).upsertGraph( + { + id: 1, + model1Relation2: [ + { '#id': 'a', model2Prop1: 'x' }, + { model2Prop1: '#ref{a.model2Prop1} sibling' }, + ], + }, + { allowRefs: true, noInsert: ['model1Relation2'], fetchStrategy }, + ); + + expect(await session.knex('model2').where('model1_id', 1)).toEqual([]); + }); + + it('should read a #ref{} to a model marked with #unrelate', async () => { + await Model1.query(session.knex).upsertGraph( + { + id: 2, + model1Relation2: [ + { idCol: 1, '#id': 'u', '#unrelate': true, model2Prop1: 'unrelated' }, + { idCol: 2 }, + { model2Prop1: 'after #ref{u.model2Prop1}' }, + ], + }, + { allowRefs: true, allowMarkers: true, fetchStrategy }, + ); + + const rows = await session.knex('model2').where('model1_id', 2).orderBy('id_col'); + expect(rows.map((row) => row.model2_prop1)).toEqual(['hasMany 2', 'after unrelated']); + }); + + it('should write the props of a model marked with #unrelate through a #ref to it', async () => { + await Model1.query(session.knex).upsertGraph( + { + id: 2, + model1Relation1: { + model1Prop1: 'new', + model1Relation3: [ + { idCol: 1, '#id': 'm', '#unrelate': true, model2Prop1: 'written' }, + ], + }, + model1Relation3: [{ '#ref': 'm' }], + }, + { allowRefs: true, allowMarkers: true, relate: true, fetchStrategy }, + ); + + expect(await session.knex('model2').where('id_col', 1).first()).toMatchObject({ + model2_prop1: 'written', + }); + expect(await session.knex('Model1Model2').where({ model1Id: 2, model2Id: 1 })).toHaveLength( + 1, + ); + }); + + it('should read a #ref{} to a model marked with #delete', async () => { + await Model1.query(session.knex).upsertGraph( + { + id: 2, + model1Relation2: [ + { idCol: 1, '#id': 'd', '#delete': true, model2Prop1: 'gone' }, + { idCol: 2 }, + { model2Prop1: 'after #ref{d.model2Prop1}' }, + ], + }, + { allowRefs: true, allowMarkers: true, fetchStrategy }, + ); + + const rows = await session.knex('model2').where('model1_id', 2).orderBy('id_col'); + expect(rows.map((row) => row.model2_prop1)).toEqual(['hasMany 2', 'after gone']); + }); + + it('should read a #ref{} into a branch that the graph does not write', async () => { + await Model1.query(session.knex).upsertGraph( + { + id: 1, + // Ignored with its branch because of `noRelate`: + model1Relation1: { + id: 3, + model1Relation3: [{ '#id': 'm', idCol: 1, model2Prop1: 'ignored' }], + }, + model1Relation2: [{ model2Prop1: 'of #ref{m.model2Prop1}' }], + }, + { relate: true, noRelate: ['model1Relation1'], allowRefs: true, fetchStrategy }, + ); + + await Model1.query(session.knex).upsertGraph( + { + id: 2, + model1Prop1: 'of #ref{d.model1Prop1}', + model1Relation2: [ + { + idCol: 1, + // Deleted without its branch: + '#delete': true, + model2Relation1: [{ '#id': 'd', id: 4, model1Prop1: 'deleted' }], + }, + { idCol: 2 }, + ], + }, + { allowRefs: true, allowMarkers: true, fetchStrategy }, + ); + + const rows = await session.knex('model2').where('model1_id', 1); + expect(rows.map((row) => row.model2_prop1)).toEqual(['of ignored']); + expect(await session.knex('Model1').where('id', 2).first()).toMatchObject({ + model1Prop1: 'of deleted', + }); + }); + + for (const hasHook of [true, false]) { + for (const noUpdate of [false, true]) { + it(`should only patch join rows with the extras that change (${hasHook ? 'with' : 'without'} beforeUpdate hook${noUpdate ? ', noUpdate' : ''})`, async () => { + await session + .knex('Model1Model2') + .where({ model1Id: 4, model2Id: 1 }) + .update({ extra1: 'a', extra2: 'b' }); + + const queries = []; + const onQuery = ({ sql }) => queries.push(sql); + const { $beforeUpdate } = Model2.prototype; + session.knex.on('query', onQuery); + + // The models of all tests have update hooks, which only removing + // them leaves out: + if (!hasHook) { + delete Model2.prototype.$beforeUpdate; + } + + try { + await Model1.query(session.knex).upsertGraph( + { + id: 4, + model1Relation3: [{ idCol: 1, model2Prop1: 'changed', extra1: 'a', extra2: 'c' }], + }, + { noDelete: true, noUpdate, fetchStrategy }, + ); + } finally { + session.knex.removeListener('query', onQuery); + Model2.prototype.$beforeUpdate = $beforeUpdate; + } + + const joinRowUpdates = queries.filter((sql) => /update\W+Model1Model2/i.test(sql)); + const [joinRow] = await session + .knex('Model1Model2') + .where({ model1Id: 4, model2Id: 1 }); + const [row] = await session.knex('model2').where('id_col', 1); + + if (noUpdate) { + expect(joinRowUpdates).toEqual([]); + expect(joinRow).toMatchObject({ extra1: 'a', extra2: 'b' }); + expect(row.model2_prop1).not.toBe('changed'); + } else { + expect(joinRowUpdates).toHaveLength(1); + // Without the current extras, the graph can't know that they + // don't change: + expect(joinRowUpdates[0].includes('extra1')).toBe( + fetchStrategy === FetchStrategy.OnlyIdentifiers, + ); + expect(joinRow).toMatchObject({ extra1: 'a', extra2: 'c' }); + expect(row.model2_prop1).toBe('changed'); + } + }); + } + } + + it('should patch the keys of a many-to-many model without its extras under noUpdate', async () => { + await session + .knex('Model1Model2') + .where({ model1Id: 4, model2Id: 1 }) + .update({ extra3: 'a' }); + + await Model2.query(session.knex).upsertGraph( + { + idCol: 1, + // The new related model changes the key of model 4, which is written + // even with `noUpdate`, but its extra isn't: + model2Relation1: [ + { id: 4, aliasedExtra: 'b', model1Relation1: { model1Prop1: 'new' } }, + { id: 5 }, + ], + }, + { noUpdate: true, noDelete: true, fetchStrategy }, + ); + + const { id } = await session.knex('Model1').where('model1Prop1', 'new').first(); + expect(await session.knex('Model1').where('id', 4).first()).toMatchObject({ + model1Id: id, + }); + expect( + await session.knex('Model1Model2').where({ model1Id: 4, model2Id: 1 }).first(), + ).toMatchObject({ extra3: 'a' }); + }); + + it('should relate a #ref to an existing model in a branch that the graph does not write', async () => { + await Model1.query(session.knex).upsertGraph( + { + id: 1, + // Ignored with its branch because of `noRelate`: + model1Relation1: { id: 3, model1Relation3: [{ '#id': 'm', idCol: 1 }] }, + model1Relation3: [{ '#ref': 'm' }], + }, + { relate: true, noRelate: ['model1Relation1'], allowRefs: true, fetchStrategy }, + ); + + expect(await session.knex('Model1Model2').where('model1Id', 1).select('model2Id')).toEqual([ + { model2Id: 1 }, + ]); + }); + + it('should fetch with the graph and graph modifiers of upsertGraphAndFetch() (Vincit#839)', async () => { + const result = await Model1.query(session.knex) + .withGraphFetched('model1Relation2') + .modifyGraph('model1Relation2', (query) => + query.select('id_col', 'model1_id').orderBy('id_col'), + ) + .upsertGraphAndFetch( + { id: 2, model1Relation2: [{ idCol: 1 }, { idCol: 2 }] }, + { fetchStrategy }, + ); + + expect(result.model1Relation2.map((model) => model.$toJson())).toEqual([ + { idCol: 1, model1Id: 2 }, + { idCol: 2, model1Id: 2 }, + ]); + }); + + it('should fetch with the named modifiers and aliases of upsertGraphAndFetch() (Vincit#839)', async () => { + const result = await Model1.query(session.knex) + .withGraphFetched('model1Relation2(onlyFirst) as first') + .modifiers({ onlyFirst: (query) => query.where('id_col', 1) }) + .upsertGraphAndFetch( + { id: 2, model1Relation2: [{ idCol: 1 }, { idCol: 2 }] }, + { fetchStrategy }, + ); + + expect(result.first.map((model) => model.idCol)).toEqual([1]); + expect(result.model1Relation2.map((model) => model.idCol).sort()).toEqual([1, 2]); + }); + for (const passthroughMethodCall of [ null, 'forUpdate', @@ -192,103 +632,93 @@ module.exports = (session) => { }, }) .then((result) => { - expect(sql.length).to.equal(12); + expect(sql.length).toBe(12); if (session.isPostgres()) { if (fetchStrategy === FetchStrategy.OnlyIdentifiers) { - chai - .expect(sql) - .to.containSubset([ - 'select "Model1"."id", "Model1"."model1Id" from "Model1" where "Model1"."id" in (2) order by "Model1"."id" asc' + - passthroughMethodCallSql[passthroughMethodCall], - 'select "Model1"."id" from "Model1" where "Model1"."id" in (3) order by "Model1"."id" asc', - 'select "model2"."model1_id", "model2"."id_col" from "model2" where "model2"."model1_id" in (2) order by "model2"."id_col" asc', - 'select "Model1Model2"."model2Id" as "objectiontmpjoin0", "Model1"."id" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1, 2) order by "Model1"."id" asc', - - 'delete from "model2" where "model2"."id_col" in (2) and "model2"."model1_id" in (2)', - 'delete from "Model1" where "Model1"."id" in (select "Model1"."id" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1) and "Model1"."id" in (5) order by "Model1"."id" asc)', - - 'insert into "Model1" ("model1Prop1") values (\'inserted manyToMany\') returning "id"', - 'insert into "model2" ("model1_id", "model2_prop1") values (2, \'inserted hasMany\') returning "id_col"', - 'insert into "Model1Model2" ("model1Id", "model2Id") values (8, 1) returning "model1Id"', - - 'update "Model1" set "model1Prop1" = \'updated belongsToOne\' where "Model1"."id" = 3 and "Model1"."id" in (3)', - 'update "Model1" set "model1Prop1" = \'updated manyToMany 1\' where "Model1"."id" = \'4\'', - 'update "model2" set "model2_prop1" = \'updated hasMany 1\' where "model2"."id_col" = 1', - ]); + expect(sql).toContainSubset([ + 'select "Model1"."id", "Model1"."model1Id" from "Model1" where "Model1"."id" in (2) order by "Model1"."id" asc' + + passthroughMethodCallSql[passthroughMethodCall], + 'select "Model1"."id" from "Model1" where "Model1"."id" in (3) order by "Model1"."id" asc', + 'select "model2"."model1_id", "model2"."id_col" from "model2" where "model2"."model1_id" in (2) order by "model2"."id_col" asc', + 'select "Model1Model2"."model2Id" as "objectiontmpjoin0", "Model1"."id" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1, 2) order by "Model1"."id" asc', + + 'delete from "model2" where "model2"."id_col" in (2) and "model2"."model1_id" in (2)', + 'delete from "Model1" where "Model1"."id" in (select "Model1"."id" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1) and "Model1"."id" in (5) order by "Model1"."id" asc)', + + 'insert into "Model1" ("model1Prop1") values (\'inserted manyToMany\') returning "id"', + 'insert into "model2" ("model1_id", "model2_prop1") values (2, \'inserted hasMany\') returning "id_col"', + 'insert into "Model1Model2" ("model1Id", "model2Id") values (8, 1) returning "model1Id"', + + 'update "Model1" set "model1Prop1" = \'updated belongsToOne\' where "Model1"."id" = 3 and "Model1"."id" in (3)', + 'update "Model1" set "model1Prop1" = \'updated manyToMany 1\' where "Model1"."id" = \'4\'', + 'update "model2" set "model2_prop1" = \'updated hasMany 1\' where "model2"."id_col" = 1', + ]); } else if (fetchStrategy === FetchStrategy.Everything) { - chai - .expect(sql) - .to.containSubset([ - 'select "Model1".* from "Model1" where "Model1"."id" in (2) order by "Model1"."id" asc' + - passthroughMethodCallSql[passthroughMethodCall], - 'select "Model1".* from "Model1" where "Model1"."id" in (3) order by "Model1"."id" asc', - 'select "model2".* from "model2" where "model2"."model1_id" in (2) order by "model2"."id_col" asc', - 'select "Model1".*, "Model1Model2"."extra3" as "aliasedExtra", "Model1Model2"."model2Id" as "objectiontmpjoin0" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1, 2) order by "Model1"."id" asc', - - 'delete from "model2" where "model2"."id_col" in (2) and "model2"."model1_id" in (2)', - 'delete from "Model1" where "Model1"."id" in (select "Model1"."id" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1) and "Model1"."id" in (5) order by "Model1"."id" asc)', - - 'insert into "Model1" ("model1Prop1") values (\'inserted manyToMany\') returning "id"', - 'insert into "model2" ("model1_id", "model2_prop1") values (2, \'inserted hasMany\') returning "id_col"', - 'insert into "Model1Model2" ("model1Id", "model2Id") values (8, 1) returning "model1Id"', - - 'update "Model1" set "model1Prop1" = \'updated belongsToOne\' where "Model1"."id" = 3 and "Model1"."id" in (3)', - 'update "Model1" set "model1Prop1" = \'updated manyToMany 1\' where "Model1"."id" = \'4\'', - 'update "model2" set "model2_prop1" = \'updated hasMany 1\' where "model2"."id_col" = 1', - ]); + expect(sql).toContainSubset([ + 'select "Model1".* from "Model1" where "Model1"."id" in (2) order by "Model1"."id" asc' + + passthroughMethodCallSql[passthroughMethodCall], + 'select "Model1".* from "Model1" where "Model1"."id" in (3) order by "Model1"."id" asc', + 'select "model2".* from "model2" where "model2"."model1_id" in (2) order by "model2"."id_col" asc', + 'select "Model1".*, "Model1Model2"."extra3" as "aliasedExtra", "Model1Model2"."model2Id" as "objectiontmpjoin0" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1, 2) order by "Model1"."id" asc', + + 'delete from "model2" where "model2"."id_col" in (2) and "model2"."model1_id" in (2)', + 'delete from "Model1" where "Model1"."id" in (select "Model1"."id" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1) and "Model1"."id" in (5) order by "Model1"."id" asc)', + + 'insert into "Model1" ("model1Prop1") values (\'inserted manyToMany\') returning "id"', + 'insert into "model2" ("model1_id", "model2_prop1") values (2, \'inserted hasMany\') returning "id_col"', + 'insert into "Model1Model2" ("model1Id", "model2Id") values (8, 1) returning "model1Id"', + + 'update "Model1" set "model1Prop1" = \'updated belongsToOne\' where "Model1"."id" = 3 and "Model1"."id" in (3)', + 'update "Model1" set "model1Prop1" = \'updated manyToMany 1\' where "Model1"."id" = \'4\'', + 'update "model2" set "model2_prop1" = \'updated hasMany 1\' where "model2"."id_col" = 1', + ]); } else if (fetchStrategy === FetchStrategy.OnlyNeeded) { - chai - .expect(sql) - .to.containSubset([ - 'select "Model1"."id", "Model1"."model1Id" from "Model1" where "Model1"."id" in (2) order by "Model1"."id" asc' + - passthroughMethodCallSql[passthroughMethodCall], - 'select "Model1"."id", "Model1"."model1Prop1" from "Model1" where "Model1"."id" in (3) order by "Model1"."id" asc', - 'select "model2"."model1_id", "model2"."id_col", "model2"."model2_prop1" from "model2" where "model2"."model1_id" in (2) order by "model2"."id_col" asc', - 'select "Model1Model2"."model2Id" as "objectiontmpjoin0", "Model1"."id", "Model1"."model1Prop1" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1, 2) order by "Model1"."id" asc', - - 'delete from "model2" where "model2"."id_col" in (2) and "model2"."model1_id" in (2)', - 'delete from "Model1" where "Model1"."id" in (select "Model1"."id" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1) and "Model1"."id" in (5) order by "Model1"."id" asc)', - - 'insert into "Model1" ("model1Prop1") values (\'inserted manyToMany\') returning "id"', - 'insert into "model2" ("model1_id", "model2_prop1") values (2, \'inserted hasMany\') returning "id_col"', - 'insert into "Model1Model2" ("model1Id", "model2Id") values (8, 1) returning "model1Id"', - - 'update "Model1" set "model1Prop1" = \'updated belongsToOne\' where "Model1"."id" = 3 and "Model1"."id" in (3)', - 'update "Model1" set "model1Prop1" = \'updated manyToMany 1\' where "Model1"."id" = \'4\'', - 'update "model2" set "model2_prop1" = \'updated hasMany 1\' where "model2"."id_col" = 1', - ]); + expect(sql).toContainSubset([ + 'select "Model1"."id", "Model1"."model1Id" from "Model1" where "Model1"."id" in (2) order by "Model1"."id" asc' + + passthroughMethodCallSql[passthroughMethodCall], + 'select "Model1"."id", "Model1"."model1Prop1" from "Model1" where "Model1"."id" in (3) order by "Model1"."id" asc', + 'select "model2"."model1_id", "model2"."id_col", "model2"."model2_prop1" from "model2" where "model2"."model1_id" in (2) order by "model2"."id_col" asc', + 'select "Model1Model2"."model2Id" as "objectiontmpjoin0", "Model1"."id", "Model1"."model1Prop1" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1, 2) order by "Model1"."id" asc', + + 'delete from "model2" where "model2"."id_col" in (2) and "model2"."model1_id" in (2)', + 'delete from "Model1" where "Model1"."id" in (select "Model1"."id" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1) and "Model1"."id" in (5) order by "Model1"."id" asc)', + + 'insert into "Model1" ("model1Prop1") values (\'inserted manyToMany\') returning "id"', + 'insert into "model2" ("model1_id", "model2_prop1") values (2, \'inserted hasMany\') returning "id_col"', + 'insert into "Model1Model2" ("model1Id", "model2Id") values (8, 1) returning "model1Id"', + + 'update "Model1" set "model1Prop1" = \'updated belongsToOne\' where "Model1"."id" = 3 and "Model1"."id" in (3)', + 'update "Model1" set "model1Prop1" = \'updated manyToMany 1\' where "Model1"."id" = \'4\'', + 'update "model2" set "model2_prop1" = \'updated hasMany 1\' where "model2"."id_col" = 1', + ]); } } - expect(result.$beforeUpdateCalled).to.equal(undefined); - expect(result.$afterUpdateCalled).to.equal(undefined); + expect(result.$beforeUpdateCalled).toBeUndefined(); + expect(result.$afterUpdateCalled).toBeUndefined(); - expect(result.model1Relation1.$beforeUpdateCalled).to.equal(1); - expect(result.model1Relation1.$afterUpdateCalled).to.equal(1); + expect(result.model1Relation1.$beforeUpdateCalled).toBe(1); + expect(result.model1Relation1.$afterUpdateCalled).toBe(1); - expect(result.model1Relation2[0].$beforeUpdateCalled).to.equal(1); - expect(result.model1Relation2[0].$afterUpdateCalled).to.equal(1); + expect(result.model1Relation2[0].$beforeUpdateCalled).toBe(1); + expect(result.model1Relation2[0].$afterUpdateCalled).toBe(1); - expect(result.model1Relation2[1].$beforeUpdateCalled).to.equal(undefined); - expect(result.model1Relation2[1].$afterUpdateCalled).to.equal(undefined); + expect(result.model1Relation2[1].$beforeUpdateCalled).toBeUndefined(); + expect(result.model1Relation2[1].$afterUpdateCalled).toBeUndefined(); - expect(result.model1Relation2[1].$beforeInsertCalled).to.equal(1); - expect(result.model1Relation2[1].$afterInsertCalled).to.equal(1); + expect(result.model1Relation2[1].$beforeInsertCalled).toBe(1); + expect(result.model1Relation2[1].$afterInsertCalled).toBe(1); - expect( - result.model1Relation2[0].model2Relation1[0].$beforeUpdateCalled, - ).to.equal(1); - expect( - result.model1Relation2[0].model2Relation1[0].$afterUpdateCalled, - ).to.equal(1); + expect(result.model1Relation2[0].model2Relation1[0].$beforeUpdateCalled).toBe( + 1, + ); + expect(result.model1Relation2[0].model2Relation1[0].$afterUpdateCalled).toBe(1); - expect( - result.model1Relation2[0].model2Relation1[1].$beforeInsertCalled, - ).to.equal(1); - expect( - result.model1Relation2[0].model2Relation1[1].$afterInsertCalled, - ).to.equal(1); + expect(result.model1Relation2[0].model2Relation1[1].$beforeInsertCalled).toBe( + 1, + ); + expect(result.model1Relation2[0].model2Relation1[1].$afterInsertCalled).toBe(1); // Fetch the graph from the database. return Model1.query(trx) @@ -299,7 +729,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: 3, model1Prop1: 'root 2', @@ -341,13 +771,13 @@ module.exports = (session) => { return Promise.all([trx('Model1'), trx('model2')]).then( ([model1Rows, model2Rows]) => { // Row 5 should be deleted. - expect(model1Rows.find((it) => it.id == 5)).to.equal(undefined); + expect(model1Rows.find((it) => it.id == 5)).toBeUndefined(); // Row 6 should NOT be deleted even thought its parent is. - expect(model1Rows.find((it) => it.id == 6)).to.be.an(Object); + expect(model1Rows.find((it) => it.id == 6)).toBeInstanceOf(Object); // Row 7 should NOT be deleted even thought its parent is. - expect(model1Rows.find((it) => it.id == 7)).to.be.an(Object); + expect(model1Rows.find((it) => it.id == 7)).toBeInstanceOf(Object); // Row 2 should be deleted. - expect(model2Rows.find((it) => it.id_col == 2)).to.equal(undefined); + expect(model2Rows.find((it) => it.id_col == 2)).toBeUndefined(); }, ); }) @@ -416,7 +846,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: 3, model1Prop1: 'root 2', @@ -473,13 +903,13 @@ module.exports = (session) => { return Promise.all([trx('Model1'), trx('model2')]).then( ([model1Rows, model2Rows]) => { // Row 5 should be deleted. - expect(model1Rows.find((it) => it.id == 5)).to.equal(undefined); + expect(model1Rows.find((it) => it.id == 5)).toBeUndefined(); // Row 6 should NOT be deleted even thought its parent is. - expect(model1Rows.find((it) => it.id == 6)).to.be.an(Object); + expect(model1Rows.find((it) => it.id == 6)).toBeInstanceOf(Object); // Row 7 should NOT be deleted even thought its parent is. - expect(model1Rows.find((it) => it.id == 7)).to.be.an(Object); + expect(model1Rows.find((it) => it.id == 7)).toBeInstanceOf(Object); // Row 2 should NOT be deleted because of `noDelete`. - expect(model2Rows.find((it) => it.id_col == 2)).to.be.an(Object); + expect(model2Rows.find((it) => it.id_col == 2)).toBeInstanceOf(Object); }, ); }); @@ -532,7 +962,7 @@ module.exports = (session) => { .upsertGraph(upsert, { fetchStrategy, noInsert: '*' }) .then(() => Promise.all([trx('Model1'), trx('model2')])) .then(([model1Rows, model2Rows]) => { - expect(model1Rows.map((it) => it.model1Prop1).sort()).to.eql([ + expect(model1Rows.map((it) => it.model1Prop1).sort()).toEqual([ 'belongsToOne', 'inserted root', 'manyToMany 1', @@ -543,7 +973,7 @@ module.exports = (session) => { 'root 2', ]); - expect(model2Rows.map((it) => it.model2_prop1).sort()).to.eql([ + expect(model2Rows.map((it) => it.model2_prop1).sort()).toEqual([ 'hasMany 1', 'hasMany 2', ]); @@ -570,8 +1000,37 @@ module.exports = (session) => { session.knex('model2'), ]); - chai.expect(model1Rows).to.have.length(7); - chai.expect(model2Rows).to.have.length(2); + expect(model1Rows).toHaveLength(7); + expect(model2Rows).toHaveLength(2); + }); + + it('should insert a referenced model below a skipped model without its join row (#369)', async () => { + await Model1.query(session.knex).upsertGraph( + { + id: 1, + model1Relation1: { '#ref': 'a' }, + model1Relation2: [ + { + // Not inserted because of `noInsert`: + model2Prop1: 'skipped', + model2Relation1: [{ '#id': 'a', model1Prop1: 'a' }], + }, + ], + }, + { + fetchStrategy, + allowRefs: true, + relate: true, + noDelete: true, + noInsert: ['model1Relation2'], + }, + ); + + const row = await session.knex('Model1').where('model1Prop1', 'a').first(); + expect(await session.knex('Model1').where('id', 1).first()).toMatchObject({ + model1Id: row.id, + }); + expect(await session.knex('Model1Model2').where('model1Id', row.id)).toEqual([]); }); for (const [title, model1Relation1, options] of [ @@ -593,9 +1052,61 @@ module.exports = (session) => { .findById(2) .withGraphFetched('model1Relation1'); - chai.expect(root.model1Id).to.equal(3); - chai.expect(root.model1Relation1.id).to.equal(3); - chai.expect(await session.knex('Model1')).to.have.length(7); + expect(root.model1Id).toBe(3); + expect(root.model1Relation1.id).toBe(3); + expect(await session.knex('Model1')).toHaveLength(7); + }); + } + + for (const [title, model1Relation1Inverse, options] of [ + ['noInsert', { model1Prop1: 'skipped' }, { noInsert: ['model1Relation1Inverse'] }], + [ + 'noInsert and insertMissing', + { id: 100 }, + { insertMissing: true, noInsert: ['model1Relation1Inverse'] }, + ], + ['noRelate', { id: 1 }, { relate: true, noRelate: ['model1Relation1Inverse'] }], + ]) { + it(`should keep the current HasOneRelation model when ${title} skips its replacement`, async () => { + await Model1.query(session.knex).upsertGraph( + // Row 3 has one row 2 that references it. + { id: 3, model1Relation1Inverse }, + { fetchStrategy, ...options }, + ); + + const root = await Model1.query(session.knex) + .findById(3) + .withGraphFetched('model1Relation1Inverse'); + + expect(root.model1Relation1Inverse.id).toBe(2); + expect(await session.knex('Model1')).toHaveLength(7); + }); + } + + for (const [title, model2Relation2, options] of [ + ['noInsert', { model1Prop1: 'skipped' }, { noInsert: ['model2Relation2'] }], + [ + 'noInsert and insertMissing', + { id: 100 }, + { insertMissing: true, noInsert: ['model2Relation2'] }, + ], + ['noRelate', { id: 5 }, { relate: true, noRelate: ['model2Relation2'] }], + ]) { + it(`should keep the current HasOneThroughRelation model when ${title} skips its replacement`, async () => { + // Row 1 of model2 has one row 4 through the join table. + await session.knex('Model1Model2One').insert({ model2Id: 1, model1Id: 4 }); + + await Model2.query(session.knex).upsertGraph( + { idCol: 1, model2Relation2 }, + { fetchStrategy, ...options }, + ); + + const root = await Model2.query(session.knex) + .findById(1) + .withGraphFetched('model2Relation2'); + + expect(root.model2Relation2.id).toBe(4); + expect(await session.knex('Model1')).toHaveLength(7); }); } @@ -615,9 +1126,10 @@ module.exports = (session) => { .withGraphFetched('model1Relation1') .orderBy('id'); - chai - .expect(roots.map((root) => root.model1Relation1.model1Prop1)) - .to.eql(['patched', 'inserted']); + expect(roots.map((root) => root.model1Relation1.model1Prop1)).toEqual([ + 'patched', + 'inserted', + ]); }); for (const noInsert of [ @@ -679,13 +1191,13 @@ module.exports = (session) => { it.model2Prop1, it.model2Relation1.map((it) => it.model1Prop1), ]), - ).to.eql([ + ).toEqual([ ['hasMany 1', ['manyToMany 1', 'manyToMany 2']], ['hasMany 2', ['manyToMany 3', 'manyToMany 4']], ['inserted hasMany', []], ]); - expect(result.model1Relation3.map((it) => it.model2Prop1)).to.eql([ + expect(result.model1Relation3.map((it) => it.model2Prop1)).toEqual([ 'inserted manyToMany relation 3', ]); }); @@ -726,7 +1238,7 @@ module.exports = (session) => { it.model2Prop1, it.model2Relation1.map((it) => it.model1Prop1), ]), - ).to.eql([ + ).toEqual([ // Not deleted because of `noDelete`. ['hasMany 1', expectedRelated], ['hasMany 2', ['manyToMany 3', 'manyToMany 4']], @@ -771,15 +1283,15 @@ module.exports = (session) => { it.model2Prop1, it.model2Relation1.map((it) => it.model1Prop1), ]), - ).to.eql([['hasMany 1', ['root 1', 'manyToMany 1']]]); + ).toEqual([['hasMany 1', ['root 1', 'manyToMany 1']]]); - expect(unrelatedRows.map((it) => it.model1Prop1)).to.eql(['manyToMany 2']); - expect(model2Rows.map((it) => it.model2_prop1)).to.eql(['hasMany 1']); + expect(unrelatedRows.map((it) => it.model1Prop1)).toEqual(['manyToMany 2']); + expect(model2Rows.map((it) => it.model2_prop1)).toEqual(['hasMany 1']); }); }); }); - it(`should not relate the parent relation with ${title}`, (done) => { + it(`should not relate the parent relation with ${title}`, () => { const upsert = { id: 1, @@ -792,18 +1304,16 @@ module.exports = (session) => { ], }; - transaction(session.knex, (trx) => { + return transaction(session.knex, (trx) => { return Model1.query(trx).upsertGraph(upsert, { fetchStrategy, relate: path }); }) .then(() => { throw new Error('should not get here'); }) .catch((err) => { - expect(err instanceof Model1.NotFoundError).to.equal(true); - expect(err.message).to.match(/^model \(id=1\) is not a child of model \(id=1\)/); - done(); - }) - .catch(done); + expect(err instanceof Model1.NotFoundError).toBe(true); + expect(err.message).toMatch(/^model \(id=1\) is not a child of model \(id=1\)/); + }); }); } @@ -832,9 +1342,9 @@ module.exports = (session) => { .withGraphFetched('model1Relation1.model1Relation1.model1Relation1'); }) .then((result) => { - expect(result.model1Relation1.id).to.equal(2); - expect(result.model1Relation1.model1Relation1.id).to.equal(3); - expect(result.model1Relation1.model1Relation1.model1Relation1.id).to.equal(4); + expect(result.model1Relation1.id).toBe(2); + expect(result.model1Relation1.model1Relation1.id).toBe(3); + expect(result.model1Relation1.model1Relation1.model1Relation1.id).toBe(4); }); }); }); @@ -852,18 +1362,18 @@ module.exports = (session) => { return Model1.query(trx) .upsertGraph(upsert, { relate: true, fetchStrategy }) .then((result) => { - expect(result.$beforeUpdateCalled).to.equal(1); - expect(result.$afterUpdateCalled).to.equal(1); + expect(result.$beforeUpdateCalled).toBe(1); + expect(result.$afterUpdateCalled).toBe(1); - expect(result.model1Relation1.$beforeUpdateCalled).to.equal(undefined); - expect(result.model1Relation1.$afterUpdateCalled).to.equal(undefined); + expect(result.model1Relation1.$beforeUpdateCalled).toBeUndefined(); + expect(result.model1Relation1.$afterUpdateCalled).toBeUndefined(); }); }) .then(() => { return Model1.query(session.knex).findById(1); }) .then((model) => { - expect(model.model1Id).to.equal(3); + expect(model.model1Id).toBe(3); }); }); @@ -879,7 +1389,7 @@ module.exports = (session) => { Model1.query(session.knex).findById(inserted.id).withGraphFetched('model1Relation1'), ) .then((model) => { - chai.expect(model).to.containSubset({ + expect(model).toMatchObject({ model1Prop1: null, model1Prop2: null, model1Relation1: { @@ -904,7 +1414,7 @@ module.exports = (session) => { .withGraphFetched('model1Relation1Inverse'), ) .then((model) => { - chai.expect(model).to.containSubset({ + expect(model).toMatchObject({ model1Prop1: null, model1Prop2: null, model1Relation1Inverse: { @@ -928,19 +1438,19 @@ module.exports = (session) => { return Model1.query(trx) .upsertGraph(upsert, { relate: true, fetchStrategy }) .then((result) => { - expect(result.$beforeUpdateCalled).to.equal(1); - expect(result.$afterUpdateCalled).to.equal(1); + expect(result.$beforeUpdateCalled).toBe(1); + expect(result.$afterUpdateCalled).toBe(1); - expect(result.model1Relation1.$beforeUpdateCalled).to.equal(undefined); - expect(result.model1Relation1.$afterUpdateCalled).to.equal(undefined); + expect(result.model1Relation1.$beforeUpdateCalled).toBeUndefined(); + expect(result.model1Relation1.$afterUpdateCalled).toBeUndefined(); }); }) .then(() => { return Model1.query(session.knex).findById(1); }) .then((model) => { - expect(model.model1Id).to.equal(3); - expect(model.model1Prop1).to.equal('updated'); + expect(model.model1Id).toBe(3); + expect(model.model1Prop1).toBe('updated'); }); }); @@ -966,7 +1476,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ model1Prop1: 'new', model1Relation1: { model1Prop1: 'new belongsToOne', @@ -1004,13 +1514,32 @@ module.exports = (session) => { ], }; return transaction(session.knex, (trx) => { - return Model1.query(trx).upsertGraphAndFetch(upsert, { fetchStrategy }); + return ( + Model1.query(trx) + .upsertGraphAndFetch(upsert, { fetchStrategy }) + // Order the fetched relations like the fetch below. + .context({ + onBuild(builder) { + if (!builder.isFind()) { + return; + } + + if (builder.modelClass().getTableName() === 'Model1') { + builder.orderBy('Model1.id'); + } else if (builder.modelClass().getTableName() === 'model2') { + builder.orderBy('model2.id_col'); + } + }, + }) + ); }).then((upserted) => { return Model1.query(session.knex) - .withGraphFetched('[model1Relation1, model1Relation2.model2Relation1]') + .withGraphFetched( + '[model1Relation1, model1Relation2(orderById).model2Relation1(orderById)]', + ) .findById(upserted.id) .then((fetched) => { - expect(upserted.$toJson()).to.eql(fetched.$toJson()); + expect(upserted.$toJson()).toEqual(fetched.$toJson()); }); }); }); @@ -1086,33 +1615,31 @@ module.exports = (session) => { }, }) .then((result) => { - expect(result.model1Relation2[0].model2Relation1[2].$beforeUpdateCalled).to.equal( + expect(result.model1Relation2[0].model2Relation1[2].$beforeUpdateCalled).toBe( undefined, ); if (session.isPostgres()) { - expect(sql.length).to.equal(12); + expect(sql.length).toBe(12); if (fetchStrategy === FetchStrategy.OnlyIdentifiers) { - chai - .expect(sql) - .to.containSubset([ - 'select "Model1"."id", "Model1"."model1Id" from "Model1" where "Model1"."id" in (2) order by "Model1"."id" asc', - 'select "Model1"."id" from "Model1" where "Model1"."id" in (3) order by "Model1"."id" asc', - 'select "model2"."model1_id", "model2"."id_col" from "model2" where "model2"."model1_id" in (2) order by "model2"."id_col" asc', - 'select "Model1Model2"."model2Id" as "objectiontmpjoin0", "Model1"."id" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1, 2) order by "Model1"."id" asc', - - 'delete from "Model1Model2" where "Model1Model2"."model2Id" in (1) and exists (select 1 from "Model1" where "Model1"."id" = "Model1Model2"."model1Id" and "Model1"."id" in (5) order by "Model1"."id" asc)', - 'update "model2" set "model1_id" = NULL where "model2"."id_col" in (2) and "model2"."model1_id" in (2)', - - 'insert into "Model1" ("model1Prop1") values (\'inserted manyToMany\') returning "id"', - 'insert into "model2" ("model1_id", "model2_prop1") values (2, \'inserted hasMany\') returning "id_col"', - 'insert into "Model1Model2" ("model1Id", "model2Id") values (8, 1), (6, 1) returning "model1Id"', - - 'update "Model1" set "model1Prop1" = \'updated root 2\', "model1Id" = NULL where "Model1"."id" = 2', - 'update "Model1" set "model1Prop1" = \'updated manyToMany 1\' where "Model1"."id" = 4', - 'update "model2" set "model2_prop1" = \'updated hasMany 1\' where "model2"."id_col" = 1', - ]); + expect(sql).toContainSubset([ + 'select "Model1"."id", "Model1"."model1Id" from "Model1" where "Model1"."id" in (2) order by "Model1"."id" asc', + 'select "Model1"."id" from "Model1" where "Model1"."id" in (3) order by "Model1"."id" asc', + 'select "model2"."model1_id", "model2"."id_col" from "model2" where "model2"."model1_id" in (2) order by "model2"."id_col" asc', + 'select "Model1Model2"."model2Id" as "objectiontmpjoin0", "Model1"."id" from "Model1" inner join "Model1Model2" on "Model1"."id" = "Model1Model2"."model1Id" where "Model1Model2"."model2Id" in (1, 2) order by "Model1"."id" asc', + + 'delete from "Model1Model2" where "Model1Model2"."model2Id" in (1) and exists (select 1 from "Model1" where "Model1"."id" = "Model1Model2"."model1Id" and "Model1"."id" in (5) order by "Model1"."id" asc)', + 'update "model2" set "model1_id" = NULL where "model2"."id_col" in (2) and "model2"."model1_id" in (2)', + + 'insert into "Model1" ("model1Prop1") values (\'inserted manyToMany\') returning "id"', + 'insert into "model2" ("model1_id", "model2_prop1") values (2, \'inserted hasMany\') returning "id_col"', + 'insert into "Model1Model2" ("model1Id", "model2Id") values (8, 1), (6, 1) returning "model1Id"', + + 'update "Model1" set "model1Prop1" = \'updated root 2\', "model1Id" = NULL where "Model1"."id" = 2', + 'update "Model1" set "model1Prop1" = \'updated manyToMany 1\' where "Model1"."id" = 4', + 'update "model2" set "model2_prop1" = \'updated hasMany 1\' where "model2"."id_col" = 1', + ]); } } @@ -1125,7 +1652,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: null, model1Prop1: 'updated root 2', @@ -1168,7 +1695,7 @@ module.exports = (session) => { return Promise.all([trx('Model1'), trx('model2')]).then( ([model1Rows, model2Rows]) => { // Row 3 should NOT be deleted. - expect(model1Rows.find((it) => it.id == 3)).to.eql({ + expect(model1Rows.find((it) => it.id == 3)).toEqual({ id: 3, model1Id: null, model1Prop1: 'belongsToOne', @@ -1176,7 +1703,7 @@ module.exports = (session) => { }); // Row 5 should NOT be deleted. - expect(model1Rows.find((it) => it.id == 5)).to.eql({ + expect(model1Rows.find((it) => it.id == 5)).toEqual({ id: 5, model1Id: null, model1Prop1: 'manyToMany 2', @@ -1184,7 +1711,7 @@ module.exports = (session) => { }); // Row 2 should NOT be deleted. - expect(model2Rows.find((it) => it.id_col == 2)).to.eql({ + expect(model2Rows.find((it) => it.id_col == 2)).toEqual({ id_col: 2, model1_id: null, model2_prop1: 'hasMany 2', @@ -1225,9 +1752,9 @@ module.exports = (session) => { return BoundModel1.query().findById(1).withGraphFetched('model1Relation2'); }) .then((result) => { - expect(result.model1Relation2).to.have.length(3); + expect(result.model1Relation2).toHaveLength(3); - chai.expect(result).to.containSubset({ + expect(result).toContainSubset({ id: 1, model1Id: null, model1Prop1: 'root 1', @@ -1279,9 +1806,9 @@ module.exports = (session) => { return BoundModel1.query().findById(1).withGraphFetched('model1Relation2'); }) .then((result) => { - expect(result.model1Relation2).to.have.length(3); + expect(result.model1Relation2).toHaveLength(3); - chai.expect(result).to.containSubset({ + expect(result).toContainSubset({ id: 1, model1Id: null, model1Prop1: 'root 1', @@ -1348,7 +1875,7 @@ module.exports = (session) => { return Model1.query(trx) .upsertGraph(upsert, { unrelate: true, relate: true, fetchStrategy }) .then((result) => { - expect(result.model1Relation2[0].model2Relation1[2].$beforeUpdateCalled).to.equal(1); + expect(result.model1Relation2[0].model2Relation1[2].$beforeUpdateCalled).toBe(1); // Fetch the graph from the database. return Model1.query(trx) @@ -1357,7 +1884,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: 3, model1Prop1: 'root 2', @@ -1426,7 +1953,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: 3, model1Prop1: null, @@ -1480,7 +2007,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: 3, model1Prop1: null, @@ -1581,7 +2108,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: null, model1Prop1: 'updated root 2', @@ -1637,7 +2164,7 @@ module.exports = (session) => { return Promise.all([trx('Model1'), trx('model2')]).then( ([model1Rows, model2Rows]) => { // Row 3 should NOT be deleted. - expect(model1Rows.find((it) => it.id == 3)).to.eql({ + expect(model1Rows.find((it) => it.id == 3)).toEqual({ id: 3, model1Id: null, model1Prop1: 'belongsToOne', @@ -1645,7 +2172,7 @@ module.exports = (session) => { }); // Row 5 should NOT be deleted. - expect(model1Rows.find((it) => it.id == 5)).to.eql({ + expect(model1Rows.find((it) => it.id == 5)).toEqual({ id: 5, model1Id: null, model1Prop1: 'manyToMany 2', @@ -1653,7 +2180,7 @@ module.exports = (session) => { }); // Row 2 should NOT be deleted. - expect(model2Rows.find((it) => it.id_col == 2)).to.eql({ + expect(model2Rows.find((it) => it.id_col == 2)).toEqual({ id_col: 2, model1_id: 2, model2_prop1: 'hasMany 2', @@ -1731,7 +2258,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: null, model1Prop1: 'updated root 2', @@ -1774,7 +2301,7 @@ module.exports = (session) => { return Promise.all([trx('Model1'), trx('model2')]).then( ([model1Rows, model2Rows]) => { // Row 3 should NOT be deleted. - expect(model1Rows.find((it) => it.id == 3)).to.eql({ + expect(model1Rows.find((it) => it.id == 3)).toEqual({ id: 3, model1Id: null, model1Prop1: 'belongsToOne', @@ -1782,7 +2309,7 @@ module.exports = (session) => { }); // Row 4 should NOT be deleted. - expect(model1Rows.find((it) => it.id == 4)).to.eql({ + expect(model1Rows.find((it) => it.id == 4)).toEqual({ id: 4, model1Id: null, model1Prop1: 'manyToMany 1', @@ -1790,7 +2317,7 @@ module.exports = (session) => { }); // Row 2 should be deleted. - expect(model2Rows.find((it) => it.id_col == 2)).to.equal(undefined); + expect(model2Rows.find((it) => it.id_col == 2)).toBeUndefined(); }, ); }); @@ -1820,13 +2347,13 @@ module.exports = (session) => { .findById(2) .withGraphFetched('[model1Relation1, model1Relation2.model2Relation1(orderById)]'); - expect(result.model1Relation1).to.equal(null); - expect(result.model1Relation2.map((it) => it.idCol)).to.eql([2]); - expect(result.model1Relation2[0].model2Relation1.map((it) => it.id)).to.eql([7]); + expect(result.model1Relation1).toBeNull(); + expect(result.model1Relation2.map((it) => it.idCol)).toEqual([2]); + expect(result.model1Relation2[0].model2Relation1.map((it) => it.id)).toEqual([7]); const model1Ids = (await trx('Model1')).map((it) => it.id).sort((a, b) => a - b); // Row 6 was deleted, rows 3 and 5 were unrelated. - expect(model1Ids).to.eql([1, 2, 3, 4, 5, 7]); + expect(model1Ids).toEqual([1, 2, 3, 4, 5, 7]); }); }); @@ -1836,7 +2363,24 @@ module.exports = (session) => { { fetchStrategy, allowMarkers: true, allowRefs: true }, ); - expect(result.model1Relation1.model1Relation1).to.not.have.property('#dbRef'); + expect(result.model1Relation1.model1Relation1).not.toHaveProperty('#dbRef'); + }); + + it('should not return the markers of removed and skipped models', async () => { + const result = await Model1.query(session.knex).upsertGraph( + { + id: 2, + model1Relation2: [ + { idCol: 1, '#delete': true }, + { idCol: 2 }, + { '#id': 'skipped', model2Prop1: 'skipped' }, + ], + }, + { fetchStrategy, allowMarkers: true, noInsert: ['model1Relation2'] }, + ); + + expect(result.model1Relation2[0]).not.toHaveProperty('#delete'); + expect(result.model1Relation2[2]).not.toHaveProperty('#id'); }); it('should not fail when trying to #unrelate or #delete a model which either does not exist or is linked elsewhere', () => { @@ -1880,7 +2424,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: 3, model1Prop1: 'root 2', @@ -1961,62 +2505,116 @@ module.exports = (session) => { .withGraphFetched('model1Relation2(orderById).model2Relation1(orderById)'), ); - expect(result.model1Relation2.map((it) => it.model2Relation1.map((it) => it.id))).to.eql([ - [], - [7], - ]); + expect(result.model1Relation2.map((it) => it.model2Relation1.map((it) => it.id))).toEqual( + [[], [7]], + ); const model1Ids = (await trx('Model1')).map((it) => it.id).sort((a, b) => a - b); // Rows 4 and 6 were unrelated, row 5 was deleted. - expect(model1Ids).to.eql([1, 2, 3, 4, 6, 7]); + expect(model1Ids).toEqual([1, 2, 3, 4, 6, 7]); + }); + }); + + for (const prop of ['#unrelate', '#delete']) { + it(`should support ${prop} on BelongsToOneRelation models`, () => { + const upsert = { + id: 2, + model1Relation1: { id: 3, [prop]: true }, + }; + + return transaction(session.knex, async (trx) => { + await Model1.query(trx).upsertGraph(upsert, { fetchStrategy, allowMarkers: true }); + + const root = await Model1.query(trx).findById(2).withGraphFetched('model1Relation1'); + // The owner's reference to the deleted row is removed by + // `ON DELETE SET NULL`, which SQL Server can't have on Model1's + // reference to itself. + if (prop === '#unrelate' || !session.isMsSql()) { + expect(root.model1Id).toBeNull(); + } + expect(root.model1Relation1).toBeNull(); + + const row = await trx('Model1').where('id', 3).first(); + + if (prop === '#unrelate') { + expect(row.id).toBe(3); + } else { + expect(row).toBeUndefined(); + } + }); + }); + + it(`should keep the current BelongsToOneRelation model with ${prop} on a different model`, () => { + const upsert = { + id: 2, + // Row 1 isn't related to row 2, which is related to row 3. + model1Relation1: { id: 1, [prop]: true }, + }; + + return transaction(session.knex, async (trx) => { + await Model1.query(trx).upsertGraph(upsert, { fetchStrategy, allowMarkers: true }); + + const root = await Model1.query(trx).findById(2).withGraphFetched('model1Relation1'); + expect(root.model1Id).toBe(3); + expect(root.model1Relation1.id).toBe(3); + + const ids = (await trx('Model1').whereIn('id', [1, 3])).map((it) => it.id); + expect(ids.sort()).toEqual([1, 3]); + }); + }); + + it(`should keep the current HasOneRelation model with ${prop} on a different model`, () => { + const upsert = { + id: 3, + // Row 1 isn't related to row 3, which has one row 2. + model1Relation1Inverse: { id: 1, [prop]: true }, + }; + + return transaction(session.knex, async (trx) => { + await Model1.query(trx).upsertGraph(upsert, { fetchStrategy, allowMarkers: true }); + + const root = await Model1.query(trx) + .findById(3) + .withGraphFetched('model1Relation1Inverse'); + expect(root.model1Relation1Inverse.id).toBe(2); + + const ids = (await trx('Model1').whereIn('id', [1, 2])).map((it) => it.id); + expect(ids.sort()).toEqual([1, 2]); + }); }); - }); - for (const prop of ['#unrelate', '#delete']) { - it(`should support ${prop} on BelongsToOneRelation models`, () => { + it(`should keep the current HasOneThroughRelation model with ${prop} on a different model`, () => { const upsert = { - id: 2, - model1Relation1: { id: 3, [prop]: true }, + idCol: 1, + // Row 5 isn't related to row 1, which has one row 4 through the join table. + model2Relation2: { id: 5, [prop]: true }, }; return transaction(session.knex, async (trx) => { - await Model1.query(trx).upsertGraph(upsert, { fetchStrategy, allowMarkers: true }); + await trx('Model1Model2One').insert({ model2Id: 1, model1Id: 4 }); + await Model2.query(trx).upsertGraph(upsert, { fetchStrategy, allowMarkers: true }); - const root = await Model1.query(trx).findById(2).withGraphFetched('model1Relation1'); - // The owner's reference to the deleted row is removed by - // `ON DELETE SET NULL`, which SQL Server can't have on Model1's - // reference to itself. - if (prop === '#unrelate' || !session.isMsSql()) { - expect(root.model1Id).to.equal(null); - } - expect(root.model1Relation1).to.equal(null); - - const row = await trx('Model1').where('id', 3).first(); + const root = await Model2.query(trx).findById(1).withGraphFetched('model2Relation2'); + expect(root.model2Relation2.id).toBe(4); - if (prop === '#unrelate') { - expect(row.id).to.equal(3); - } else { - expect(row).to.equal(undefined); - } + const ids = (await trx('Model1').whereIn('id', [4, 5])).map((it) => it.id); + expect(ids.sort()).toEqual([4, 5]); }); }); - it(`should keep the current BelongsToOneRelation model with ${prop} on a different model`, () => { + it(`should remove the current HasManyRelation models with ${prop} on a different model`, () => { const upsert = { id: 2, - // Row 1 isn't related to row 2, which is related to row 3. - model1Relation1: { id: 1, [prop]: true }, + // Row 3 of model2 isn't related to row 2, which has rows 1 and 2. + model1Relation2: [{ idCol: 3, [prop]: true }], }; return transaction(session.knex, async (trx) => { + await trx('model2').insert({ id_col: 3, model2_prop1: 'other' }); await Model1.query(trx).upsertGraph(upsert, { fetchStrategy, allowMarkers: true }); - const root = await Model1.query(trx).findById(2).withGraphFetched('model1Relation1'); - expect(root.model1Id).to.equal(3); - expect(root.model1Relation1.id).to.equal(3); - - const ids = (await trx('Model1').whereIn('id', [1, 3])).map((it) => it.id); - expect(ids.sort()).to.eql([1, 3]); + const root = await Model1.query(trx).findById(2).withGraphFetched('model1Relation2'); + expect(root.model1Relation2).toEqual([]); }); }); } @@ -2044,13 +2642,13 @@ module.exports = (session) => { await Model1.query(trx).upsertGraph(upsert, { fetchStrategy, allowMarkers: true }); const root = await Model1.query(trx).findById(2).withGraphFetched('model1Relation2'); - expect(root.model1Relation2).to.eql([]); + expect(root.model1Relation2).toEqual([]); const model2 = omitIrrelevantProps( await Model2.query(trx).withGraphFetched('model2Relation1(orderById)'), ); - expect(model2).to.eql([ + expect(model2).toEqual([ { idCol: 1, model1Id: null, @@ -2073,7 +2671,7 @@ module.exports = (session) => { const model1Ids = (await trx('Model1')).map((it) => it.id).sort((a, b) => a - b); // Rows 6 and 7 still exist, nothing was inserted. - expect(model1Ids).to.eql([1, 2, 3, 4, 5, 6, 7]); + expect(model1Ids).toEqual([1, 2, 3, 4, 5, 6, 7]); }); }); @@ -2103,8 +2701,8 @@ module.exports = (session) => { .findById(2) .withGraphFetched('model1Relation2(orderById)'); - expect(root.model1Prop1).to.equal('updated root 2'); - expect(root.model1Relation2.map((it) => it.model2Prop1)).to.eql([ + expect(root.model1Prop1).toBe('updated root 2'); + expect(root.model1Relation2.map((it) => it.model2Prop1)).toEqual([ 'updated hasMany 1', 'hasMany 2', 'inserted hasMany', @@ -2140,11 +2738,11 @@ module.exports = (session) => { .orderBy('id_col') .withGraphFetched('model2Relation1(orderById)'); - expect(result.map((it) => it.model2Relation1.map((it) => it.id))).to.eql([[], [7]]); + expect(result.map((it) => it.model2Relation1.map((it) => it.id))).toEqual([[], [7]]); const model1Ids = (await trx('Model1')).map((it) => it.id).sort((a, b) => a - b); // Row 4 was deleted, rows 5 and 6 were unrelated. - expect(model1Ids).to.eql([1, 2, 3, 5, 6, 7]); + expect(model1Ids).toEqual([1, 2, 3, 5, 6, 7]); }); }); @@ -2162,17 +2760,17 @@ module.exports = (session) => { ) .catch((err) => err); - expect(err).to.be.a(ValidationError); - expect(err.type).to.equal('InvalidGraph'); - expect(err.message).to.equal( + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('InvalidGraph'); + expect(err.message).toBe( '#unrelate and #delete can only be used for related models, not for root models', ); const row = await session.knex('Model1').where('id', 2).first(); - expect(row.id).to.equal(2); + expect(row.id).toBe(2); const rows = await session.knex('model2').where('model1_id', 2).orderBy('id_col'); - expect(rows.map((it) => it.id_col)).to.eql([1, 2]); + expect(rows.map((it) => it.id_col)).toEqual([1, 2]); }); } } @@ -2195,14 +2793,14 @@ module.exports = (session) => { ) .catch((err) => err); - expect(err).to.be.a(ValidationError); - expect(err.type).to.equal('InvalidGraph'); - expect(err.message).to.equal( + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('InvalidGraph'); + expect(err.message).toBe( `${unrelateProp} and ${deleteProp} markers are not allowed in a graph by default. see the allowMarkers upsertGraph option`, ); const rows = await session.knex('model2').where('model1_id', 2).orderBy('id_col'); - expect(rows.map((it) => it.id_col)).to.eql([1, 2]); + expect(rows.map((it) => it.id_col)).toEqual([1, 2]); } finally { Model2.graphUnrelateProp = graphUnrelateProp; Model2.graphDeleteProp = graphDeleteProp; @@ -2229,8 +2827,8 @@ module.exports = (session) => { .findById(2) .withGraphFetched('model1Relation2'); - expect(root.model1Prop1).to.equal('root 2'); - expect(root.model1Relation2.map((it) => it.model2Prop1).sort()).to.eql([ + expect(root.model1Prop1).toBe('root 2'); + expect(root.model1Relation2.map((it) => it.model2Prop1).sort()).toEqual([ 'hasMany 1', 'hasMany 2', 'inserted hasMany', @@ -2258,7 +2856,7 @@ module.exports = (session) => { ); const rows = await session.knex('model2').whereIn('id_col', [1, 2]).orderBy('id_col'); - expect(rows.map((it) => [it.id_col, it.model1_id])).to.eql([[2, null]]); + expect(rows.map((it) => [it.id_col, it.model1_id])).toEqual([[2, null]]); }); it('should point to the allowMarkers option also with the options of insertGraph', async () => { @@ -2275,9 +2873,9 @@ module.exports = (session) => { ) .catch((err) => err); - expect(err).to.be.a(ValidationError); - expect(err.type).to.equal('InvalidGraph'); - expect(err.message).to.equal( + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('InvalidGraph'); + expect(err.message).toBe( '#unrelate and #delete markers are not allowed in a graph by default. see the allowMarkers upsertGraph option', ); }); @@ -2343,7 +2941,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: null, model1Prop1: 'updated root 2', @@ -2386,7 +2984,7 @@ module.exports = (session) => { return Promise.all([trx('Model1'), trx('model2')]).then( ([model1Rows, model2Rows]) => { // Row 3 should NOT be deleted. - expect(model1Rows.find((it) => it.id == 3)).to.eql({ + expect(model1Rows.find((it) => it.id == 3)).toEqual({ id: 3, model1Id: null, model1Prop1: 'belongsToOne', @@ -2394,7 +2992,7 @@ module.exports = (session) => { }); // Row 5 should NOT be deleted. - expect(model1Rows.find((it) => it.id == 5)).to.eql({ + expect(model1Rows.find((it) => it.id == 5)).toEqual({ id: 5, model1Id: null, model1Prop1: 'manyToMany 2', @@ -2402,7 +3000,7 @@ module.exports = (session) => { }); // Row 2 should be deleted. - expect(model2Rows.find((it) => it.id_col == 2)).to.equal(undefined); + expect(model2Rows.find((it) => it.id_col == 2)).toBeUndefined(); }, ); }); @@ -2429,7 +3027,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 1, model1Relation1: { id: 3, @@ -2460,7 +3058,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 1, model1Relation1: { model1Prop1, @@ -2491,7 +3089,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: 8, model1Prop1: 'updated root 2', @@ -2505,7 +3103,7 @@ module.exports = (session) => { return Promise.all([trx('Model1'), trx('model2')]).then(([model1Rows]) => { // Row 3 should be deleted. - expect(model1Rows.find((it) => it.id == 3)).to.equal(undefined); + expect(model1Rows.find((it) => it.id == 3)).toBeUndefined(); }); }); }); @@ -2532,7 +3130,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - chai.expect(result).to.containSubset({ + expect(result).toMatchObject({ id: 2, model1Relation1: { @@ -2543,7 +3141,7 @@ module.exports = (session) => { return Promise.all([trx('Model1'), trx('model2')]).then( ([model1Rows, model2Rows]) => { // Row 3 should be deleted. - expect(model1Rows.find((it) => it.id == 3)).to.equal(undefined); + expect(model1Rows.find((it) => it.id == 3)).toBeUndefined(); }, ); }); @@ -2571,7 +3169,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - chai.expect(result).to.containSubset({ + expect(result).toContainSubset({ id: 2, model1Relation2: [ @@ -2584,7 +3182,7 @@ module.exports = (session) => { return trx('model2').then((model2Rows) => { // Row 2 should be deleted. - expect(model2Rows.find((it) => it.idCol == 2)).to.equal(undefined); + expect(model2Rows.find((it) => it.idCol == 2)).toBeUndefined(); }); }); }); @@ -2619,14 +3217,16 @@ module.exports = (session) => { .upsertGraph(upsert, options) .then(() => { if (fetchStrategy === FetchStrategy.OnlyIdentifiers) { - expect(sql.length).to.equal(3); + expect(sql.length).toBe(4); } if (session.isPostgres()) { if (fetchStrategy === FetchStrategy.OnlyIdentifiers) { - chai.expect(sql).to.containSubset([ + expect(sql).toContainSubset([ 'select "Model1"."id", "Model1"."model1Id" from "Model1" where "Model1"."id" in (2)', 'select "Model1"."id" from "Model1" where "Model1"."id" in (3)', + // The related model is checked to exist: + 'select "Model1"."id" from "Model1" where "Model1"."id" in (4)', // There should only be one `model1Id` update here. If you see two, something is broken. 'update "Model1" set "model1Id" = 4 where "Model1"."id" = 2', ]); @@ -2638,7 +3238,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: 4, model1Prop1: 'root 2', @@ -2653,7 +3253,7 @@ module.exports = (session) => { return Promise.all([trx('Model1'), trx('model2')]).then( ([model1Rows, model2Rows]) => { // Row 3 should not be deleted. - expect(model1Rows.find((it) => it.id == 3)).to.not.equal(undefined); + expect(model1Rows.find((it) => it.id == 3)).not.toBeUndefined(); }, ); }); @@ -2688,7 +3288,7 @@ module.exports = (session) => { .withGraphFetched('model1Relation1.model1Relation1'); }) .then((result) => { - chai.expect(result).to.containSubset({ + expect(result).toMatchObject({ id: 2, model1Relation1: { @@ -2732,7 +3332,7 @@ module.exports = (session) => { .withGraphFetched('model1Relation1.model1Relation1'); }) .then((result) => { - chai.expect(result).to.containSubset({ + expect(result).toMatchObject({ id: 2, model1Relation1: { @@ -2748,6 +3348,22 @@ module.exports = (session) => { }); }); + it('should only write the foreign key when hasManyRelation and hasOneRelation models are related with noUpdate: true', async () => { + await Model1.query(session.knex).upsertGraph( + { + id: 1, + model1Relation1Inverse: { id: 4, model1Prop1: 'this should not be written to db' }, + model1Relation2: [{ idCol: 1, model2Prop1: 'this should not be written to db' }], + }, + { fetchStrategy, relate: true, noUpdate: true, noDelete: true }, + ); + + const model1 = await Model1.query(session.knex).findById(4); + expect(model1).toMatchObject({ id: 4, model1Id: 1, model1Prop1: 'manyToMany 1' }); + const model2 = await Model2.query(session.knex).findById(1); + expect(model2).toMatchObject({ idCol: 1, model1Id: 1, model2Prop1: 'hasMany 1' }); + }); + it('should not update other than the relation properties when belongsToOneRelation is unrelated but the parent has noUpdate: true', () => { const upsert1 = { id: 2, @@ -2792,7 +3408,7 @@ module.exports = (session) => { .withGraphFetched('model1Relation1.model1Relation1'); }) .then((result) => { - chai.expect(result).to.containSubset({ + expect(result).toMatchObject({ id: 2, model1Relation1: { @@ -2830,7 +3446,7 @@ module.exports = (session) => { .withGraphFetched('model1Relation2.model2Relation1(orderById)') .modifiers({ orderById: (builder) => builder.orderBy('id') }); - chai.expect(result).to.containSubset({ + expect(result).toMatchObject({ id: 1, model1Relation2: [ @@ -2858,8 +3474,8 @@ module.exports = (session) => { ) .catch((err) => err); - expect(err).to.be.a(Model1.NotFoundError); - expect(err.message).to.match(/^model \(id=4\) is not a child of model \(id=1\)/); + expect(err).toBeInstanceOf(Model1.NotFoundError); + expect(err.message).toMatch(/^model \(id=4\) is not a child of model \(id=1\)/); }); it('should insert with an id instead of throwing an error if `insertMissing` option is true', () => { @@ -2908,7 +3524,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: 3, model1Prop1: 'root 2', @@ -3000,7 +3616,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: 3, model1Prop1: 'root 2', @@ -3063,7 +3679,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: NONEXISTENT_ID, model1Prop1: `updated root ${NONEXISTENT_ID}`, model1Id: 8, @@ -3082,7 +3698,7 @@ module.exports = (session) => { return upsertAndCompare().then(() => upsertAndCompare()); }); - it('should fail if given nonexistent id in root', (done) => { + it('should fail if given nonexistent id in root', () => { const upsert = { // This doesn't exist. id: NONEXISTENT_ID, @@ -3093,30 +3709,28 @@ module.exports = (session) => { }, }; - transaction(session.knex, (trx) => { + return transaction(session.knex, (trx) => { return Model1.query(trx).upsertGraph(upsert, { fetchStrategy }); }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err instanceof Model1.NotFoundError).to.equal(true); - expect(err.message).to.equal( + expect(err instanceof Model1.NotFoundError).toBe(true); + expect(err.message).toBe( 'root model (id=1000) does not exist. If you want to insert it with an id, use the insertMissing option', ); - expect(err.data.dataPath).to.eql([]); + expect(err.data.dataPath).toEqual([]); return session .knex('Model1') .whereIn('model1Prop1', ['updated root 2', 'inserted belongsToOne']); }) .then((rows) => { - expect(rows).to.have.length(0); - done(); - }) - .catch(done); + expect(rows).toHaveLength(0); + }); }); - it('should fail if given nonexistent id in a relation (without relate: true option)', (done) => { + it('should fail if given nonexistent id in a relation (without relate: true option)', () => { const upsert = { id: 2, model1Prop1: 'updated root 2', @@ -3128,28 +3742,37 @@ module.exports = (session) => { }, }; - transaction(session.knex, (trx) => { + return transaction(session.knex, (trx) => { return Model1.query(trx).upsertGraph(upsert, { fetchStrategy }); }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err instanceof Model1.NotFoundError).to.equal(true); - expect(err.type).to.equal('NotFound'); - expect(err.message).to.equal( + expect(err instanceof Model1.NotFoundError).toBe(true); + expect(err.type).toBe('NotFound'); + expect(err.message).toBe( 'model (id=1000) is not a child of model (id=2). If you want to relate it, use the relate option. If you want to insert it with an id, use the insertMissing option', ); - expect(err.data.dataPath).to.eql(['model1Relation1']); + expect(err.data.dataPath).toEqual(['model1Relation1']); return session .knex('Model1') .whereIn('model1Prop1', ['updated root 2', 'inserted belongsToOne']); }) .then((rows) => { - expect(rows).to.have.length(0); - done(); + expect(rows).toHaveLength(0); + }); + }); + + it('should include the index of array input in the data path of errors', async () => { + const err = await Model1.query(session.knex) + .upsertGraph([{ id: 1 }, { id: 2, model1Relation1: { id: NONEXISTENT_ID } }], { + fetchStrategy, }) - .catch(done); + .catch((err) => err); + + expect(err).toBeInstanceOf(Model1.NotFoundError); + expect(err.data.dataPath).toEqual([1, 'model1Relation1']); }); it('allowGraph should limit the relations that can be upserted', () => { @@ -3228,15 +3851,15 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(errors.length).to.equal(2); + expect(errors.length).toBe(2); errors.forEach((error) => { - expect(error).to.be.a(ValidationError); - expect(error.type).to.equal('UnallowedRelation'); - expect(error.message).to.equal('trying to upsert an unallowed relation'); + expect(error).toBeInstanceOf(ValidationError); + expect(error.type).toBe('UnallowedRelation'); + expect(error.message).toBe('trying to upsert an unallowed relation'); }); - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: null, model1Prop1: 'updated root 2', @@ -3279,7 +3902,7 @@ module.exports = (session) => { return Promise.all([session.knex('Model1'), session.knex('model2')]).then( ([model1Rows, model2Rows]) => { // Row 3 should NOT be deleted. - expect(model1Rows.find((it) => it.id == 3)).to.eql({ + expect(model1Rows.find((it) => it.id == 3)).toEqual({ id: 3, model1Id: null, model1Prop1: 'belongsToOne', @@ -3287,7 +3910,7 @@ module.exports = (session) => { }); // Row 5 should NOT be deleted. - expect(model1Rows.find((it) => it.id == 5)).to.eql({ + expect(model1Rows.find((it) => it.id == 5)).toEqual({ id: 5, model1Id: null, model1Prop1: 'manyToMany 2', @@ -3295,7 +3918,7 @@ module.exports = (session) => { }); // Row 2 should NOT be deleted. - expect(model2Rows.find((it) => it.id_col == 2)).to.eql({ + expect(model2Rows.find((it) => it.id_col == 2)).toEqual({ id_col: 2, model1_id: null, model2_prop1: 'hasMany 2', @@ -3360,7 +3983,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: 3, model1Prop1: '30', @@ -3402,13 +4025,13 @@ module.exports = (session) => { return Promise.all([trx('Model1'), trx('model2')]).then( ([model1Rows, model2Rows]) => { // Row 5 should be deleted. - expect(model1Rows.find((it) => it.id == 5)).to.equal(undefined); + expect(model1Rows.find((it) => it.id == 5)).toBeUndefined(); // Row 6 should NOT be deleted even thought its parent is. - expect(model1Rows.find((it) => it.id == 6)).to.be.an(Object); + expect(model1Rows.find((it) => it.id == 6)).toBeInstanceOf(Object); // Row 7 should NOT be deleted even thought its parent is. - expect(model1Rows.find((it) => it.id == 7)).to.be.an(Object); + expect(model1Rows.find((it) => it.id == 7)).toBeInstanceOf(Object); // Row 2 should be deleted. - expect(model2Rows.find((it) => it.id_col == 2)).to.equal(undefined); + expect(model2Rows.find((it) => it.id_col == 2)).toBeUndefined(); }, ); }); @@ -3430,11 +4053,11 @@ module.exports = (session) => { return Model1.query(session.knex).findById(2).withGraphFetched('model1Relation1'); }) .then((result) => { - expect(result.model1Relation1).to.equal(null); + expect(result.model1Relation1).toBeNull(); return Model1.query(session.knex).findById(3); }) .then((result) => { - expect(result).to.equal(undefined); + expect(result).toBeUndefined(); }); }); @@ -3453,7 +4076,7 @@ module.exports = (session) => { runBefore(_, builder) { if (builder.isFind() && builder.isExecutable()) { findQueryCount++; - expect(builder.isInternal()).to.equal(true); + expect(builder.isInternal()).toBe(true); } }, }) @@ -3462,31 +4085,27 @@ module.exports = (session) => { .findById(2) .withGraphFetched('model1Relation1'); - expect(findQueryCount).to.equal(2); - expect(fetchQuery.isInternal()).to.equal(false); + expect(findQueryCount).toBe(2); + expect(fetchQuery.isInternal()).toBe(false); return fetchQuery; }); }); - it('should throw a sensible error if a non-object is passed in as the root', (done) => { - Model1.bindKnex(session.knex) + it('should throw a sensible error if a non-object is passed in as the root', () => { + return Model1.bindKnex(session.knex) .query() .upsertGraph('not a model') .then(() => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.type).to.equal('InvalidGraph'); - expect(err.message).to.equal( - 'expected value "not a model" to be an instance of Model1', - ); - done(); - }) - .catch(done); + expect(err.type).toBe('InvalidGraph'); + expect(err.message).toBe('expected value "not a model" to be an instance of Model1'); + }); }); - it('should throw a sensible error if a non-object is passed in a belongs to one relation', (done) => { - Model1.bindKnex(session.knex) + it('should throw a sensible error if a non-object is passed in a belongs to one relation', () => { + return Model1.bindKnex(session.knex) .query() .upsertGraph( { @@ -3501,17 +4120,13 @@ module.exports = (session) => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.type).to.equal('InvalidGraph'); - expect(err.message).to.equal( - 'expected value "not an object" to be an instance of Model1', - ); - done(); - }) - .catch(done); + expect(err.type).toBe('InvalidGraph'); + expect(err.message).toBe('expected value "not an object" to be an instance of Model1'); + }); }); - it('should throw a sensible error if a non-object is passed in a has many relation', (done) => { - Model1.bindKnex(session.knex) + it('should throw a sensible error if a non-object is passed in a has many relation', () => { + return Model1.bindKnex(session.knex) .query() .upsertGraph( { @@ -3526,17 +4141,13 @@ module.exports = (session) => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.type).to.equal('InvalidGraph'); - expect(err.message).to.equal( - 'expected value "not an object" to be an instance of Model2', - ); - done(); - }) - .catch(done); + expect(err.type).toBe('InvalidGraph'); + expect(err.message).toBe('expected value "not an object" to be an instance of Model2'); + }); }); - it('should throw if any `where` calls are added to the query', (done) => { - Model1.bindKnex(session.knex) + it('should throw if any `where` calls are added to the query', () => { + return Model1.bindKnex(session.knex) .query() .where('id', 1) .upsertGraph( @@ -3551,16 +4162,14 @@ module.exports = (session) => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( - 'upsertGraph query should contain no other query builder calls like `findById`, `where` or `$relatedQuery` that would affect the SQL. They have no effect.', + expect(err.message).toBe( + 'upsertGraph query should contain no other query builder calls like `findById`, `where`, `$query` or `$relatedQuery` that would affect the SQL. They have no effect.', ); - done(); - }) - .catch(done); + }); }); - it('should throw if any `findById` call is added to the query', (done) => { - Model1.bindKnex(session.knex) + it('should throw if any `findById` call is added to the query', () => { + return Model1.bindKnex(session.knex) .query() .findById(1) .upsertGraph( @@ -3575,16 +4184,14 @@ module.exports = (session) => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( - 'upsertGraph query should contain no other query builder calls like `findById`, `where` or `$relatedQuery` that would affect the SQL. They have no effect.', + expect(err.message).toBe( + 'upsertGraph query should contain no other query builder calls like `findById`, `where`, `$query` or `$relatedQuery` that would affect the SQL. They have no effect.', ); - done(); - }) - .catch(done); + }); }); - it('should throw if any `findOne` call is added to the query', (done) => { - Model1.bindKnex(session.knex) + it('should throw if any `findOne` call is added to the query', () => { + return Model1.bindKnex(session.knex) .query() .findOne({ id: 1 }) .upsertGraph( @@ -3599,16 +4206,14 @@ module.exports = (session) => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( - 'upsertGraph query should contain no other query builder calls like `findById`, `where` or `$relatedQuery` that would affect the SQL. They have no effect.', + expect(err.message).toBe( + 'upsertGraph query should contain no other query builder calls like `findById`, `where`, `$query` or `$relatedQuery` that would affect the SQL. They have no effect.', ); - done(); - }) - .catch(done); + }); }); - it('should throw if `upsertGraph` is used with `$relatedQuery`', (done) => { - Model1.fromJson({ id: 1 }) + it('should throw if `upsertGraph` is used with `$relatedQuery`', () => { + return Model1.fromJson({ id: 1 }) .$relatedQuery('model1Relation1', session.knex) .upsertGraph( { @@ -3622,12 +4227,21 @@ module.exports = (session) => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( - 'upsertGraph query should contain no other query builder calls like `findById`, `where` or `$relatedQuery` that would affect the SQL. They have no effect.', + expect(err.message).toBe( + 'upsertGraph query should contain no other query builder calls like `findById`, `where`, `$query` or `$relatedQuery` that would affect the SQL. They have no effect.', ); - done(); - }) - .catch(done); + }); + }); + + it('should throw if `upsertGraph` is used with `$query` (Vincit#1168)', async () => { + const err = await Model1.fromJson({ id: 1 }) + .$query(session.knex) + .upsertGraph({ id: 1, model1Prop1: 'updated' }, { fetchStrategy }) + .catch((err) => err); + + expect(err.message).toBe( + 'upsertGraph query should contain no other query builder calls like `findById`, `where`, `$query` or `$relatedQuery` that would affect the SQL. They have no effect.', + ); }); if (fetchStrategy !== FetchStrategy.OnlyIdentifiers) { @@ -3721,7 +4335,7 @@ module.exports = (session) => { .then(() => { // There should only be the selects, one update and a m2m insert. // 5 selects, 1 update, 2 inserts (row and pivot row). - expect(sql.length).to.equal(8); + expect(sql.length).toBe(8); return Model1.query(trx) .findById(2) @@ -3740,7 +4354,7 @@ module.exports = (session) => { .then(() => { // There should only be the selects since we patched using // the current state. - expect(sql.length).to.equal(5); + expect(sql.length).toBe(5); return Model1.query(trx) .findById(2) @@ -3762,15 +4376,15 @@ module.exports = (session) => { }) .then(() => { // There should only be the selects and the json field update. - expect(sql.length).to.equal(6); + expect(sql.length).toBe(6); }); }); }); }); }); - it('should throw a sensible error if an option with an invalid type is passed', (done) => { - Model1.bindKnex(session.knex) + it('should throw a sensible error if an option with an invalid type is passed', () => { + return Model1.bindKnex(session.knex) .query() .upsertGraph( { @@ -3784,16 +4398,14 @@ module.exports = (session) => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( 'expected noRelate option value "42" to be a boolean, an array of relation paths or a relation expression', ); - done(); - }) - .catch(done); + }); }); - it('should throw a sensible error if an invalid relation expression is passed', (done) => { - Model1.bindKnex(session.knex) + it('should throw a sensible error if an invalid relation expression is passed', () => { + return Model1.bindKnex(session.knex) .query() .upsertGraph( { @@ -3807,12 +4419,10 @@ module.exports = (session) => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.match( + expect(err.message).toMatch( /^invalid relation expression "\[model1Relation1" in noInsert option: /, ); - done(); - }) - .catch(done); + }); }); } @@ -3828,7 +4438,7 @@ module.exports = (session) => { const rows = await session.knex('model2').orderBy('id_col'); - chai.expect(rows.map((row) => [row.id_col, row.model1_id, row.model2_prop1])).to.eql([ + expect(rows.map((row) => [row.id_col, row.model1_id, row.model2_prop1])).toEqual([ [1, 1, 'moved'], [2, 2, 'hasMany 2'], ]); @@ -3846,7 +4456,7 @@ module.exports = (session) => { const rows = await session.knex('model2').orderBy('id_col'); - chai.expect(rows.map((row) => [row.id_col, row.model1_id, row.model2_prop1])).to.eql([ + expect(rows.map((row) => [row.id_col, row.model1_id, row.model2_prop1])).toEqual([ [1, 1, 'moved'], [2, 2, 'hasMany 2'], ]); @@ -3864,7 +4474,7 @@ module.exports = (session) => { const rows = await session.knex('model2').orderBy('id_col'); - chai.expect(rows.map((row) => row.id_col)).to.eql([2]); + expect(rows.map((row) => row.id_col)).toEqual([2]); }); it('should unrelate a ManyToMany row that is related elsewhere instead of deleting it', async () => { @@ -3885,8 +4495,8 @@ module.exports = (session) => { .where('model1Id', 5) .select('model2Id'); - expect(model1Row.model1Prop1).to.equal('manyToMany 2'); - chai.expect(joinRows).to.eql([{ model2Id: 2 }]); + expect(model1Row.model1Prop1).toBe('manyToMany 2'); + expect(joinRows).toEqual([{ model2Id: 2 }]); }); it('should keep a ManyToMany row related in its old place with noDelete: true', async () => { @@ -3907,7 +4517,7 @@ module.exports = (session) => { .orderBy('model2Id') .select('model2Id'); - chai.expect(joinRows).to.eql([{ model2Id: 1 }, { model2Id: 2 }]); + expect(joinRows).toEqual([{ model2Id: 1 }, { model2Id: 2 }]); }); it('should unrelate a BelongsToOne row that is related elsewhere instead of deleting it', async () => { @@ -3921,7 +4531,7 @@ module.exports = (session) => { const rows = await session.knex('Model1').whereIn('id', [1, 2, 3]).orderBy('id'); - chai.expect(rows.map((row) => [row.id, row.model1Id])).to.eql([ + expect(rows.map((row) => [row.id, row.model1Id])).toEqual([ [1, 3], [2, null], [3, null], @@ -4038,7 +4648,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: 6, @@ -4104,7 +4714,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: null, model1Prop1: 'updated root 2', @@ -4172,7 +4782,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: null, model1Prop1: 'updated root 2', @@ -4201,6 +4811,83 @@ module.exports = (session) => { }); }); + it('should throw before writing anything if a related model with relations does not exist', async () => { + const upsert = { + id: 2, + model1Prop1: 'updated root 2', + model1Relation3: [{ idCol: NONEXISTENT_ID, model2Relation3: [{ id: 1 }] }], + }; + + const err = await Model1.query(session.knex) + .upsertGraph(upsert, { relate: true, fetchStrategy }) + .catch((err) => err); + + expect(err).toBeInstanceOf(Model1.NotFoundError); + expect(err.message).toBe( + 'model (id=1000) does not exist. If you want to insert it with an id, use the insertMissing option', + ); + expect(err.data.dataPath).toEqual(['model1Relation3', 0]); + expect(await session.knex('Model1').where('model1Prop1', 'updated root 2')).toEqual([]); + }); + + for (const [relationName, related, dataPath] of [ + ['model1Relation1', { id: NONEXISTENT_ID }, ['model1Relation1']], + ['model1Relation1Inverse', { id: NONEXISTENT_ID }, ['model1Relation1Inverse']], + ['model1Relation2', [{ idCol: 1 }, { idCol: NONEXISTENT_ID }], ['model1Relation2', 1]], + ['model1Relation3', [{ idCol: NONEXISTENT_ID }], ['model1Relation3', 0]], + ]) { + it(`should throw before writing anything if a model related through ${relationName} does not exist (#372)`, async () => { + const err = await Model1.query(session.knex) + .upsertGraph( + { id: 2, model1Prop1: 'updated root 2', [relationName]: related }, + { relate: true, noDelete: true, fetchStrategy }, + ) + .catch((err) => err); + + expect(err).toBeInstanceOf(Model1.NotFoundError); + expect(err.message).toBe( + 'model (id=1000) does not exist. If you want to insert it with an id, use the insertMissing option', + ); + expect(err.data.dataPath).toEqual(dataPath); + expect(await session.knex('Model1').where('model1Prop1', 'updated root 2')).toEqual([]); + }); + } + + it('should relate a model that the graph inserts elsewhere with insertMissing (#372)', async () => { + await Model1.query(session.knex).upsertGraph( + { + id: 2, + model1Relation2: [{ idCol: NONEXISTENT_ID }], + model1Relation3: [{ idCol: NONEXISTENT_ID, model2Prop1: 'inserted' }], + }, + { relate: true, insertMissing: ['model1Relation3'], noDelete: true, fetchStrategy }, + ); + + expect(await session.knex('model2').where('id_col', NONEXISTENT_ID)).toMatchObject([ + { model1_id: 2, model2_prop1: 'inserted' }, + ]); + expect( + await session.knex('Model1Model2').where('model2Id', NONEXISTENT_ID).select('model1Id'), + ).toEqual([{ model1Id: 2 }]); + }); + + it('should throw before writing anything for a #dbRef to a model that does not exist (#372)', async () => { + const err = await Model1.query(session.knex) + .upsertGraph( + { + id: 2, + model1Prop1: 'updated root 2', + model1Relation1: { '#dbRef': NONEXISTENT_ID }, + }, + { allowRefs: true, fetchStrategy }, + ) + .catch((err) => err); + + expect(err).toBeInstanceOf(Model1.NotFoundError); + expect(err.message).toMatch(/model \(id=1000\) does not exist/); + expect(await session.knex('Model1').where('model1Prop1', 'updated root 2')).toEqual([]); + }); + for (const [title, options] of [ [ 'flags', @@ -4249,7 +4936,7 @@ module.exports = (session) => { }) .then(omitIrrelevantProps) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ id: 2, model1Id: null, model1Prop1: 'updated root 2', @@ -4325,7 +5012,7 @@ module.exports = (session) => { it.model2Prop1, it.model2Relation3.map((it) => it.id), ]), - ).to.eql([ + ).toEqual([ ['manyToMany 1', [1, 2, 3]], ['manyToMany 2', [2, 4]], ]); @@ -4334,7 +5021,7 @@ module.exports = (session) => { }); } - it('references to parent graph should produce an error in recursive upsert by default', (done) => { + it('references to parent graph should produce an error in recursive upsert by default', () => { const upsert = { id: 2, model1Prop1: 'updated root 2', @@ -4364,16 +5051,15 @@ module.exports = (session) => { fetchStrategy, }; - Model1.query(session.knex) + return Model1.query(session.knex) .upsertGraph(upsert, options) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( + expect(err.message).toBe( '#ref references are not allowed in a graph by default. see the allowRefs insert/upsert graph option', ); - done(); }); }); @@ -4411,7 +5097,7 @@ module.exports = (session) => { return Model1.query(session.knex) .upsertGraph(upsert, options) .then((result) => { - chai.expect(result).to.containSubset({ + expect(result).toContainSubset({ id: 2, model1Prop1: 'updated root 2', @@ -4431,9 +5117,7 @@ module.exports = (session) => { ], }); - expect(result.model1Relation1.id).to.equal( - result.model1Relation3[0].model2Relation2.id, - ); + expect(result.model1Relation1.id).toBe(result.model1Relation3[0].model2Relation2.id); return Model1.query(session.knex) .findById(2) @@ -4445,7 +5129,7 @@ module.exports = (session) => { }); }) .then((result) => { - chai.expect(result).to.containSubset({ + expect(result).toContainSubset({ id: 2, model1Prop1: 'updated root 2', @@ -4465,9 +5149,7 @@ module.exports = (session) => { ], }); - expect(result.model1Relation1.id).to.equal( - result.model1Relation3[0].model2Relation2.id, - ); + expect(result.model1Relation1.id).toBe(result.model1Relation3[0].model2Relation2.id); }); }); @@ -4507,7 +5189,7 @@ module.exports = (session) => { return Model1.query(session.knex) .upsertGraph(upsert, options) .then((result) => { - chai.expect(result).to.containSubset({ + expect(result).toContainSubset({ id: 2, model1Prop1: 'updated root 2', @@ -4528,7 +5210,7 @@ module.exports = (session) => { ], }); - expect(result.model1Relation1.id).to.not.equal( + expect(result.model1Relation1.id).not.toBe( result.model1Relation3[0].model2Relation2.id, ); @@ -4542,7 +5224,7 @@ module.exports = (session) => { }); }) .then((result) => { - chai.expect(result).to.containSubset({ + expect(result).toContainSubset({ id: 2, model1Prop1: 'updated root 2', @@ -4563,7 +5245,7 @@ module.exports = (session) => { ], }); - expect(result.model1Relation1.id).to.not.equal( + expect(result.model1Relation1.id).not.toBe( result.model1Relation3[0].model2Relation2.id, ); }); @@ -4571,7 +5253,7 @@ module.exports = (session) => { }); describe('validation and transactions', () => { - before(() => { + beforeAll(() => { Model1.$$jsonSchema = { type: 'object', required: ['model1Prop1', 'model1Prop2'], @@ -4592,7 +5274,7 @@ module.exports = (session) => { }; }); - after(() => { + afterAll(() => { delete Model1.$$jsonSchema; delete Model1.$$validator; @@ -4758,10 +5440,8 @@ module.exports = (session) => { .then((results) => { // Check that all transactions have failed because of a validation error. results.forEach((res, index) => { - expect(res.isRejected()).to.equal(true); - expect(res.reason().data[errorKeys[index]][0].message).to.equal( - 'must be string,null', - ); + expect(res.isRejected()).toBe(true); + expect(res.reason().data[errorKeys[index]][0].message).toBe('must be string,null'); }); return Model1.query(session.knex) @@ -4773,7 +5453,7 @@ module.exports = (session) => { }) .then((db) => { // Check that the transactions worked and the database was in no way modified. - expect(omitIrrelevantProps(db)).to.eql(population); + expect(omitIrrelevantProps(db)).toEqual(population); return transaction(session.knex, (trx) => { return Model1.query(trx) @@ -4789,7 +5469,7 @@ module.exports = (session) => { .then(omitIrrelevantProps) .then(omitIds) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ model1Id: 3, model1Prop1: 'updated root 2', @@ -4825,13 +5505,13 @@ module.exports = (session) => { return Promise.all([trx('Model1'), trx('model2')]).then( ([model1Rows, model2Rows]) => { // Row 5 should be deleted. - expect(model1Rows.find((it) => it.id == 5)).to.equal(undefined); + expect(model1Rows.find((it) => it.id == 5)).toBeUndefined(); // Row 6 should NOT be deleted even thought its parent is. - expect(model1Rows.find((it) => it.id == 6)).to.be.an(Object); + expect(model1Rows.find((it) => it.id == 6)).toBeInstanceOf(Object); // Row 7 should NOT be deleted even thought its parent is. - expect(model1Rows.find((it) => it.id == 7)).to.be.an(Object); + expect(model1Rows.find((it) => it.id == 7)).toBeInstanceOf(Object); // Row 2 should be deleted. - expect(model2Rows.find((it) => it.id_col == 2)).to.equal(undefined); + expect(model2Rows.find((it) => it.id_col == 2)).toBeUndefined(); }, ); }); @@ -4886,7 +5566,7 @@ module.exports = (session) => { ); }) .then((result) => { - chai.expect(result).to.containSubset([ + expect(result).toContainSubset([ { id: 1000, model1Relation2: [ @@ -4965,7 +5645,7 @@ module.exports = (session) => { ); }) .then((result) => { - chai.expect(result).to.containSubset([ + expect(result).toContainSubset([ { id: 1000, model1Relation2: [ @@ -4994,7 +5674,7 @@ module.exports = (session) => { }); }); - it('should always patch-validate #dbRef reference objects (does validate)', (done) => { + it('should always patch-validate #dbRef reference objects (does validate)', () => { const upsert = [ { id: 1000, @@ -5034,23 +5714,21 @@ module.exports = (session) => { allowRefs: true, }; - Model1.query(session.knex) + return Model1.query(session.knex) .upsertGraph(upsert, options) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.data['model1Relation2[0].model2Prop1'][0].message).to.equal( + expect(err.data['[0].model1Relation2[0].model2Prop1'][0].message).toBe( 'must be string,null', ); - done(); - }) - .catch(done); + }); }); }); describe('upserts with update: true option', () => { - before(() => { + beforeAll(() => { Model1.$$jsonSchema = { type: 'object', required: ['model1Prop1', 'model1Prop2'], @@ -5062,7 +5740,7 @@ module.exports = (session) => { }; }); - after(() => { + afterAll(() => { delete Model1.$$jsonSchema; delete Model1.$$validator; }); @@ -5119,8 +5797,8 @@ module.exports = (session) => { .then((results) => { // Check that all transactions have failed because of a validation error. results.forEach((res, index) => { - expect(res.isRejected()).to.equal(true); - expect(res.reason().data[errorKeys[index]][0].message).to.equal( + expect(res.isRejected()).toBe(true); + expect(res.reason().data[errorKeys[index]][0].message).toBe( "must have required property 'model1Prop2'", ); }); @@ -5136,7 +5814,7 @@ module.exports = (session) => { .then(omitIrrelevantProps) .then(omitIds) .then((result) => { - expect(result).to.eql({ + expect(result).toEqual({ model1Id: 3, model1Prop1: 'updated root 2', @@ -5179,16 +5857,166 @@ module.exports = (session) => { ) .then((err) => { if (errorKey) { - expect(err).to.be.a(ValidationError); - expect(Object.keys(err.data)).to.eql([errorKey]); + expect(err).toBeInstanceOf(ValidationError); + expect(Object.keys(err.data)).toEqual([errorKey]); } else { - expect(err).to.equal(null); + expect(err).toBeNull(); } }); }); } }); + describe('#ref models related into a HasManyRelation or HasOneRelation (#311)', () => { + it('should set the foreign key of the referenced model', async () => { + const upsert = { + id: 1, + model1Relation2: [{ '#ref': 'referenced' }], + model1Relation3: [{ '#id': 'referenced', model2Prop1: 'referenced' }], + }; + + await Model1.query(session.knex).upsertGraph(upsert, { fetchStrategy, allowRefs: true }); + + const [row] = await session.knex('model2').where('model2_prop1', 'referenced'); + expect(row.model1_id).toBe(1); + }); + + it('should set the foreign key of the referenced model to an inserted owner', async () => { + const upsert = { + id: 1, + model1Relation1: { + model1Prop1: 'inserted owner', + model1Relation2: [{ '#ref': 'referenced' }], + }, + model1Relation3: [{ '#id': 'referenced', model2Prop1: 'referenced' }], + }; + + const result = await Model1.query(session.knex).upsertGraph(upsert, { + fetchStrategy, + allowRefs: true, + }); + + const [row] = await session.knex('model2').where('model2_prop1', 'referenced'); + expect(row.model1_id).toBe(result.model1Relation1.id); + }); + + it('should set the foreign key of a referenced model related into a HasOneRelation', async () => { + const upsert = { + id: 1, + model1Relation1: { '#id': 'referenced', model1Prop1: 'referenced' }, + model1Relation1Inverse: { '#ref': 'referenced' }, + }; + + const result = await Model1.query(session.knex).upsertGraph(upsert, { + fetchStrategy, + allowRefs: true, + }); + + const [row] = await session.knex('Model1').where('model1Prop1', 'referenced'); + expect(row.model1Id).toBe(1); + expect(result.model1Id).toBe(row.id); + }); + }); + + describe('rows that reference each other (#385)', () => { + // Upsert the graph and count the updates that run at the same time, + // giving each one time to overlap with the others, and the updates + // that still run or start once the upsert rejects: + async function upsertCountingUpdates( + upsert, + { shouldFail = () => false, ...options } = {}, + ) { + const counts = { running: 0, maxRunning: 0, runningOnError: null, startedOnError: 0 }; + + await Model1.query(session.knex) + .upsertGraph(upsert, { fetchStrategy, allowRefs: true, ...options }) + .context({ + async runBefore(result, builder) { + if (builder.isUpdate()) { + if (counts.runningOnError !== null) { + counts.startedOnError++; + } + if (shouldFail(builder)) { + throw new Error('failed update'); + } + counts.maxRunning = Math.max(counts.maxRunning, ++counts.running); + await delay(10); + } + return result; + }, + runAfter(result, builder) { + if (builder.isUpdate()) { + counts.running--; + } + return result; + }, + }) + .catch(async () => { + counts.runningOnError = counts.running; + await delay(50); + }); + + return counts; + } + + // Root 1 and the model inserted as its BelongsToOneRelation model, + // related into its HasOneRelation, and root 2 with other updates: + const upsertWithOtherUpdates = [ + { + id: 1, + model1Relation1: { '#id': 'referenced', model1Prop1: 'referenced' }, + model1Relation1Inverse: { '#ref': 'referenced' }, + }, + { + id: 2, + model1Prop1: 'updated', + model1Relation1: { id: 3, model1Prop1: 'updated' }, + model1Relation2: [ + { idCol: 1, model2Prop1: 'updated' }, + { idCol: 2, model2Prop1: 'updated' }, + ], + }, + ]; + + it('should patch them one after the other', async () => { + const { maxRunning } = await upsertCountingUpdates({ + id: 1, + model1Relation1: { '#id': 'referenced', model1Prop1: 'referenced' }, + model1Relation1Inverse: { '#ref': 'referenced' }, + }); + + expect(maxRunning).toBe(1); + }); + + it('should patch them one after the other when a #ref{} writes the key', async () => { + const { maxRunning } = await upsertCountingUpdates( + { id: 1, '#id': 'root', model1Relation1: { id: 3, model1Id: '#ref{root.id}' } }, + { relate: true }, + ); + + expect(maxRunning).toBe(1); + const rows = await session.knex('Model1').whereIn('id', [1, 3]).orderBy('id'); + expect(rows.map((row) => row.model1Id)).toEqual([3, 1]); + }); + + it('should keep the concurrency of the other patches', async () => { + const { maxRunning } = await upsertCountingUpdates(upsertWithOtherUpdates); + expect(maxRunning).toBe(Math.min(Model1.getConcurrency(session.knex), 4)); + }); + + it('should wait for the started patches before it rejects', async () => { + const counts = await upsertCountingUpdates( + [ + upsertWithOtherUpdates[0], + { id: 2, model1Relation2: [{ idCol: 1, model2Prop1: 'updated' }] }, + ], + { shouldFail: (builder) => builder.modelClass().getTableName() === 'model2' }, + ); + + expect(counts).toMatchObject({ runningOnError: 0, startedOnError: 0 }); + }); + }); + describe('cyclic references', () => { it('should break cycles through #ref nodes by deferring the foreign key', async () => { const upsert = { @@ -5209,17 +6037,17 @@ module.exports = (session) => { .upsertGraph(upsert, { fetchStrategy, allowRefs: true }); const inserted = result.model1Relation1; - expect(inserted.id).to.be.a('number'); - expect(inserted.model1Id).to.equal(inserted.id); + expect(inserted.id).toBeTypeOf('number'); + expect(inserted.model1Id).toBe(inserted.id); const fetched = await Model1.query(session.knex) .findById(1) .withGraphFetched('model1Relation1.model1Relation1'); - expect(fetched.model1Id).to.equal(inserted.id); - expect(fetched.model1Relation1.model1Prop1).to.equal('self reference'); - expect(fetched.model1Relation1.model1Id).to.equal(inserted.id); - expect(fetched.model1Relation1.model1Relation1.id).to.equal(inserted.id); + expect(fetched.model1Id).toBe(inserted.id); + expect(fetched.model1Relation1.model1Prop1).toBe('self reference'); + expect(fetched.model1Relation1.model1Id).toBe(inserted.id); + expect(fetched.model1Relation1.model1Relation1.id).toBe(inserted.id); }); it('cycle detection should consider already inserted nodes', () => { @@ -5258,7 +6086,7 @@ module.exports = (session) => { }) .then((result) => { const id = result.model1Relation1.model1Relation1.id; - chai.expect(result).containSubset({ + expect(result).toMatchObject({ id: 2, model1Relation1: { id: 3, @@ -5299,7 +6127,7 @@ module.exports = (session) => { return Model2.query(trx) .upsertGraph(upsert, { fetchStrategy }) .then((result) => { - expect(result.model2Relation1[2].aliasedExtra).to.equal('foo'); + expect(result.model2Relation1[2].aliasedExtra).toBe('foo'); }); }) .then(() => { @@ -5308,7 +6136,7 @@ module.exports = (session) => { .withGraphFetched('model2Relation1(orderById)'); }) .then((model) => { - expect(model.model2Relation1[2].aliasedExtra).to.equal('foo'); + expect(model.model2Relation1[2].aliasedExtra).toBe('foo'); }); }); @@ -5334,8 +6162,8 @@ module.exports = (session) => { return Model2.query(trx) .upsertGraph(upsert, { relate: true, fetchStrategy }) .then((result) => { - expect(result.model2Relation1[0].id).to.equal(5); - expect(result.model2Relation1[0].aliasedExtra).to.equal('foo'); + expect(result.model2Relation1[0].id).toBe(5); + expect(result.model2Relation1[0].aliasedExtra).toBe('foo'); }); }) .then(() => { @@ -5344,11 +6172,119 @@ module.exports = (session) => { .withGraphFetched('model2Relation1(orderById)'); }) .then((model) => { - expect(model.model2Relation1[0].id).to.equal(5); - expect(model.model2Relation1[0].aliasedExtra).to.equal('foo'); + expect(model.model2Relation1[0].id).toBe(5); + expect(model.model2Relation1[0].aliasedExtra).toBe('foo'); }); }); + it('reject a model placed twice with other extras', async () => { + const joinRows = await session.knex('Model1Model2').orderBy('id'); + + await expect( + Model2.query(session.knex).upsertGraph( + { + idCol: 2, + model2Relation1: [ + { id: 5, aliasedExtra: 'foo' }, + { id: 5, aliasedExtra: 'bar' }, + ], + }, + { relate: true, fetchStrategy }, + ), + ).rejects.toThrow( + "the graph gives aliasedExtra of one join row of model2Relation1 two values, at 'model2Relation1[0].aliasedExtra' and at 'model2Relation1[1].aliasedExtra'", + ); + expect(await session.knex('Model1Model2').orderBy('id')).toEqual(joinRows); + }); + + it('relate a model placed twice with one join row', async () => { + const model = await Model2.query(session.knex) + .findById(2) + .withGraphFetched('model2Relation1(orderById)'); + + await Model2.query(session.knex).upsertGraph( + { + idCol: 2, + model2Relation1: [ + ...model.model2Relation1.map(({ id }) => ({ id })), + { id: 5, aliasedExtra: 'foo' }, + { id: 5, model1Prop1: 'changed' }, + ], + }, + { relate: true, fetchStrategy }, + ); + + const rows = await session + .knex('Model1Model2') + .select('extra3') + .where({ model2Id: 2, model1Id: 5 }); + expect(rows).toEqual([{ extra3: 'foo' }]); + expect((await Model1.query(session.knex).findById(5)).model1Prop1).toBe('changed'); + }); + + it('relate a model placed with a string and a number id with one join row', async () => { + const model = await Model2.query(session.knex) + .findById(2) + .withGraphFetched('model2Relation1(orderById)'); + + await Model2.query(session.knex).upsertGraph( + { + idCol: 2, + model2Relation1: [ + ...model.model2Relation1.map(({ id }) => ({ id })), + { id: 5, aliasedExtra: 'foo' }, + { id: '5' }, + ], + }, + { relate: true, fetchStrategy }, + ); + + expect( + await session.knex('Model1Model2').select('extra3').where({ model2Id: 2, model1Id: 5 }), + ).toEqual([{ extra3: 'foo' }]); + }); + + it('update the extras of models that an earlier upsert patched', async () => { + const model = await Model2.query(session.knex) + .findById(2) + .withGraphFetched('model2Relation1(orderById)'); + model.model2Relation1[0].model1Prop1 = 'changed'; + await Model2.query(session.knex).upsertGraph(model, { fetchStrategy }); + model.model2Relation1[0].aliasedExtra = 'changed extra'; + await Model2.query(session.knex).upsertGraph(model, { fetchStrategy }); + + const row = await session + .knex('Model1Model2') + .where({ model2Id: 2, model1Id: model.model2Relation1[0].id }) + .first(); + expect(row.extra3).toBe('changed extra'); + }); + + it('update the extras of all join rows of a pair', async () => { + const { id } = ( + await Model2.query(session.knex) + .findById(2) + .withGraphFetched('model2Relation1(orderById)') + ).model2Relation1[0]; + // A second join row of the pair, written by something else than the graph: + const [joinRow] = await session.knex('Model1Model2').where({ model2Id: 2, model1Id: id }); + await session.knex('Model1Model2').insert({ ...joinRow, id: undefined }); + + const model = await Model2.query(session.knex) + .findById(2) + .withGraphFetched('model2Relation1(orderById)'); + for (const related of model.model2Relation1.filter((related) => related.id === id)) { + related.aliasedExtra = 'changed extra'; + } + await Model2.query(session.knex).upsertGraph(model, { fetchStrategy }); + + const rows = await session + .knex('Model1Model2') + .select('extra3') + .where({ model2Id: 2, model1Id: id }); + expect(rows).toEqual([{ extra3: 'changed extra' }, { extra3: 'changed extra' }]); + }); + it('update', () => { const upsert = { idCol: 2, @@ -5377,17 +6313,15 @@ module.exports = (session) => { return Model2.query(trx) .upsertGraph(upsert, { fetchStrategy }) .then((result) => { - expect(result.model2Relation1[0].aliasedExtra).to.equal('hello extra 1'); - expect(result.model2Relation1[1].aliasedExtra).to.equal('hello extra 2'); + expect(result.model2Relation1[0].aliasedExtra).toBe('hello extra 1'); + expect(result.model2Relation1[1].aliasedExtra).toBe('hello extra 2'); if (session.isPostgres()) { // Only the join rows are patched, by the ids of both ends. - chai - .expect(sql.filter((query) => query.startsWith('update')).sort()) - .to.eql([ - 'update "Model1Model2" set "extra3" = \'hello extra 1\' where "Model1Model2"."model2Id" in (2) and "Model1Model2"."model1Id" = 6', - 'update "Model1Model2" set "extra3" = \'hello extra 2\' where "Model1Model2"."model2Id" in (2) and "Model1Model2"."model1Id" = 7', - ]); + expect(sql.filter((query) => query.startsWith('update')).sort()).toEqual([ + 'update "Model1Model2" set "extra3" = \'hello extra 1\' where "Model1Model2"."model2Id" in (2) and "Model1Model2"."model1Id" = 6', + 'update "Model1Model2" set "extra3" = \'hello extra 2\' where "Model1Model2"."model2Id" in (2) and "Model1Model2"."model1Id" = 7', + ]); } }); }) @@ -5397,8 +6331,8 @@ module.exports = (session) => { .withGraphFetched('model2Relation1(orderById)'); }) .then((model) => { - expect(model.model2Relation1[0].aliasedExtra).to.equal('hello extra 1'); - expect(model.model2Relation1[1].aliasedExtra).to.equal('hello extra 2'); + expect(model.model2Relation1[0].aliasedExtra).toBe('hello extra 1'); + expect(model.model2Relation1[1].aliasedExtra).toBe('hello extra 2'); }); }); }); @@ -5406,14 +6340,14 @@ module.exports = (session) => { describe('modifying properties in $beforeUpdate (#2233)', () => { let $beforeUpdate; - before(() => { + beforeAll(() => { $beforeUpdate = Model1.prototype.$beforeUpdate; Model1.prototype.$beforeUpdate = function () { this.model1Prop1 = 'updated in before update'; }; }); - after(() => { + afterAll(() => { Model1.prototype.$beforeUpdate = $beforeUpdate; }); @@ -5427,13 +6361,13 @@ module.exports = (session) => { return Model1.query(trx).upsertGraph(upsert, { fetchStrategy }); }) .then((res) => { - expect(res.model1Prop1).to.equal('updated in before update'); - expect(res.model1Prop2).to.equal(101); + expect(res.model1Prop1).toBe('updated in before update'); + expect(res.model1Prop2).toBe(101); return Model1.query(session.knex).findById(1); }) .then((model) => { - expect(model.model1Prop1).to.equal('updated in before update'); - expect(model.model1Prop2).to.equal(101); + expect(model.model1Prop1).toBe('updated in before update'); + expect(model.model1Prop2).toBe(101); }); }); }); @@ -5441,11 +6375,11 @@ module.exports = (session) => { describe('should not call onError() with internal exception (#2603)', () => { let query; - before(() => { + beforeAll(() => { query = Model1.query; }); - after(() => { + afterAll(() => { Model1.query = query; }); @@ -5462,7 +6396,7 @@ module.exports = (session) => { await transaction(session.knex, (trx) => { return Model1.query(trx).upsertGraph(upsert, { fetchStrategy }); }); - expect(error).to.equal(null); + expect(error).toBeNull(); }); }); @@ -5477,8 +6411,25 @@ module.exports = (session) => { return Model1.query(trx) .upsertGraph(upsert, { relate: true, fetchStrategy }) .then((result) => { - expect(result.$beforeUpdateCalled).to.equal(undefined); - expect(result.model1Relation1.$beforeUpdateCalled).to.equal(undefined); + expect(result.$beforeUpdateCalled).toBeUndefined(); + expect(result.model1Relation1.$beforeUpdateCalled).toBeUndefined(); + }); + }); + }); + + it('should not call $beforeUpdate() on models that only have a #id', async () => { + const upsert = { + id: 2, + '#id': 'root', + model1Relation1: { id: 3, '#id': 'related' }, + }; + + await transaction(session.knex, (trx) => { + return Model1.query(trx) + .upsertGraph(upsert, { relate: true, allowRefs: true, fetchStrategy }) + .then((result) => { + expect(result.$beforeUpdateCalled).toBeUndefined(); + expect(result.model1Relation1.$beforeUpdateCalled).toBeUndefined(); }); }); }); @@ -5539,7 +6490,7 @@ module.exports = (session) => { }) .returning('*') .then((result) => { - chai.expect(result).to.containSubset({ + expect(result).toContainSubset({ id: 2, model1Id: 3, $afterFindCalled: 1, diff --git a/tests/integration/viewsAndAliases.js b/tests/integration/viewsAndAliases.js index 2de75d63e..e85e1d525 100644 --- a/tests/integration/viewsAndAliases.js +++ b/tests/integration/viewsAndAliases.js @@ -1,11 +1,11 @@ -const utils = require('../../lib/utils/knexUtils'); -const expect = require('expect.js'); -const mockKnexFactory = require('../../testUtils/mockKnex'); -const { sortBy } = require('../../testUtils/testUtils'); +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import * as utils from '../../lib/utils/knexUtils.js'; +import mockKnexFactory from '../../testUtils/mockKnex.js'; +import { sortBy } from '../../testUtils/testUtils.js'; // This is another one of those features that need a separate test suite // because they are so pervasive. -module.exports = (session) => { +export default (session) => { const queries = []; const knex = mockKnexFactory(session.knex, function (mock, oldImpl, args) { @@ -184,7 +184,7 @@ module.exports = (session) => { ]; }); - before(() => { + beforeAll(() => { // This makes sure the columnInfo cache is populated. return Model1.query().findById(1).withGraphJoined('[model1Relation1, model1Relation2]'); }); @@ -197,7 +197,7 @@ module.exports = (session) => { .joinRelated('[model1Relation1, model1Relation2, model1Relation3]') .then((models) => { if (utils.isPostgres(session.knex)) { - expect(queries[0].replace(/\s/g, '')).to.equal( + expect(queries[0].replace(/\s/g, '')).toBe( ` select "someAlias".* from "Model1" as "someAlias" @@ -221,7 +221,7 @@ module.exports = (session) => { }) .then((model) => { if (session.isPostgres()) { - expect(queries).to.eql([ + expect(queries).toEqual([ 'select "foo".* from "Model1" as "foo" inner join "Model1" as "model1Relation1" on "model1Relation1"."id" = "foo"."model1Id" inner join "Model1" as "model1Relation1:model1Relation1" on "model1Relation1:model1Relation1"."id" = "model1Relation1"."model1Id" where "foo"."id" = 1', ]); } @@ -248,11 +248,11 @@ module.exports = (session) => { ]; expectedQueries.forEach((expectedQuery, i) => { - expect(queries[i]).to.match(expectedQuery); + expect(queries[i]).toMatch(expectedQuery); }); } - expect(model).to.eql(fullEagerResult[0]); + expect(model).toEqual(fullEagerResult[0]); }); }); @@ -276,11 +276,11 @@ module.exports = (session) => { ]; expectedQueries.forEach((expectedQuery, i) => { - expect(queries[i]).to.match(expectedQuery); + expect(queries[i]).toMatch(expectedQuery); }); } - expect(model).to.eql(fullEagerResult[0]); + expect(model).toEqual(fullEagerResult[0]); }); }); @@ -292,8 +292,8 @@ module.exports = (session) => { .then(sortEager) .then((model) => { if (utils.isPostgres(session.knex)) { - expect(queries.length).to.equal(1); - expect(queries[0].replace(/\s/g, '')).to.equal( + expect(queries.length).toBe(1); + expect(queries[0].replace(/\s/g, '')).toBe( ` select "someAlias"."id" as "id", @@ -341,26 +341,26 @@ module.exports = (session) => { ); } - expect(model).to.eql(fullEagerResult[0]); + expect(model).toEqual(fullEagerResult[0]); }); }); }); if (utils.isPostgres(session.knex)) { describe('views', () => { - before(() => { + beforeAll(() => { return session.knex.schema.raw(` create view "someView" as (select "Model1".*, "Model1"."model1Prop1" as "viewProp" from "Model1") `); }); - after(() => { + afterAll(() => { return session.knex.schema.raw(` drop view "someView" `); }); - before(() => { + beforeAll(() => { // This makes sure the columnInfo cache is populated. return Model1.query() .findById(1) @@ -375,7 +375,7 @@ module.exports = (session) => { .joinRelated('[model1Relation1, model1Relation2, model1Relation3]') .then((models) => { if (utils.isPostgres(session.knex)) { - expect(queries[0].replace(/\s/g, '')).to.equal( + expect(queries[0].replace(/\s/g, '')).toBe( ` select "someView".* from "someView" @@ -409,10 +409,10 @@ module.exports = (session) => { ]; expectedQueries.forEach((expectedQuery, i) => { - expect(queries[i]).to.match(expectedQuery); + expect(queries[i]).toMatch(expectedQuery); }); - expect(model).to.eql([ + expect(model).toEqual([ { id: 1, model1Id: 2, @@ -501,8 +501,8 @@ module.exports = (session) => { .withGraphJoined(fullEager) .then(sortEager) .then((model) => { - expect(queries.length).to.equal(1); - expect(queries[0].replace(/\s/g, '')).to.equal( + expect(queries.length).toBe(1); + expect(queries[0].replace(/\s/g, '')).toBe( ` select "someView"."id" as "id", @@ -554,11 +554,11 @@ module.exports = (session) => { ); // This makes sure, `Model1` and `someView` have different metadata. - expect(Array.from(Model1.$$tableMetadata.keys()).sort()).to.eql([ + expect(Array.from(Model1.$$tableMetadata.keys()).sort()).toEqual([ 'Model1', 'someView', ]); - expect(model).to.eql([ + expect(model).toEqual([ { id: 1, model1Id: 2, @@ -651,8 +651,8 @@ module.exports = (session) => { ) .then(sortEager) .then(() => { - expect(queries.length).to.equal(1); - expect(queries[0].replace(/\s/g, '')).to.equal( + expect(queries.length).toBe(1); + expect(queries[0].replace(/\s/g, '')).toBe( ` select "someView"."id" as "id", diff --git a/tests/integration/withGraph.js b/tests/integration/withGraph.js index 759c3b2c3..c91a6cb30 100644 --- a/tests/integration/withGraph.js +++ b/tests/integration/withGraph.js @@ -1,11 +1,10 @@ -const chai = require('chai'); -const expect = require('expect.js'); -const { ValidationError, raw, ref } = require('../..'); -const mockKnexFactory = require('../../testUtils/mockKnex'); -const { map: promiseMap } = require('../../lib/utils/promiseUtils'); -const { pick, range, sortBy } = require('../../testUtils/testUtils'); - -module.exports = (session) => { +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { ValidationError, raw, ref } from 'objection'; +import mockKnexFactory from '../../testUtils/mockKnex.js'; +import { map as promiseMap } from '../../lib/utils/promiseUtils/index.js'; +import { pick, range, sortBy } from '../../testUtils/testUtils.js'; + +export default (session) => { const Model1 = session.models.Model1; const Model2 = session.models.Model2; @@ -87,7 +86,7 @@ module.exports = (session) => { }); test('model1Relation1', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -104,12 +103,12 @@ module.exports = (session) => { }, ]); - expect(models[0]).to.be.a(Model1); - expect(models[0].model1Relation1).to.be.a(Model1); + expect(models[0]).toBeInstanceOf(Model1); + expect(models[0].model1Relation1).toBeInstanceOf(Model1); }); test({ model1Relation1: true }, (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -126,12 +125,12 @@ module.exports = (session) => { }, ]); - expect(models[0]).to.be.a(Model1); - expect(models[0].model1Relation1).to.be.a(Model1); + expect(models[0]).toBeInstanceOf(Model1); + expect(models[0].model1Relation1).toBeInstanceOf(Model1); }); test('model1Relation1(select:model1Prop1)', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -145,8 +144,8 @@ module.exports = (session) => { }, ]); - expect(models[0]).to.be.a(Model1); - expect(models[0].model1Relation1).to.be.a(Model1); + expect(models[0]).toBeInstanceOf(Model1); + expect(models[0].model1Relation1).toBeInstanceOf(Model1); }); test( @@ -156,7 +155,7 @@ module.exports = (session) => { }, }, (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -170,13 +169,13 @@ module.exports = (session) => { }, ]); - expect(models[0]).to.be.a(Model1); - expect(models[0].model1Relation1).to.be.a(Model1); + expect(models[0]).toBeInstanceOf(Model1); + expect(models[0].model1Relation1).toBeInstanceOf(Model1); }, ); test('model1Relation1.model1Relation1', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -202,7 +201,7 @@ module.exports = (session) => { }); test({ model1Relation1: { model1Relation1: {} } }, (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -228,7 +227,7 @@ module.exports = (session) => { }); test('model1Relation1.model1Relation1Inverse', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -256,7 +255,7 @@ module.exports = (session) => { test( 'model1Relation1.^', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -292,7 +291,7 @@ module.exports = (session) => { ); test('model1Relation1.^2', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -325,7 +324,7 @@ module.exports = (session) => { }, }, (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -355,7 +354,7 @@ module.exports = (session) => { test( 'model1Relation1(selectId).^', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -394,7 +393,7 @@ module.exports = (session) => { test( 'model1Relation1(selectId).^4', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -429,7 +428,7 @@ module.exports = (session) => { ); test('model1Relation2.model2Relation2', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -465,12 +464,12 @@ module.exports = (session) => { }, ]); - expect(models[0]).to.be.a(Model1); - expect(models[0].model1Relation2[0].model2Relation2).to.be.a(Model1); + expect(models[0]).toBeInstanceOf(Model1); + expect(models[0].model1Relation2[0].model2Relation2).toBeInstanceOf(Model1); }); test('model1Relation2.model2Relation2.model1Relation1', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -514,12 +513,12 @@ module.exports = (session) => { }, ]); - expect(models[0]).to.be.a(Model1); - expect(models[0].model1Relation2[0].model2Relation2.model1Relation1).to.be.a(Model1); + expect(models[0]).toBeInstanceOf(Model1); + expect(models[0].model1Relation2[0].model2Relation2.model1Relation1).toBeInstanceOf(Model1); }); test('[model1Relation1, model1Relation2]', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -554,14 +553,14 @@ module.exports = (session) => { }, ]); - expect(models[0]).to.be.a(Model1); - expect(models[0].model1Relation2[0]).to.be.a(Model2); + expect(models[0]).toBeInstanceOf(Model1); + expect(models[0].model1Relation2[0]).toBeInstanceOf(Model2); }); test( '[model1Relation1, model1Relation2(orderByDesc, selectProps)]', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -609,7 +608,7 @@ module.exports = (session) => { ); test('[model1Relation1, model1Relation2.model2Relation1]', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -666,7 +665,7 @@ module.exports = (session) => { }); test('[model1Relation2.model2Relation1, model1Relation1]', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -723,7 +722,7 @@ module.exports = (session) => { }); test('[model1Relation1, model1Relation2.model2Relation1.[model1Relation1, model1Relation2]]', (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -811,7 +810,7 @@ module.exports = (session) => { ] ]`, (models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -891,9 +890,7 @@ module.exports = (session) => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( - 'unknown relation "model1Relation111" in a relation expression', - ); + expect(err.message).toBe('unknown relation "model1Relation111" in a relation expression'); }); }); @@ -907,7 +904,7 @@ module.exports = (session) => { ]; }); - const fetchModel1s = (expr, modify, opt = { limitPerParent: true }) => + const fetchModel1s = (expr, modify, opt) => Model1.query() .withGraphFetched(expr, opt) .whereExists(Model1.relatedQuery('model1Relation2')) @@ -919,21 +916,21 @@ module.exports = (session) => { query.orderBy('id_col', 'desc').limit(1), ); - expect(relatedIds(models, 'model1Relation2')).to.eql([ + expect(relatedIds(models, 'model1Relation2')).toEqual([ [1, [2]], [4, [4]], [6, [3]], ]); }); - it('should apply limit() to all parents without the option', async () => { + it('should apply limit() to all parents with limitPerParent: false', async () => { const models = await fetchModel1s( 'model1Relation2', (query) => query.orderBy('id_col', 'desc').limit(1), - {}, + { limitPerParent: false }, ); - expect(relatedIds(models, 'model1Relation2')).to.eql([ + expect(relatedIds(models, 'model1Relation2')).toEqual([ [1, []], [4, [4]], [6, []], @@ -945,7 +942,7 @@ module.exports = (session) => { query.orderBy('id_col', 'desc').limit(1).offset(1), ); - expect(relatedIds(models, 'model1Relation2')).to.eql([ + expect(relatedIds(models, 'model1Relation2')).toEqual([ [1, [1]], [4, []], [6, []], @@ -957,7 +954,7 @@ module.exports = (session) => { query.orderBy('id_col', 'desc').first(), ); - expect(relatedIds(models, 'model1Relation2')).to.eql([ + expect(relatedIds(models, 'model1Relation2')).toEqual([ [1, [2]], [4, [4]], [6, [3]], @@ -969,7 +966,7 @@ module.exports = (session) => { query.orderByRaw('case when id_col = ? then 0 else 1 end, id_col desc', [1]).limit(1), ); - expect(relatedIds(models, 'model1Relation2')).to.eql([ + expect(relatedIds(models, 'model1Relation2')).toEqual([ [1, [1]], [4, [4]], [6, [3]], @@ -984,12 +981,12 @@ module.exports = (session) => { query.orderBy('model2_prop1', 'asc', nulls).limit(1), ); - expect(relatedIds(await fetchFirst('first'), 'model1Relation2')).to.eql([ + expect(relatedIds(await fetchFirst('first'), 'model1Relation2')).toEqual([ [1, [2]], [4, [4]], [6, [3]], ]); - expect(relatedIds(await fetchFirst('last'), 'model1Relation2')).to.eql([ + expect(relatedIds(await fetchFirst('last'), 'model1Relation2')).toEqual([ [1, [1]], [4, [4]], [6, [3]], @@ -1001,7 +998,7 @@ module.exports = (session) => { query.orderBy('id_col', 'desc').onBuildKnex((knexQuery) => knexQuery.limit(1)), ); - expect(relatedIds(models, 'model1Relation2')).to.eql([ + expect(relatedIds(models, 'model1Relation2')).toEqual([ [1, [2]], [4, [4]], [6, [3]], @@ -1013,7 +1010,7 @@ module.exports = (session) => { query.orderBy('id_col', 'desc').limit(null), ); - expect(relatedIds(models, 'model1Relation2')).to.eql([ + expect(relatedIds(models, 'model1Relation2')).toEqual([ [1, [2, 1]], [4, [4]], [6, [3]], @@ -1025,7 +1022,7 @@ module.exports = (session) => { query.orderBy('id_col', 'desc').range(0, 0).limit(2), ); - expect(relatedIds(models, 'model1Relation2')).to.eql([ + expect(relatedIds(models, 'model1Relation2')).toEqual([ [1, []], [4, [4]], [6, [3]], @@ -1045,8 +1042,8 @@ module.exports = (session) => { error = err; } - expect(error).to.be.a(Error); - expect(error.message).to.contain('Defined query timeout of 50ms exceeded'); + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain('Defined query timeout of 50ms exceeded'); }); } @@ -1055,13 +1052,13 @@ module.exports = (session) => { query.orderBy('id_col', 'desc').limit(2), ); - expect(relatedIds(models, 'model1Relation2')).to.eql([ + expect(relatedIds(models, 'model1Relation2')).toEqual([ [1, [2, 1]], [4, [4]], [6, [3]], ]); - expect(models[0].model1Relation2[0]).to.be.a(Model2); - expect(models[0].model1Relation2[0]).to.not.have.property('objectiontmprownum'); + expect(models[0].model1Relation2[0]).toBeInstanceOf(Model2); + expect(models[0].model1Relation2[0]).not.toHaveProperty('objectiontmprownum'); }); it('should fetch nested relations of the limited models', async () => { @@ -1069,12 +1066,12 @@ module.exports = (session) => { query.orderBy('id_col', 'desc').limit(1), ).modifyGraph('model1Relation2.model2Relation1', (query) => query.orderBy('id')); - expect(relatedIds(models, 'model1Relation2')).to.eql([ + expect(relatedIds(models, 'model1Relation2')).toEqual([ [1, [2]], [4, [4]], [6, [3]], ]); - expect(relatedIds(models[0].model1Relation2, 'model2Relation1', 'id')).to.eql([ + expect(relatedIds(models[0].model1Relation2, 'model2Relation1', 'id')).toEqual([ [2, [5, 6]], ]); }); @@ -1085,16 +1082,16 @@ module.exports = (session) => { const models = await Model2.query() .whereIn('id_col', [1, 2, 3]) - .withGraphFetched('model2Relation1', { limitPerParent: true }) + .withGraphFetched('model2Relation1') .modifyGraph('model2Relation1', (query) => query.orderBy('id', 'desc').limit(1)) .orderBy('id_col'); - expect(relatedIds(models, 'model2Relation1', 'id')).to.eql([ + expect(relatedIds(models, 'model2Relation1', 'id')).toEqual([ [1, [5]], [2, [6]], [3, []], ]); - expect(models[1].model2Relation1[0].aliasedExtra).to.equal('extra 6'); + expect(models[1].model2Relation1[0].aliasedExtra).toBe('extra 6'); }); // SQLite can't select `schema.table.*`. @@ -1156,7 +1153,7 @@ module.exports = (session) => { ) .orderBy('id'); - expect(relatedIds(model1s, 'model1Relation2')).to.eql([ + expect(relatedIds(model1s, 'model1Relation2')).toEqual([ [1, [2]], [4, [4]], [6, [3]], @@ -1169,7 +1166,7 @@ module.exports = (session) => { .modifyGraph('model2Relation1', (query) => query.orderBy('id', 'desc').limit(1)) .orderBy('id_col'); - expect(relatedIds(model2s, 'model2Relation1', 'id')).to.eql([ + expect(relatedIds(model2s, 'model2Relation1', 'id')).toEqual([ [1, []], [2, [6]], [3, []], @@ -1193,21 +1190,60 @@ module.exports = (session) => { } const models = await Model1Latest.query() - .withGraphFetched('latestModel2', { limitPerParent: true }) + .withGraphFetched('latestModel2') .whereExists(Model1Latest.relatedQuery('latestModel2')) .orderBy('id'); - expect(relatedIds(models, 'latestModel2')).to.eql([ + expect(relatedIds(models, 'latestModel2')).toEqual([ [1, 2], [4, 4], [6, 3], ]); }); - it('should support limitPerParent in defaultGraphOptions', async () => { + it('should apply first() of a relation mapping modify per parent', async () => { + class Model1Latest extends Model1 { + static get relationMappings() { + return { + ...super.relationMappings, + latestModel2: { + relation: Model1.HasOneRelation, + modelClass: Model2, + join: { from: 'Model1.id', to: 'model2.model1_id' }, + modify: (query) => query.orderBy('id_col', 'desc').first(), + }, + }; + } + } + + const models = await Model1Latest.query() + .withGraphFetched('latestModel2') + .whereExists(Model1Latest.relatedQuery('model1Relation2')) + .orderBy('id'); + + expect(relatedIds(models, 'latestModel2')).toEqual([ + [1, 2], + [4, 4], + [6, 3], + ]); + }); + + it('should apply first() of an onBuild() hook per parent', async () => { + const models = await fetchModel1s('model1Relation2', (query) => + query.orderBy('id_col', 'desc').onBuild((query) => query.first()), + ); + + expect(relatedIds(models, 'model1Relation2')).toEqual([ + [1, [2]], + [4, [4]], + [6, [3]], + ]); + }); + + it('should support limitPerParent: false in defaultGraphOptions', async () => { class Model1Defaults extends Model1 { static get defaultGraphOptions() { - return { limitPerParent: true }; + return { limitPerParent: false }; } } @@ -1217,103 +1253,12 @@ module.exports = (session) => { .modifyGraph('model1Relation2', (query) => query.orderBy('id_col', 'desc').limit(1)) .orderBy('id'); - expect(relatedIds(models, 'model1Relation2')).to.eql([ - [1, [2]], + expect(relatedIds(models, 'model1Relation2')).toEqual([ + [1, []], [4, [4]], - [6, [3]], + [6, []], ]); }); - - if (session.isMySql()) { - describe('without window functions', () => { - let knex; - let queries; - - // Simulate MySQL 5.7, as knex only detects the server's version - // if it isn't configured. - const createModel = (ModelClass) => - ModelClass.bindKnex( - mockKnexFactory(knex, function (mock, oldImpl, args) { - queries.push(this.toSQL().sql); - return oldImpl.apply(this, args); - }), - ); - - before(() => { - knex = require('knex')({ ...session.opt.knexConfig, version: '5.7.0' }); - }); - - after(() => { - return knex.destroy(); - }); - - beforeEach(() => { - queries = []; - }); - - it('should fetch the related models of each parent separately', async () => { - const TestModel = createModel(Model1); - const models = await TestModel.query() - .withGraphFetched('model1Relation2', { limitPerParent: true }) - .whereExists(TestModel.relatedQuery('model1Relation2')) - .modifyGraph('model1Relation2', (query) => query.orderBy('id_col', 'desc').first()) - .orderBy('id'); - - expect(relatedIds(models, 'model1Relation2')).to.eql([ - [1, [2]], - [4, [4]], - [6, [3]], - ]); - expect(queries).to.have.length(4); - expect(queries.join()).to.not.contain('row_number'); - }); - - it('should fetch the related models of each parent separately for a relation mapping modify', async () => { - class Model1Latest extends Model1 { - static get relationMappings() { - return { - ...super.relationMappings, - latestModel2: { - relation: Model1.HasOneRelation, - modelClass: Model2, - join: { from: 'Model1.id', to: 'model2.model1_id' }, - modify: (query) => query.orderBy('id_col', 'desc').limit(1), - }, - }; - } - } - - const TestModel = createModel(Model1Latest); - const models = await TestModel.query() - .withGraphFetched('latestModel2', { limitPerParent: true }) - .whereExists(TestModel.relatedQuery('latestModel2')) - .orderBy('id'); - - expect(relatedIds(models, 'latestModel2')).to.eql([ - [1, 2], - [4, 4], - [6, 3], - ]); - expect(queries).to.have.length(4); - }); - - it('should fetch the related models of all parents together without a limit', async () => { - const TestModel = createModel(Model1); - const models = await TestModel.query() - .withGraphFetched('model1Relation2', { limitPerParent: true }) - .whereExists(TestModel.relatedQuery('model1Relation2')) - .modifyGraph('model1Relation2', (query) => query.orderBy('id_col', 'desc')) - .orderBy('id'); - - expect(relatedIds(models, 'model1Relation2')).to.eql([ - [1, [2, 1]], - [4, [4]], - [6, [3]], - ]); - expect(queries).to.have.length(2); - }); - }); - } }); it('setting maxBatchSize option to 1 should cause relations to be fetched naively to each parent separately', () => { @@ -1326,7 +1271,7 @@ module.exports = (session) => { }) .orderBy('id') .then((result) => { - expect(result).to.eql([ + expect(result).toEqual([ { id: 1, model1Id: 2, @@ -1379,6 +1324,53 @@ module.exports = (session) => { }); }); + it('should keep an aggregate aliased as the join column (#111)', async () => { + const models = await Model1.query() + .max('model1Id as model1Id') + .count('* as count') + .whereNotNull('model1Id') + .groupBy('model1Id') + .orderBy('model1Id') + .withGraphFetched('model1Relation1'); + + expect( + models.map((model) => [model.model1Id, Number(model.count), model.model1Relation1.id]), + ).toEqual([ + [2, 1, 2], + [3, 1, 3], + [4, 1, 4], + [7, 1, 7], + [9, 1, 9], + ]); + }); + + it('should keep raw and subquery selections aliased as the join column (#111)', async () => { + const selections = [ + raw('?? as ??', ['Model1.model1Id', 'model1Id']), + Model1.query() + .alias('m') + .select('m.model1Id') + .whereColumn('m.id', 'Model1.id') + .as('model1Id'), + ]; + + for (const selection of selections) { + const models = await Model1.query() + .select('id', selection) + .whereNotNull('model1Id') + .orderBy('id') + .withGraphFetched('model1Relation1'); + + expect(models.map((model) => [model.model1Id, model.model1Relation1.id])).toEqual([ + [2, 2], + [3, 3], + [4, 4], + [7, 7], + [9, 9], + ]); + } + }); + it('should work with zero id', () => { return promiseMap( ['withGraphFetched', 'withGraphJoined'], @@ -1415,7 +1407,7 @@ module.exports = (session) => { [method]('[model1Relation1, model1Relation2.model2Relation1]'); }) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 0, model1Prop1: 'hello 0', @@ -1471,7 +1463,7 @@ module.exports = (session) => { qb.select('Model1.id').orderBy('Model1.id'), ) .then((res) => { - expect(res).to.eql({ + expect(res).toEqual({ id: 1, model1Id: 2, $afterFindCalled: 1, @@ -1498,12 +1490,10 @@ module.exports = (session) => { { id: 5, $afterFindCalled: 1, - objectiontmpjoin0: 2, }, { id: 6, $afterFindCalled: 1, - objectiontmpjoin0: 2, }, ], }, @@ -1518,8 +1508,8 @@ module.exports = (session) => { .withGraphFetched('[model1Relation1, model1Relation2]') .range(0, 0) .then((res) => { - expect(res.results[0].model1Relation1.id).to.equal(2); - expect(res.results[0].model1Relation2).to.have.length(2); + expect(res.results[0].model1Relation1.id).toBe(2); + expect(res.results[0].model1Relation2).toHaveLength(2); }); }); @@ -1529,7 +1519,7 @@ module.exports = (session) => { .withGraphJoined('model1Relation1') .range(0, 0) .then((res) => { - expect(res.results[0].model1Relation1.id).to.equal(2); + expect(res.results[0].model1Relation1.id).toBe(2); }); }); @@ -1541,28 +1531,27 @@ module.exports = (session) => { .withGraphJoined('model1Relation2') .resultSize() .then((size) => { - expect(size).to.equal(2); + expect(size).toBe(2); }); }); - it('resultSize should support aliased selections in groupBy and having with joinEager', function () { - // Postgres and SQL Server don't support selection aliases in `having`. - if (session.isPostgres() || session.isMsSql()) { - return this.skip(); - } - - const query = Model1.query() - .select('Model1.id', 'Model1.model1Prop1 as prop') - .whereIn('Model1.id', [1, 6]) - .withGraphJoined('model1Relation2') - .groupBy('Model1.id', 'model1Relation2.id_col') - .having('prop', '=', 'hello 1'); + it.skipIf(session.isPostgres() || session.isMsSql())( + 'resultSize should support aliased selections in groupBy and having with joinEager', + () => { + // Postgres and SQL Server don't support selection aliases in `having`. + const query = Model1.query() + .select('Model1.id', 'Model1.model1Prop1 as prop') + .whereIn('Model1.id', [1, 6]) + .withGraphJoined('model1Relation2') + .groupBy('Model1.id', 'model1Relation2.id_col') + .having('prop', '=', 'hello 1'); - return Promise.all([query, query.resultSize()]).then(([models, size]) => { - expect(models.map((it) => it.id)).to.eql([1]); - expect(size).to.equal(1); - }); - }); + return Promise.all([query, query.resultSize()]).then(([models, size]) => { + expect(models.map((it) => it.id)).toEqual([1]); + expect(size).toBe(1); + }); + }, + ); it('resultSize should support a column selected twice with groupBy and joinEager', async () => { const query = Model1.query() @@ -1586,89 +1575,86 @@ module.exports = (session) => { ); const [models, size] = await Promise.all([query, query.resultSize()]); - expect(models.map((it) => it.id).sort()).to.eql([1, 6]); - expect(size).to.equal(2); + expect(models.map((it) => it.id).sort()).toEqual([1, 6]); + expect(size).toBe(2); }); - it('resultSize should support an aliased selection in groupBy with joinEager', async function () { - // SQL Server doesn't support selection aliases in `groupBy`. - if (session.isMsSql()) { - return this.skip(); - } - - const query = Model1.query() - .select('Model1.id', 'Model1.model1Prop1 as prop') - .whereIn('Model1.id', [1, 6]) - .withGraphJoined('model1Relation2') - .groupBy('Model1.id', 'prop', 'model1Relation2.id_col'); + it.skipIf(session.isMsSql())( + 'resultSize should support an aliased selection in groupBy with joinEager', + async () => { + // SQL Server doesn't support selection aliases in `groupBy`. + const query = Model1.query() + .select('Model1.id', 'Model1.model1Prop1 as prop') + .whereIn('Model1.id', [1, 6]) + .withGraphJoined('model1Relation2') + .groupBy('Model1.id', 'prop', 'model1Relation2.id_col'); - const [models, size] = await Promise.all([query, query.resultSize()]); - expect(models.map((it) => it.id).sort()).to.eql([1, 6]); - expect(size).to.equal(2); - }); + const [models, size] = await Promise.all([query, query.resultSize()]); + expect(models.map((it) => it.id).sort()).toEqual([1, 6]); + expect(size).toBe(2); + }, + ); - it('resultSize and page should respect distinctOn with joinEager', async function () { - if (!session.isPostgres()) { - return this.skip(); - } + it.skipIf(!session.isPostgres())( + 'resultSize and page should respect distinctOn with joinEager', + async () => { + // `model1Prop2` is null for all models, so `distinctOn` returns one. + for (const relation of ['model1Relation1', 'model1Relation2']) { + const query = Model1.query() + .distinctOn('Model1.model1Prop2') + .withGraphJoined(relation) + .orderBy('Model1.model1Prop2') + .orderBy('Model1.id'); + + const [models, size, page] = await Promise.all([ + query, + query.resultSize(), + query.clone().page(0, 10), + ]); + expect(models.map((it) => it.id)).toEqual([1]); + expect(size).toBe(1); + expect(page.total).toBe(1); + } + }, + ); - // `model1Prop2` is null for all models, so `distinctOn` returns one. - for (const relation of ['model1Relation1', 'model1Relation2']) { - const query = Model1.query() - .distinctOn('Model1.model1Prop2') - .withGraphJoined(relation) - .orderBy('Model1.model1Prop2') - .orderBy('Model1.id'); - - const [models, size, page] = await Promise.all([ - query, - query.resultSize(), - query.clone().page(0, 10), + it.skipIf(!session.isPostgres())( + 'resultSize and page should respect the order of distinctOn on a joined column with joinEager', + async () => { + await session.populate([ + { + id: 1, + model1Relation2: [ + { idCol: 1, model2Prop1: 'a' }, + { idCol: 2, model2Prop1: 'b' }, + ], + }, + { + id: 2, + model1Relation2: [{ idCol: 3, model2Prop1: 'b' }], + }, ]); - expect(models.map((it) => it.id)).to.eql([1]); - expect(size).to.equal(1); - expect(page.total).to.equal(1); - } - }); - - it('resultSize and page should respect the order of distinctOn on a joined column with joinEager', async function () { - if (!session.isPostgres()) { - return this.skip(); - } - await session.populate([ - { - id: 1, - model1Relation2: [ - { idCol: 1, model2Prop1: 'a' }, - { idCol: 2, model2Prop1: 'b' }, - ], - }, - { - id: 2, - model1Relation2: [{ idCol: 3, model2Prop1: 'b' }], - }, - ]); - - // The order decides whether 'b' picks the row of model 1 or model 2, and - // without it, `distinctOn` picks the same row in both directions. - for (const direction of ['asc', 'desc']) { - const query = Model1.query() - .distinctOn('model1Relation2.model2_prop1') - .withGraphJoined('model1Relation2') - .orderBy('model1Relation2.model2_prop1') - .orderBy('Model1.id', direction); + // The order decides whether 'b' picks the row of model 1 or model 2, and + // without it, `distinctOn` picks the same row in both directions. + for (const direction of ['asc', 'desc']) { + const query = Model1.query() + .distinctOn('model1Relation2.model2_prop1') + .withGraphJoined('model1Relation2') + .orderBy('model1Relation2.model2_prop1') + .orderBy('Model1.id', direction); - const [models, size, page] = await Promise.all([ - query, - query.resultSize(), - query.clone().page(0, 10), - ]); - expect(models.map((it) => it.id).sort()).to.eql(direction === 'asc' ? [1] : [1, 2]); - expect(size).to.equal(models.length); - expect(page.total).to.equal(models.length); - } - }); + const [models, size, page] = await Promise.all([ + query, + query.resultSize(), + query.clone().page(0, 10), + ]); + expect(models.map((it) => it.id).sort()).toEqual(direction === 'asc' ? [1] : [1, 2]); + expect(size).toBe(models.length); + expect(page.total).toBe(models.length); + } + }, + ); it('page should count the root models with joinEager', () => { return Model1.query() @@ -1677,10 +1663,10 @@ module.exports = (session) => { .orderBy('Model1.id') .page(0, 10) .then((res) => { - expect(res.total).to.equal(2); - expect(res.results.map((it) => it.id)).to.eql([1, 6]); - expect(res.results[0].model1Relation2).to.have.length(2); - expect(res.results[1].model1Relation2).to.have.length(1); + expect(res.total).toBe(2); + expect(res.results.map((it) => it.id)).toEqual([1, 6]); + expect(res.results[0].model1Relation2).toHaveLength(2); + expect(res.results[1].model1Relation2).toHaveLength(1); }); }); @@ -1693,7 +1679,7 @@ module.exports = (session) => { }) .findOne({ model1Prop1: 'hello 1' }) .then((result) => { - chai.expect(result).to.containSubset({ + expect(result).toMatchObject({ model1Prop1: 'hello 1', model1Relation1: { @@ -1711,7 +1697,7 @@ module.exports = (session) => { .where('Model1.model1Prop1', 'hello 1') .first() .then((result) => { - chai.expect(result).to.containSubset({ + expect(result).toMatchObject({ model1Prop1: 'hello 1', model1Relation1: { @@ -1726,7 +1712,7 @@ module.exports = (session) => { .modifyGraph('foo', () => {}) .findOne({ model1Prop1: 'hello 1' }) .then((result) => { - expect(result.model1Prop1).to.equal('hello 1'); + expect(result.model1Prop1).toBe('hello 1'); }); }); @@ -1802,7 +1788,7 @@ module.exports = (session) => { }, }); - expect(result).to.eql([ + expect(result).toEqual([ { idCol: 1, model1Id: 1, @@ -1858,11 +1844,7 @@ module.exports = (session) => { }); } - it('distinctOn should not select its columns', async function () { - if (!session.isPostgres()) { - return this.skip(); - } - + it.skipIf(!session.isPostgres())('distinctOn should not select its columns', async () => { for (const method of ['withGraphFetched', 'withGraphJoined']) { const [{ model1Relation2, ...model }] = await Model1.query() .distinctOn('Model1.model1Prop1') @@ -1874,15 +1856,15 @@ module.exports = (session) => { }, }); - expect(model).to.eql({ + expect(model).toEqual({ id: 1, model1Id: 2, model1Prop1: 'hello 1', model1Prop2: null, $afterFindCalled: 1, }); - expect(model1Relation2).to.have.length(1); - expect(Object.keys(model1Relation2[0]).sort()).to.eql([ + expect(model1Relation2).toHaveLength(1); + expect(Object.keys(model1Relation2[0]).sort()).toEqual([ '$afterFindCalled', 'idCol', 'model1Id', @@ -1892,15 +1874,48 @@ module.exports = (session) => { } }); - // Throws in objection 4.0, see ditojs/objection#53. - it.skip('should fail with a clear error when a duplicate relation is detected', () => { - expect(() => { - Model1.query().withGraphFetched('[model1Relation1, model1Relation1.model1Relation2]'); - }).to.throwException((err) => { - expect(err.message).to.equal( - `Duplicate relation name "model1Relation1" in relation expression "[model1Relation1, model1Relation1.model1Relation2]". Use "a.[b, c]" instead of "[a.b, a.c]".`, + for (const method of ['withGraphFetched', 'withGraphJoined', 'allowGraph']) { + it(`${method}() should fail with a clear error when a duplicate relation is detected`, () => { + const expression = '[model1Relation1, model1Relation1.model1Relation2]'; + + expect(() => Model1.query()[method](expression)).toThrow( + expect.objectContaining({ + constructor: ValidationError, + type: 'RelationExpression', + message: + `Invalid relation expression "${expression}": Duplicate relation name ` + + '"model1Relation1". For example, use "model1Relation1.[foo, bar]" instead of ' + + '"[model1Relation1.foo, model1Relation1.bar]".', + }), ); }); + } + + for (const method of ['withGraphFetched', 'withGraphJoined', 'allowGraph']) { + it(`${method}() should fail with a clear error for relations after ^`, () => { + const expression = 'model1Relation1.^.model1Relation2'; + + expect(() => Model1.query()[method](expression)).toThrow( + expect.objectContaining({ + constructor: ValidationError, + type: 'RelationExpression', + message: + `Invalid relation expression "${expression}": ` + + '"^" is only supported at the end of a relation path.', + }), + ); + }); + } + + it('joinRelated() should fail with a clear error when a duplicate relation is detected', () => { + const builder = Model1.query().joinRelated( + '[model1Relation1, model1Relation1.model1Relation2]', + ); + + expect(() => builder.toKnexQuery()).toThrow( + 'Duplicate relation name "model1Relation1". For example, use "model1Relation1.[foo, bar]" ' + + 'instead of "[model1Relation1.foo, model1Relation1.bar]".', + ); }); describe('skipFetched option', () => { @@ -1925,8 +1940,8 @@ module.exports = (session) => { queries = []; const result = await TestModel.fetchGraph(models, 'model1Relation1', { skipFetched: true }); - expect(queries).to.have.length(0); - expect(models).to.eql(result); + expect(queries).toHaveLength(0); + expect(models).toEqual(result); }); it('should not fetch an existing nested relation when `skipFetched` option is true', async () => { @@ -1939,7 +1954,7 @@ module.exports = (session) => { skipFetched: true, }); - expect(queries).to.have.length(1); + expect(queries).toHaveLength(1); }); it('should fetch an existing relation when `skipFetched` option is true if not all needed relation props exist', async () => { @@ -1953,8 +1968,8 @@ module.exports = (session) => { queries = []; await model.$fetchGraph('model1Relation1.model1Relation1', { skipFetched: true }); - expect(queries).to.have.length(2); - expect(model).to.eql({ + expect(queries).toHaveLength(2); + expect(model).toEqual({ id: 1, model1Id: 2, model1Prop1: 'hello 1', @@ -1988,7 +2003,7 @@ module.exports = (session) => { queries = []; await model.$fetchGraph('model1Relation1.model1Relation2', { skipFetched: true }); - expect(queries).to.have.length(2); + expect(queries).toHaveLength(2); }); it('should fetch other relations when `skipFetched` option is true', async () => { @@ -2001,8 +2016,8 @@ module.exports = (session) => { { skipFetched: true }, ); - expect(queries).to.have.length(2); - expect(result).to.eql([ + expect(queries).toHaveLength(2); + expect(result).toEqual([ { id: 1, model1Id: 2, @@ -2055,8 +2070,8 @@ module.exports = (session) => { { skipFetched: true }, ); - expect(queries).to.have.length(1); - expect(result).to.eql([ + expect(queries).toHaveLength(1); + expect(result).toEqual([ { id: 1, model1Id: 2, @@ -2109,8 +2124,8 @@ module.exports = (session) => { { skipFetched: true }, ); - expect(queries).to.have.length(0); - expect(result).to.eql([ + expect(queries).toHaveLength(0); + expect(result).toEqual([ { id: 1, model1Id: 2, @@ -2161,7 +2176,7 @@ module.exports = (session) => { .where('model1Relation2:model2Relation1.id', 6) .withGraphJoined('[model1Relation1, model1Relation2.model2Relation1]') .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Prop1: 'hello 1', @@ -2209,28 +2224,27 @@ module.exports = (session) => { .orderBy('Model1.id') .limit(2); - expect(models.map((it) => it.id)).to.eql([1, 4]); + expect(models.map((it) => it.id)).toEqual([1, 4]); }); - it('distinctOn + select * should select the distinct column', async function () { - if (!session.isPostgres()) { - return this.skip(); - } - - const models = await Model1.query() - .distinctOn('Model1.model1Prop1') - .select('*') - .where('Model1.id', 1) - .withGraphJoined('model1Relation2'); + it.skipIf(!session.isPostgres())( + 'distinctOn + select * should select the distinct column', + async () => { + const models = await Model1.query() + .distinctOn('Model1.model1Prop1') + .select('*') + .where('Model1.id', 1) + .withGraphJoined('model1Relation2'); - expect(models.map((it) => it.model1Prop1)).to.eql(['hello 1']); - }); + expect(models.map((it) => it.model1Prop1)).toEqual(['hello 1']); + }, + ); it("should fail if the user's selections select other columns under the names of its columns", async () => { const expectError = async (query, selection, alias, column) => { const err = await query.withGraphJoined('model1Relation1').catch((err) => err); - expect(err.message).to.equal( + expect(err.message).toBe( `withGraphJoined: the selection '${selection}' selects '${alias}', ` + `the name under which the column '${column}' is selected. ` + 'Select it under a different name.', @@ -2264,7 +2278,7 @@ module.exports = (session) => { .where('model1Relation2:model2Relation1.id', 6) .withGraphJoined('[model1Relation1, model1Relation2.model2Relation1]') .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Prop1: 'hello 1', @@ -2321,7 +2335,7 @@ module.exports = (session) => { }, }) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, $afterFindCalled: 1, @@ -2379,9 +2393,9 @@ module.exports = (session) => { }, }) .then((models) => { - expect(models).to.have.length(1); - expect(models[0].id).to.equal(1); - expect(sortBy(models[0].model1Relation2, 'idCol')).to.eql([ + expect(models).toHaveLength(1); + expect(models[0].id).toBe(1); + expect(sortBy(models[0].model1Relation2, 'idCol')).toEqual([ { idCol: 1, ownerProp: 'hello 1', ownerId: 1, $afterFindCalled: 1 }, { idCol: 2, ownerProp: 'hello 1', ownerId: 1, $afterFindCalled: 1 }, ]); @@ -2409,18 +2423,18 @@ module.exports = (session) => { }, }) .then((models) => { - expect(models).to.have.length(1); - expect(+models[0].relation2Count).to.equal(2); + expect(models).toHaveLength(1); + expect(+models[0].relation2Count).toBe(2); expect( pick(models[0], ['id', 'model1Id', 'model1Prop1', 'model1Prop2', '$afterFindCalled']), - ).to.eql({ + ).toEqual({ id: 1, model1Id: 2, model1Prop1: 'hello 1', model1Prop2: null, $afterFindCalled: 1, }); - expect(sortBy(models[0].model1Relation2, 'idCol')).to.eql([ + expect(sortBy(models[0].model1Relation2, 'idCol')).toEqual([ { idCol: 1, model1Id: 1, @@ -2456,9 +2470,9 @@ module.exports = (session) => { }, }) .then((models) => { - expect(models).to.have.length(1); - expect(models[0].id).to.equal(1); - expect(sortBy(models[0].model1Relation2, 'idCol')).to.eql([ + expect(models).toHaveLength(1); + expect(models[0].id).toBe(1); + expect(sortBy(models[0].model1Relation2, 'idCol')).toEqual([ { idCol: 1, upperProp: 'HEJSAN 1', lowerProp: 'hejsan 1', $afterFindCalled: 1 }, { idCol: 2, upperProp: 'HEJSAN 2', lowerProp: 'hejsan 2', $afterFindCalled: 1 }, ]); @@ -2476,7 +2490,7 @@ module.exports = (session) => { }, }) .then((models) => { - expect(models).to.have.length(1); + expect(models).toHaveLength(1); expect( pick(models[0], [ 'id', @@ -2486,7 +2500,7 @@ module.exports = (session) => { 'upper_prop', '$afterFindCalled', ]), - ).to.eql({ + ).toEqual({ id: 1, model1Id: 2, model1Prop1: 'hello 1', @@ -2494,7 +2508,7 @@ module.exports = (session) => { upper_prop: 'HELLO 1', $afterFindCalled: 1, }); - expect(sortBy(models[0].model1Relation2, 'idCol')).to.eql([ + expect(sortBy(models[0].model1Relation2, 'idCol')).toEqual([ { idCol: 1, model1Id: 1, @@ -2523,7 +2537,7 @@ module.exports = (session) => { .where('model1Relation2:model2Relation1.id', 6) .withGraphJoined('[model1Relation1, model1Relation2.model2Relation1]') .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { theId: 1, leProp: 'hello 1', @@ -2570,7 +2584,7 @@ module.exports = (session) => { .then((models) => { // With innerJoin we should only get `Model1` instances that have one // or more `model2Relation2` relations. - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Prop1: 'hello 1', @@ -2657,7 +2671,7 @@ module.exports = (session) => { .orderBy('Model1.id'), ]).then((results) => { for (const models of results) { - expect(summarize(models)).to.eql(expected); + expect(summarize(models)).toEqual(expected); } }); }); @@ -2672,7 +2686,7 @@ module.exports = (session) => { separator: '->', }) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Prop1: 'hello 1', @@ -2718,7 +2732,7 @@ module.exports = (session) => { .withGraphJoined('[model1Relation1, model1Relation2.model2Relation1]') .orderBy(['Model1.id', 'model1Relation2:model2Relation1.id']) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -2778,7 +2792,7 @@ module.exports = (session) => { }) .orderBy(['Model1.id', 'mr2:model2Relation1.id']) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -2827,51 +2841,47 @@ module.exports = (session) => { }); }); - it('relation references longer that 63 chars should throw an exception on postgres', function (done) { - if (!session.isPostgres()) { - // MySQL allows 256 characters, SQLite has no limit. - return this.skip(); - } - - Model1.query() - .where('Model1.id', 1) - .withGraphJoined('[model1Relation1.model1Relation1.model1Relation1.model1Relation1]') - .then(() => { - done(new Error('should not get here')); - }) - .catch((err) => { - expect(err).to.be.a(ValidationError); - expect(err.type).to.equal('RelationExpression'); - expect(err.modelClass).to.equal(Model1); - expect(err.message).to.equal( - 'identifier model1Relation1:model1Relation1:model1Relation1:model1Relation1:id is over 63 characters long and would be truncated by the database engine. Use the `minimize` option of withGraphJoined() to shorten the aliases.', - ); - done(); - }) - .catch(done); - }); - - it('relation references longer that 63 chars should work on databases with a higher limit', async function () { - if (session.isPostgres()) { - return this.skip(); - } + // MySQL allows 256 characters, SQLite has no limit. + it.skipIf(!session.isPostgres())( + 'relation references longer that 63 chars should throw an exception on postgres', + () => { + return Model1.query() + .where('Model1.id', 1) + .withGraphJoined('[model1Relation1.model1Relation1.model1Relation1.model1Relation1]') + .then(() => { + throw new Error('should not get here'); + }) + .catch((err) => { + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('RelationExpression'); + expect(err.modelClass).toBe(Model1); + expect(err.message).toBe( + 'identifier model1Relation1:model1Relation1:model1Relation1:model1Relation1:id is over 63 characters long and would be truncated by the database engine. Use the `minimize` option of withGraphJoined() to shorten the aliases.', + ); + }); + }, + ); - const models = await Model1.query() - .where('Model1.id', 1) - .withGraphJoined('[model1Relation1.model1Relation1.model1Relation1.model1Relation1]'); + it.skipIf(session.isPostgres())( + 'relation references longer that 63 chars should work on databases with a higher limit', + async () => { + const models = await Model1.query() + .where('Model1.id', 1) + .withGraphJoined('[model1Relation1.model1Relation1.model1Relation1.model1Relation1]'); - expect(models).to.have.length(1); - expect(models[0].model1Relation1.model1Relation1.id).to.equal(3); - }); + expect(models).toHaveLength(1); + expect(models[0].model1Relation1.model1Relation1.id).toBe(3); + }, + ); - it('relation references longer that 63 chars should NOT throw an exception if minimize: true option is given', (done) => { - Model1.query() + it('relation references longer that 63 chars should NOT throw an exception if minimize: true option is given', () => { + return Model1.query() .where('Model1.id', 1) .withGraphJoined('[model1Relation1.model1Relation1.model1Relation1.model1Relation1]', { minimize: true, }) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -2902,81 +2912,66 @@ module.exports = (session) => { }, }, ]); - - done(); - }) - .catch(done); + }); }); - it('infinitely recursive expressions should fail', (done) => { - Model1.query() + it('infinitely recursive expressions should fail', () => { + return Model1.query() .where('Model1.id', 1) .withGraphJoined('model1Relation1.^') .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.type).to.equal('RelationExpression'); - expect(err.message).to.equal( + expect(err.type).toBe('RelationExpression'); + expect(err.message).toBe( 'recursion depth of eager expression model1Relation1.^ too big for JoinEagerAlgorithm', ); - done(); - }) - .catch(done); + }); }); - it('should fail if given missing filter', (done) => { - Model1.query() + it('should fail if given missing filter', () => { + return Model1.query() .where('id', 1) .withGraphFetched('model1Relation2(missingFilter)') .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(ValidationError); - expect(err.type).to.equal('RelationExpression'); - expect(err.message).to.equal( + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('RelationExpression'); + expect(err.message).toBe( 'could not find modifier "missingFilter" for relation "model1Relation2"', ); - done(); - }) - .catch(done); + }); }); - it('should fail if given missing relation', (done) => { - Model1.query() + it('should fail if given missing relation', () => { + return Model1.query() .where('id', 1) .withGraphFetched('invalidRelation') .then(() => { throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(ValidationError); - expect(err.type).to.equal('RelationExpression'); - expect(err.message).to.equal( - 'unknown relation "invalidRelation" in an eager expression', - ); - done(); - }) - .catch(done); + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('RelationExpression'); + expect(err.message).toBe('unknown relation "invalidRelation" in an eager expression'); + }); }); - it('should fail if given invalid relation expression', (done) => { - Model1.query() + it('should fail if given invalid relation expression', () => { + return Model1.query() .where('id', 1) .withGraphFetched('invalidRelation') .then(() => { throw new Error('should not get here'); }) .catch((err) => { - expect(err).to.be.a(ValidationError); - expect(err.type).to.equal('RelationExpression'); - expect(err.message).to.equal( - 'unknown relation "invalidRelation" in an eager expression', - ); - done(); - }) - .catch(done); + expect(err).toBeInstanceOf(ValidationError); + expect(err.type).toBe('RelationExpression'); + expect(err.message).toBe('unknown relation "invalidRelation" in an eager expression'); + }); }); }); @@ -2994,11 +2989,11 @@ module.exports = (session) => { builder.where('model2_prop1', 'hejsan 2'); }) .then((models) => { - expect(models[0].model1Relation2).to.have.length(1); - expect(models[0].model1Relation2[0].model2Prop1).to.equal('hejsan 2'); + expect(models[0].model1Relation2).toHaveLength(1); + expect(models[0].model1Relation2[0].model2Prop1).toBe('hejsan 2'); - expect(models[0].model1Relation2[0].model2Relation1).to.have.length(1); - expect(models[0].model1Relation2[0].model2Relation1[0].id).to.equal(6); + expect(models[0].model1Relation2[0].model2Relation1).toHaveLength(1); + expect(models[0].model1Relation2[0].model2Relation1[0].id).toBe(6); }); }), ); @@ -3013,7 +3008,7 @@ module.exports = (session) => { .modifyGraph('model1Relation2.model2Relation1', 'select:model1Prop1') .then((models) => { const model2 = models[0].model1Relation2.find((it) => it.idCol === 2); - expect(Object.keys(model2.model2Relation1[0])).to.eql([ + expect(Object.keys(model2.model2Relation1[0])).toEqual([ 'model1Prop1', '$afterFindCalled', ]); @@ -3030,7 +3025,7 @@ module.exports = (session) => { [method]('model1Relation1') .modifyGraph('model1Relation1', ['select:id', 'select:model1Prop1']) .then((models) => { - expect(Object.keys(models[0].model1Relation1)).to.eql([ + expect(Object.keys(models[0].model1Relation1)).toEqual([ 'id', 'model1Prop1', '$afterFindCalled', @@ -3066,7 +3061,7 @@ module.exports = (session) => { 'model1Prop1', ); - expect(models).to.eql([ + expect(models).toEqual([ { model1Prop1: 'hello 1', $afterFindCalled: 1, @@ -3140,7 +3135,7 @@ module.exports = (session) => { 'model1Prop1', ); - expect(models).to.eql([ + expect(models).toEqual([ { model1Prop1: 'hello 1', $afterFindCalled: 1, @@ -3214,7 +3209,7 @@ module.exports = (session) => { builder.select('model1Prop1'); }) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { id: 1, model1Id: 2, @@ -3276,7 +3271,7 @@ module.exports = (session) => { builder.orderBy('id'); }) .then((model) => { - chai.expect(model.toJSON()).to.containSubset({ + expect(model.toJSON()).toContainSubset({ id: 1, model1Relation2: [ { @@ -3329,7 +3324,7 @@ module.exports = (session) => { }) .first() .then((model) => { - chai.expect(model.toJSON()).to.containSubset({ + expect(model.toJSON()).toContainSubset({ id: 1, model1Relation2: [ { @@ -3398,9 +3393,9 @@ module.exports = (session) => { builder.orderBy('Model1.id'); }) .then((models) => { - expect(models).to.have.length(1); + expect(models).toHaveLength(1); - chai.expect(models[0].toJSON()).to.containSubset({ + expect(models[0].toJSON()).toContainSubset({ id: 1, model1Prop1: 'hello 1', @@ -3431,11 +3426,11 @@ module.exports = (session) => { ], }); - expect(models[0]).to.be.a(Model1); - expect(models[0].model1Relation1).to.be.a(Model1); - expect(models[0].model1Relation2[0]).to.be.a(Model2); - expect(models[0].model1Relation2).to.have.length(2); - expect(models[0].model1Relation1.model1Relation1.model1Relation1).to.equal(undefined); + expect(models[0]).toBeInstanceOf(Model1); + expect(models[0].model1Relation1).toBeInstanceOf(Model1); + expect(models[0].model1Relation2[0]).toBeInstanceOf(Model2); + expect(models[0].model1Relation2).toHaveLength(2); + expect(models[0].model1Relation1.model1Relation1.model1Relation1).toBeUndefined(); }); }); }); @@ -3450,10 +3445,10 @@ module.exports = (session) => { builder.orderBy('id_col'); }) .then((models) => { - expect(models).to.have.length(1); - expect(models[0].id).to.equal(1); - expect(models[0].model1Relation1.id).to.equal(2); - expect(models[0].model1Relation2.map((it) => it.idCol)).to.eql([1, 2]); + expect(models).toHaveLength(1); + expect(models[0].id).toBe(1); + expect(models[0].model1Relation1.id).toBe(2); + expect(models[0].model1Relation2.map((it) => it.idCol)).toEqual([1, 2]); }); }); }); @@ -3464,11 +3459,11 @@ module.exports = (session) => { query = callInOrder(query, order, 'model1Relation2', 'model1Relation1.model1Relation1'); return query.then((models) => { - expect(models).to.have.length(1); - expect(models[0].id).to.equal(1); - expect(models[0].model1Relation2.map((it) => it.idCol)).to.eql([2]); - expect(models[0].model1Relation1.id).to.equal(2); - expect(models[0].model1Relation1.model1Relation1.id).to.equal(3); + expect(models).toHaveLength(1); + expect(models[0].id).toBe(1); + expect(models[0].model1Relation2.map((it) => it.idCol)).toEqual([2]); + expect(models[0].model1Relation1.id).toBe(2); + expect(models[0].model1Relation1.model1Relation1.id).toBe(3); }); }); }); @@ -3483,11 +3478,11 @@ module.exports = (session) => { builder.orderBy('id_col'); }) .then((models) => { - expect(models).to.have.length(1); - expect(models[0].id).to.equal(undefined); - expect(models[0].model1Prop1).to.equal('hello 1'); - expect(models[0].model1Relation1.id).to.equal(2); - expect(models[0].model1Relation2.map((it) => it.idCol)).to.eql([1, 2]); + expect(models).toHaveLength(1); + expect(models[0].id).toBeUndefined(); + expect(models[0].model1Prop1).toBe('hello 1'); + expect(models[0].model1Relation1.id).toBe(2); + expect(models[0].model1Relation2.map((it) => it.idCol)).toEqual([1, 2]); }); }); }); @@ -3517,18 +3512,18 @@ module.exports = (session) => { .where('Model1.id', 1) .runAfter((models) => { runAfterModels = models; - expect(models[0]).to.not.have.property('id'); + expect(models[0]).not.toHaveProperty('id'); return models; }) .then((models) => { - expect(runAfterModels).to.equal(models); - expect(models).to.have.length(1); - expect(models[0]).to.not.have.property('id'); - expect(models[0].model1Prop1).to.equal('hello 1'); + expect(runAfterModels).toBe(models); + expect(models).toHaveLength(1); + expect(models[0]).not.toHaveProperty('id'); + expect(models[0].model1Prop1).toBe('hello 1'); const { model1Relation1, model1Relation2 } = models[0]; - expect(model1Relation1 && model1Relation1.id).to.equal(joined ? 2 : undefined); - expect(model1Relation2 && model1Relation2.length).to.equal(fetched ? 2 : undefined); + expect(model1Relation1 && model1Relation1.id).toBe(joined ? 2 : undefined); + expect(model1Relation2 && model1Relation2.length).toBe(fetched ? 2 : undefined); }); }); }); @@ -3555,12 +3550,13 @@ module.exports = (session) => { }, }) .then(() => { - expect(keys.sort()).to.eql([ - 'Model1: model1Prop1', - 'Model1: model1Prop1', - 'Model1: model1Prop1, model1Relation2', - 'Model2: model2Prop1, model2Relation1', - 'Model2: model2Prop1, model2Relation1', + // `runAfter` callbacks see the models after their `$afterFind()` hooks. + expect(keys.sort()).toEqual([ + 'Model1: $afterFindCalled, model1Prop1', + 'Model1: $afterFindCalled, model1Prop1', + 'Model1: $afterFindCalled, model1Prop1, model1Relation2', + 'Model2: $afterFindCalled, model2Prop1, model2Relation1', + 'Model2: $afterFindCalled, model2Prop1, model2Relation1', ]); }); }); @@ -3576,11 +3572,11 @@ module.exports = (session) => { }) .page(0, 2) .then((res) => { - expect(res.total).to.equal(5); - expect(res.results.map((it) => it.id)).to.eql([1, 2]); - expect(res.results.map((it) => it.model1Relation1.id)).to.eql([2, 3]); - expect(res.results[0].model1Relation2.map((it) => it.idCol)).to.eql([1, 2]); - expect(res.results[1].model1Relation2).to.eql([]); + expect(res.total).toBe(5); + expect(res.results.map((it) => it.id)).toEqual([1, 2]); + expect(res.results.map((it) => it.model1Relation1.id)).toEqual([2, 3]); + expect(res.results[0].model1Relation2.map((it) => it.idCol)).toEqual([1, 2]); + expect(res.results[1].model1Relation2).toEqual([]); }); }); }); @@ -3590,11 +3586,12 @@ module.exports = (session) => { Model1.query() .withGraphJoined('model1Relation1') .withGraphFetched('model1Relation1.model1Relation1'); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'relation "model1Relation1" cannot be loaded with both withGraphJoined and withGraphFetched', - ); - }); + }).toThrow( + expect.objectContaining({ + message: + 'relation "model1Relation1" cannot be loaded with both withGraphJoined and withGraphFetched', + }), + ); }); it('withGraph() should merge nested relations into both operations', () => { @@ -3611,13 +3608,13 @@ module.exports = (session) => { builder.orderBy('Model1.id'); }) .then((models) => { - expect(models).to.have.length(1); - expect(models[0].model1Relation1.id).to.equal(2); - expect(models[0].model1Relation1.model1Relation1.id).to.equal(3); - expect(models[0].model1Relation2.map((it) => it.idCol)).to.eql([1, 2]); + expect(models).toHaveLength(1); + expect(models[0].model1Relation1.id).toBe(2); + expect(models[0].model1Relation1.model1Relation1.id).toBe(3); + expect(models[0].model1Relation2.map((it) => it.idCol)).toEqual([1, 2]); expect( models[0].model1Relation2.map((it) => it.model2Relation1.map((it) => it.id)), - ).to.eql([[], [5, 6]]); + ).toEqual([[], [5, 6]]); }); }); }); @@ -3634,10 +3631,10 @@ module.exports = (session) => { builder.orderBy('id_col'); }) .then((models) => { - expect(models).to.have.length(1); - expect(models[0].model1Relation1.id).to.equal(2); - expect(models[0].model1Relation2.map((it) => it.idCol)).to.eql([1, 2]); - expect(models[0].model1Relation3).to.be.an('array'); + expect(models).toHaveLength(1); + expect(models[0].model1Relation1.id).toBe(2); + expect(models[0].model1Relation2.map((it) => it.idCol)).toEqual([1, 2]); + expect(models[0].model1Relation3).toBeInstanceOf(Array); }); }); }); @@ -3656,7 +3653,7 @@ module.exports = (session) => { query = callInOrder(query, order, 'model1Relation1(capture)', 'model1Relation2(capture)'); return query.then(() => { - expect(sortBy(childQueries, ([name]) => name)).to.eql([ + expect(sortBy(childQueries, ([name]) => name)).toEqual([ ['Model1', true], ['Model2', false], ]); @@ -3677,7 +3674,7 @@ module.exports = (session) => { .orderBy('Model1.model1Prop1', 'DESC') .whereNotNull('Model1.model1Id') .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { model1Prop1: 'hello 8', model1Relation1: { model1Prop1: 'hello 9', $afterFindCalled: 1 }, @@ -3754,7 +3751,7 @@ module.exports = (session) => { select: (b) => b.select('model1Prop1'), }) .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { idCol: 100, model1Id: null, @@ -3802,7 +3799,7 @@ module.exports = (session) => { }) .withGraphJoined('model2Relation1(select)') .then((models) => { - expect(models).to.eql([ + expect(models).toEqual([ { idCol: 100, model1Id: null, @@ -3889,7 +3886,7 @@ module.exports = (session) => { .orderBy('id_col') .withGraphFetched('model2Relation1(orderById)') .then((result) => { - chai.expect(result).to.containSubset([ + expect(result).toContainSubset([ { idCol: 100, @@ -3950,7 +3947,7 @@ module.exports = (session) => { model.b[1].c = sortBy(model.b[1].c, 'id'); model.b[1].d = sortBy(model.b[1].d, 'id'); - expect(model).to.eql({ + expect(model).toEqual({ id: 1, $afterFindCalled: 1, @@ -4024,7 +4021,7 @@ module.exports = (session) => { model.b[0].c = sortBy(model.b[0].c, 'id'); model.b[0].d = sortBy(model.b[0].d, 'id'); - expect(model).to.eql({ + expect(model).toEqual({ id: 1, $afterFindCalled: 1, @@ -4074,7 +4071,7 @@ module.exports = (session) => { }) .findOne({ 'm1.id': 1 }) .then((model) => { - expect(model).to.eql({ + expect(model).toEqual({ id: 1, $afterFindCalled: 1, @@ -4095,7 +4092,7 @@ module.exports = (session) => { let sql = []; let mockKnex = null; - before(() => { + beforeAll(() => { mockKnex = mockKnexFactory(session.knex, function (mock, then, args) { sql.push(this.toString()); return then.apply(this, args); @@ -4122,7 +4119,7 @@ module.exports = (session) => { }, }) .then(() => { - expect(sql).to.eql([ + expect(sql).toEqual([ 'select "Model1".* from "Model1" order by "id" asc', 'select "Model1".* from "Model1" where "Model1"."id" in (2, 3, 4, 7, 9) order by "id" asc', 'select "Model1".* from "Model1" where "Model1"."model1Id" in (1, 2, 3, 4, 5, 6, 7, 8, 9) order by "id" asc', @@ -4140,8 +4137,8 @@ module.exports = (session) => { .findById(4) .withGraphFetched('model1Relation1') .then((res) => { - expect(sql).to.have.length(1); - expect(res.id).to.equal(4); + expect(sql).toHaveLength(1); + expect(res.id).toBe(4); }); }); @@ -4152,7 +4149,7 @@ module.exports = (session) => { '[model1Relation1, model1Relation1Inverse, model1Relation2.[model2Relation1, model2Relation2], model1Relation3]', ) .then(() => { - expect(sql[sql.length - 1].replace(/\s/g, '')).to.equal( + expect(sql[sql.length - 1].replace(/\s/g, '')).toBe( ` select "Model1"."id" as "id", @@ -4217,8 +4214,7 @@ module.exports = (session) => { describe.skip('big data', () => { let graph = null; - before(function () { - this.timeout(30000); + beforeAll(() => { let n = 0; graph = range(100).map(() => { @@ -4269,11 +4265,9 @@ module.exports = (session) => { .then((inserted) => { graph = inserted; }); - }); - - it('should work with a lot of data', function () { - this.timeout(30000); + }, 30000); + it('should work with a lot of data', () => { return promiseMap( ['withGraphFecthed', 'withGraphJoined'], (method) => { @@ -4295,12 +4289,12 @@ module.exports = (session) => { let expected = graph.map((it) => it.toJSON()); let got = res.map((it) => it.toJSON()); - expect(got).to.eql(expected); + expect(got).toEqual(expected); }); }, { concurrency: 1 }, ); - }); + }, 30000); function traverser(model) { ['extra1', 'extra2', 'aliasedExtra', 'model1Id', 'model1Prop2', 'model2Prop2'].forEach( @@ -4342,7 +4336,7 @@ module.exports = (session) => { }; let idCol = opt.Model.query().fullIdColumnFor(opt.Model); - let testFn = opt.only ? it.only.bind(it) : it; + let testFn = opt.only ? it.only : it; if (!opt.disableWhereIn) { testFn(testName + ' (QueryBuilder.withGraphFetched)', () => { diff --git a/tests/main.js b/tests/main.js index 63475cc74..5ee925ba0 100644 --- a/tests/main.js +++ b/tests/main.js @@ -1,64 +1,105 @@ -const expect = require('expect.js'); +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import * as dbErrors from '../lib/dbErrors/index.js'; +import * as objectionNamespace from 'objection'; +import { QueryBuilderBase } from '../lib/queryBuilder/QueryBuilderBase.js'; +import { QueryBuilderOperation } from '../lib/queryBuilder/operations/QueryBuilderOperation.js'; +import { RelationExpression } from '../lib/queryBuilder/RelationExpression.js'; +import { ValidationError } from '../lib/model/ValidationError.js'; +import { NotFoundError } from '../lib/model/NotFoundError.js'; +import { Relation } from '../lib/relations/Relation.js'; +import { HasManyRelation } from '../lib/relations/hasMany/HasManyRelation.js'; +import { HasOneRelation } from '../lib/relations/hasOne/HasOneRelation.js'; +import { BelongsToOneRelation } from '../lib/relations/belongsToOne/BelongsToOneRelation.js'; +import { HasOneThroughRelation } from '../lib/relations/hasOneThrough/HasOneThroughRelation.js'; +import { ManyToManyRelation } from '../lib/relations/manyToMany/ManyToManyRelation.js'; +import { BelongsToManyRelation } from '../lib/relations/belongsToMany/BelongsToManyRelation.js'; +import { transaction } from '../lib/transaction.js'; +import { ref } from '../lib/queryBuilder/ReferenceBuilder.js'; +import { raw } from '../lib/queryBuilder/RawBuilder.js'; +import { val } from '../lib/queryBuilder/ValueBuilder.js'; +import { mixin, compose } from '../lib/utils/mixin.js'; +import { Validator } from '../lib/model/Validator.js'; +import { AjvValidator } from '../lib/model/AjvValidator.js'; +import { Model } from '../lib/model/Model.js'; +import { QueryBuilder } from '../lib/queryBuilder/QueryBuilder.js'; + +const require = createRequire(import.meta.url); describe('main module', () => { - it('should be able to load using require', () => { - let objection = require('../'); + const expectExports = (objection) => { + expect(objection.QueryBuilderBase).toBe(QueryBuilderBase); + expect(objection.QueryBuilderOperation).toBe(QueryBuilderOperation); + expect(objection.RelationExpression).toBe(RelationExpression); + expect(objection.ValidationError).toBe(ValidationError); + expect(objection.NotFoundError).toBe(NotFoundError); + expect(objection.Relation).toBe(Relation); + expect(objection.HasManyRelation).toBe(HasManyRelation); + expect(objection.HasOneRelation).toBe(HasOneRelation); + expect(objection.BelongsToOneRelation).toBe(BelongsToOneRelation); + expect(objection.HasOneThroughRelation).toBe(HasOneThroughRelation); + expect(objection.ManyToManyRelation).toBe(ManyToManyRelation); + expect(objection.BelongsToManyRelation).toBe(BelongsToManyRelation); + expect(objection.Model.BelongsToManyRelation).toBe(BelongsToManyRelation); + expect(objection.transaction).toBe(transaction); + expect(objection.transaction.start).toBe(transaction.start); + expect(objection.ref).toBe(ref); + expect(objection.raw).toBe(raw); + expect(objection.val).toBe(val); + expect(objection.mixin).toBe(mixin); + expect(objection.compose).toBe(compose); + expect(Object.getPrototypeOf(objection.Validator)).toBe(Validator); + expect(Object.getPrototypeOf(objection.AjvValidator)).toBe(AjvValidator); + expect(Object.getPrototypeOf(objection.Model)).toBe(Model); + expect(Object.getPrototypeOf(objection.QueryBuilder)).toBe(QueryBuilder); + expect(objection.DBError).toBe(dbErrors.DBError); + expect(objection.UniqueViolationError).toBe(dbErrors.UniqueViolationError); + expect(objection.ConstraintViolationError).toBe(dbErrors.ConstraintViolationError); + expect(objection.ForeignKeyViolationError).toBe(dbErrors.ForeignKeyViolationError); + expect(objection.NotNullViolationError).toBe(dbErrors.NotNullViolationError); + expect(objection.CheckViolationError).toBe(dbErrors.CheckViolationError); + expect(objection.DataError).toBe(dbErrors.DataError); + }; + + it('should be able to load using named imports', () => { + expectExports(objectionNamespace); + }); - expect(objection.QueryBuilderBase).to.equal( - require('../lib/queryBuilder/QueryBuilderBase').QueryBuilderBase, - ); - expect(objection.QueryBuilderOperation).to.equal( - require('../lib/queryBuilder/operations/QueryBuilderOperation').QueryBuilderOperation, - ); - expect(objection.RelationExpression).to.equal( - require('../lib/queryBuilder/RelationExpression').RelationExpression, - ); - expect(objection.ValidationError).to.equal( - require('../lib/model/ValidationError').ValidationError, - ); - expect(objection.NotFoundError).to.equal(require('../lib/model/NotFoundError').NotFoundError); - expect(objection.Relation).to.equal(require('../lib/relations/Relation').Relation); - expect(objection.HasManyRelation).to.equal( - require('../lib/relations/hasMany/HasManyRelation').HasManyRelation, - ); - expect(objection.HasOneRelation).to.equal( - require('../lib/relations/hasOne/HasOneRelation').HasOneRelation, - ); - expect(objection.BelongsToOneRelation).to.equal( - require('../lib/relations/belongsToOne/BelongsToOneRelation').BelongsToOneRelation, - ); - expect(objection.HasOneThroughRelation).to.equal( - require('../lib/relations/hasOneThrough/HasOneThroughRelation').HasOneThroughRelation, - ); - expect(objection.ManyToManyRelation).to.equal( - require('../lib/relations/manyToMany/ManyToManyRelation').ManyToManyRelation, - ); - expect(objection.transaction).to.equal(require('../lib/transaction').transaction); - expect(objection.transaction.start).to.equal(require('../lib/transaction').transaction.start); - expect(objection.ref).to.equal(require('../lib/queryBuilder/ReferenceBuilder').ref); - expect(objection.raw).to.equal(require('../lib/queryBuilder/RawBuilder').raw); - expect(objection.val).to.equal(require('../lib/queryBuilder/ValueBuilder').val); - expect(objection.mixin).to.equal(require('../lib/utils/mixin').mixin); - expect(objection.compose).to.equal(require('../lib/utils/mixin').compose); - expect(Object.getPrototypeOf(objection.Validator)).to.equal( - require('../lib/model/Validator').Validator, - ); - expect(Object.getPrototypeOf(objection.AjvValidator)).to.equal( - require('../lib/model/AjvValidator').AjvValidator, - ); - expect(Object.getPrototypeOf(objection.Model)).to.equal(require('../lib/model/Model').Model); - expect(Object.getPrototypeOf(objection.QueryBuilder)).to.equal( - require('../lib/queryBuilder/QueryBuilder').QueryBuilder, - ); - expect(objection.DBError).to.equal(require('db-errors').DBError); - expect(objection.UniqueViolationError).to.equal(require('db-errors').UniqueViolationError); - expect(objection.ConstraintViolationError).to.equal( - require('db-errors').ConstraintViolationError, - ); - expect(objection.ForeignKeyViolationError).to.equal( - require('db-errors').ForeignKeyViolationError, - ); - expect(objection.NotNullViolationError).to.equal(require('db-errors').NotNullViolationError); - expect(objection.DataError).to.equal(require('db-errors').DataError); + it('should not have a default export', () => { + expect(objectionNamespace).not.toHaveProperty('default'); }); + + it('should be able to load using require', () => { + const objection = require('objection'); + expectExports(objection); + expect(objection.Model).toBe(objectionNamespace.Model); + }); + + // Runs in a fresh process, so that only the modules that the main module + // imports itself are loaded, not the ones that other tests import. + for (const type of ['module', 'commonjs']) { + it(`should load everything it needs on its own (${type})`, () => { + const load = + type === 'module' + ? `import { Model } from 'objection';\nimport Knex from 'knex';` + : `const { Model } = require('objection');\nconst Knex = require('knex');`; + const code = `${load} + class Person extends Model { + static tableName = 'persons'; + } + const query = Person.query(Knex({ client: 'pg' })).join('pets', function () { + this.on('pets.ownerId', 'persons.id'); + }); + console.log(query.toKnexQuery().toString());`; + const output = execFileSync(process.execPath, ['--input-type', type, '-e', code], { + cwd: path.join(import.meta.dirname, '..'), + encoding: 'utf8', + }); + expect(output.trim()).toBe( + 'select "persons".* from "persons" inner join "pets" on "pets"."ownerId" = "persons"."id"', + ); + }); + } }); diff --git a/tests/ts/custom-query-builder.ts b/tests/ts/custom-query-builder.ts index a282acc14..3da50c858 100644 --- a/tests/ts/custom-query-builder.ts +++ b/tests/ts/custom-query-builder.ts @@ -2,22 +2,19 @@ import { MaybeCompositeId, Model, ModelConstructor, - PartialModelObject, + NumberQueryBuilder, + PartialModelRow, + Pojo, QueryBuilder, - Page, RelateQueryBuilder, RelationExpression, TransactionOrKnex, WithGraphOptions, WithGraphQueryBuilder, -} from '../../'; +} from 'objection'; class CustomQueryBuilder extends QueryBuilder { - declare ArrayQueryBuilderType: CustomQueryBuilder; - declare SingleQueryBuilderType: CustomQueryBuilder; - declare MaybeSingleQueryBuilderType: CustomQueryBuilder; - declare NumberQueryBuilderType: CustomQueryBuilder; - declare PageQueryBuilderType: CustomQueryBuilder>; + declare RebindType: CustomQueryBuilder; someCustomMethod(): this { return this; @@ -42,9 +39,7 @@ class CustomQueryBuilder extends QueryBuilder { } // relate() can be overridden with an inferred return type... - relate( - ids: MaybeCompositeId | MaybeCompositeId[] | PartialModelObject | PartialModelObject[], - ) { + relate(ids: MaybeCompositeId | MaybeCompositeId[] | PartialModelRow | PartialModelRow[]) { return super.relate(ids); } } @@ -106,11 +101,7 @@ const throwIfNotFound: CustomQueryBuilder = Person.query() // Custom query builders can define their own constructor and call `super()` // with the model class, see #2306. class DefaultSchemaQueryBuilder extends QueryBuilder { - declare ArrayQueryBuilderType: DefaultSchemaQueryBuilder; - declare SingleQueryBuilderType: DefaultSchemaQueryBuilder; - declare MaybeSingleQueryBuilderType: DefaultSchemaQueryBuilder; - declare NumberQueryBuilderType: DefaultSchemaQueryBuilder; - declare PageQueryBuilderType: DefaultSchemaQueryBuilder>; + declare RebindType: DefaultSchemaQueryBuilder; constructor(modelClass: ModelConstructor) { super(modelClass); @@ -119,8 +110,8 @@ class DefaultSchemaQueryBuilder extends QueryBuilder | PartialModelObject[], - ): RelateQueryBuilder { + ids: MaybeCompositeId | MaybeCompositeId[] | PartialModelRow | PartialModelRow[], + ): RelateQueryBuilder> { return super.relate(ids); } } @@ -163,7 +154,7 @@ const numRelatedPets: CustomQueryBuilder = Person.relatedQuery(' .for(1) .relate(2) .someCustomMethod(); -const relatedPetRows: PromiseLike = Person.relatedQuery('pets') +const relatedPetRows: PromiseLike = Person.relatedQuery('pets') .for(1) .relate(2) .someCustomMethod() @@ -177,3 +168,40 @@ function withGraphOverride( ): QueryBuilder['withGraph'] { return withGraph; } + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; + +// The exact result types of the methods that are typed through `this`. +function resultTypes() { + const relate = Person.relatedQuery('pets').for(1).relate(2).someCustomMethod().relate(3); + type _relate = Expect< + Equal>> + >; + const relateReturning = Person.relatedQuery('pets').for(1).relate(2).debug().returning('*'); + type _relateReturning = Expect>>; + const first = Person.query().someCustomMethod().first(); + type _first = Expect>>; + const insertGraph = Person.query().insertGraph({ firstName: 'Jennifer' }); + type _insertGraph = Expect>>; + const insertGraphs = Person.query().insertGraph([{ firstName: 'Jennifer' }]); + type _insertGraphs = Expect>>; + const upsertGraph = Person.query().upsertGraph({ firstName: 'Jennifer' }); + type _upsertGraph = Expect>>; + const patchReturning = Person.query().patch({ firstName: 'Jennifer' }).returning('*'); + type _patchReturning = Expect>>; + const where = Person.query().where({ firstName: 'Jennifer' }); + type _where = Expect>>; +} + +// Query builders of subclasses and their result kinds stay assignable to the +// base types, and not the other way around. +const toBaseModel: QueryBuilder = Person.query(); +const toBaseQueryBuilder: QueryBuilder = Person.query(); +const toBaseModelNumber: QueryBuilder = Person.query().patch({}); +const toMaybeSingle: QueryBuilder = Person.query().findById(1); +// @ts-expect-error the base model's query builder isn't a Person query builder +const fromBaseModel: QueryBuilder = new QueryBuilder(Model); +// @ts-expect-error a number result isn't an array result +const fromNumber: QueryBuilder = Person.query().patch({}); diff --git a/tests/ts/documents.ts b/tests/ts/documents.ts index 0aabed39b..9784d9021 100644 --- a/tests/ts/documents.ts +++ b/tests/ts/documents.ts @@ -1,4 +1,4 @@ -import { Person } from './fixtures/person'; +import { Person } from './fixtures/person.js'; (async () => { const jennifer = await Person.query().insert({ diff --git a/tests/ts/examples.ts b/tests/ts/examples.ts index 05ae9029d..2f5a44d89 100644 --- a/tests/ts/examples.ts +++ b/tests/ts/examples.ts @@ -1,7 +1,6 @@ -import Ajv from 'ajv'; -import * as dbErrors from 'db-errors'; +import { Ajv } from 'ajv'; import { Knex, knex } from 'knex'; -import * as objection from '../../'; +import * as objection from 'objection'; import { DBError, fn, @@ -13,7 +12,7 @@ import { RelationMappings, StaticHookArguments, val, -} from '../../'; +} from 'objection'; // This file exercises the Objection.js typings. @@ -335,7 +334,7 @@ class Movie extends objection.Model { // prettier-ignore director!: Person; - fetchDirector(): PromiseLike { + fetchDirector(): PromiseLike { return this.$relatedQuery('director'); } @@ -379,6 +378,9 @@ function takesMovie(m: Movie) { async () => { // Another example of strongly-typed $relatedQuery without a cast: takesPeople(await new Movie().$relatedQuery('actors')); + takesMaybePerson(await new Movie().$relatedQuery('director')); + takesPerson(await new Movie().$relatedQuery('director').throwIfNotFound()); + // @ts-expect-error there may be no director takesPerson(await new Movie().$relatedQuery('director')); takesMaybePerson(await new Movie().$relatedQuery('actors').first()); @@ -730,8 +732,8 @@ const rowsEager: PromiseLike = Person.query().withGraphFetched('foo.ba joinOperation: 'innerJoin', }); -const rowsEagerLimited: PromiseLike = Person.query().withGraphFetched('pets', { - limitPerParent: true, +const rowsEagerUnlimited: PromiseLike = Person.query().withGraphFetched('pets', { + limitPerParent: false, }); const rowsEager2: PromiseLike = Person.query().withGraphFetched({ @@ -751,7 +753,6 @@ const rowsEager3: PromiseLike = Person.query().withGraphFetched({ }); const children: PromiseLike = Person.query() - .skipUndefined() .allowGraph('[pets, parent, children.[pets, movies.actors], movies.actors.pets]') .allowGraph({ pets: true }) .allowGraph({ parent: true }) @@ -844,7 +845,7 @@ const rowsPatchReturningColumns: PromiseLike = Person.query() // which can be join rows, related rows or owner rows, depending on the relation. const numRelated: PromiseLike = Person.relatedQuery('pets').for(1).relate(2); -const rowsRelateReturning: PromiseLike = Person.relatedQuery('pets') +const rowsRelateReturning: PromiseLike = Person.relatedQuery('pets') .for(1) .relate(2) .returning('*'); @@ -1018,7 +1019,7 @@ objection.transaction.start(Person).then((trx) => { const p: Promise = qb.then(() => 'done'); -// Verify we can call `.insert` with a Partial: +// Verify we can call `.insert` with partial data: Person.query().insert({ firstName: 'Chuck' }); @@ -1290,7 +1291,6 @@ const plugin2 = {} as any as objection.Plugin; if (err instanceof objection.UniqueViolationError) { const columns: string[] = err.columns; const typed: objection.UniqueViolationError = err; - const fromDbErrors: dbErrors.UniqueViolationError = typed; const constraintError: objection.ConstraintViolationError = typed; const dbError: objection.DBError = constraintError; } @@ -1415,10 +1415,37 @@ class Interview extends objection.Model { class JsonKeysModel extends objection.Model { static columnNameMappers = objection.snakeCaseMappers({ upperCase: true, - preserveJsonKeys: true, + preserveJsonKeys: false, }); } +class TextJsonModel extends objection.Model { + static jsonColumnFormat = 'text' as const; +} + +class ParsedJsonModel extends objection.Model { + static jsonColumnFormat: objection.JsonColumnFormat = 'parsed'; +} + +class DatabaseJsonModel extends objection.Model { + static fromDatabaseJson( + this: objection.ModelClass, + json: object, + options?: objection.DatabaseJsonOptions, + ): M { + return super.fromDatabaseJson(json, options) as M; + } + + $parseDatabaseJson(json: objection.Pojo, options?: objection.DatabaseJsonOptions) { + return super.$parseDatabaseJson(json, options); + } +} + +// @ts-expect-error jsonColumnFormat only takes 'auto', 'text' and 'parsed' +class InvalidJsonFormatModel extends objection.Model { + static jsonColumnFormat = 'json' as const; +} + async () => { // findById with composite key const interview: Interview | undefined = await Interview.query().findById([10, 11]); @@ -1475,7 +1502,7 @@ Person.fromJson({ id: 1 }).$modelClass.query(); // Each mapper factory only accepts the options it supports. () => { const options: objection.SnakeCaseMappersOptions = { upperCase: true, preserveJsonKeys: true }; - objection.snakeCaseMappers(options); + objection.snakeCaseMappers(options).format({ 'ns:fooBar': 1 }, (key) => key !== 'ns:fooBar'); const knexOptions: objection.KnexSnakeCaseMappersOptions = { upperCase: true }; objection.knexSnakeCaseMappers(knexOptions); const shared: objection.SnakeCaseOptions = { noDoubleUnderscores: true }; diff --git a/tests/ts/fixtures/animal.ts b/tests/ts/fixtures/animal.ts index 3d3098747..fc3463f97 100644 --- a/tests/ts/fixtures/animal.ts +++ b/tests/ts/fixtures/animal.ts @@ -1,5 +1,5 @@ -import * as objection from '../../../'; -import { Person } from './person'; +import * as objection from 'objection'; +import { Person } from './person.js'; export class Animal extends objection.Model { id!: number; diff --git a/tests/ts/fixtures/comment.ts b/tests/ts/fixtures/comment.ts new file mode 100644 index 000000000..b2de8b9b8 --- /dev/null +++ b/tests/ts/fixtures/comment.ts @@ -0,0 +1,6 @@ +import * as objection from 'objection'; + +export class Comment extends objection.Model { + id!: number; + comment!: string; +} diff --git a/tests/ts/fixtures/movie.ts b/tests/ts/fixtures/movie.ts index ec069b570..e4838d237 100644 --- a/tests/ts/fixtures/movie.ts +++ b/tests/ts/fixtures/movie.ts @@ -1,7 +1,7 @@ -import * as objection from '../../../'; -import { ref, RelationMappings } from '../../../'; -import { Person } from './person'; -import { Review } from './review'; +import * as objection from 'objection'; +import { ref, RelationMappings } from 'objection'; +import { Person } from './person.js'; +import { Review } from './review.js'; export class Movie extends objection.Model { id!: number; diff --git a/tests/ts/fixtures/person.ts b/tests/ts/fixtures/person.ts index 74be64bca..dfdd196f0 100644 --- a/tests/ts/fixtures/person.ts +++ b/tests/ts/fixtures/person.ts @@ -1,7 +1,8 @@ -import Ajv from 'ajv'; -import * as objection from '../../../'; -import { Animal } from './animal'; -import { Movie } from './movie'; +import { Ajv } from 'ajv'; +import * as objection from 'objection'; +import { Animal } from './animal.js'; +import { Comment } from './comment.js'; +import { Movie } from './movie.js'; class CustomValidationError extends Error {} diff --git a/tests/ts/fixtures/review.ts b/tests/ts/fixtures/review.ts index 84dcb004e..ca53b0c44 100644 --- a/tests/ts/fixtures/review.ts +++ b/tests/ts/fixtures/review.ts @@ -1,4 +1,4 @@ -import * as objection from '../../../'; +import * as objection from 'objection'; export class Review extends objection.Model { id!: number; diff --git a/tests/ts/from-schema.ts b/tests/ts/from-schema.ts new file mode 100644 index 000000000..3e302f3cb --- /dev/null +++ b/tests/ts/from-schema.ts @@ -0,0 +1,230 @@ +// Type tests for FromSchema: model properties derived from `static +// jsonSchema`, declared `as const`. + +import { FromSchema, JSONSchema, Model, ModelObject, ReadonlyJSONSchema } from 'objection'; + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; + +export class Owner extends Model { + static tableName = 'owners'; + static jsonSchema = { + type: 'object', + required: ['id', 'firstName', 'createdAt'], + properties: { + id: { type: 'integer' }, + firstName: { type: 'string', minLength: 1 }, + lastName: { type: ['string', 'null'] }, + nickName: { type: 'string', nullable: true }, + age: { type: 'number' }, + active: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time' }, + role: { enum: ['admin', 'user'] }, + status: { type: ['string', 'null'], enum: ['new', 'done', null] }, + level: { type: 'string', enum: ['low', 'high', null] }, + kind: { const: 'owner' }, + tags: { type: 'array', items: { type: 'string' } }, + point: { + type: 'array', + items: [{ type: 'number' }, { type: 'number' }], + minItems: 2, + additionalItems: false, + }, + pair: { type: 'array', items: [{ type: 'string' }, { type: 'number' }] }, + address: { + type: 'object', + required: ['street'], + properties: { + street: { type: 'string' }, + zip: { type: 'integer' }, + }, + }, + settings: { type: 'object' }, + score: { anyOf: [{ type: 'integer' }, { type: 'string' }] }, + anything: {}, + }, + } as const; + + // Relations are still declared by hand. + pets?: Pet[]; + + // Methods too. + fullName() { + return `${this.firstName} ${this.lastName}`; + } +} + +export interface Owner extends FromSchema {} + +export class Pet extends Model { + static tableName = 'pets'; + static jsonSchema = { + type: 'object', + required: ['name'], + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + ownerId: { type: ['integer', 'null'] }, + }, + } as const; + + owner?: Owner; +} + +export interface Pet extends FromSchema {} + +declare const owner: Owner; + +type Props = { + id: number; + firstName: string; + createdAt: string; + lastName?: string | null; + nickName?: string | null; + age?: number; + active?: boolean; + role?: 'admin' | 'user'; + status?: 'new' | 'done' | null; + level?: 'low' | 'high'; + kind?: 'owner'; + tags?: string[]; + point?: [number, number]; + pair?: [string?, number?, ...unknown[]]; + address?: { street: string; zip?: number }; + settings?: { [key: string]: unknown }; + score?: number | string; + anything?: unknown; +}; + +type _props = Expect, Props>>; +type _instance = Expect>; + +// ModelObject picks up the derived properties. +type _object = Expect['address'], Props['address']>>; +type _objectKeys = Expect>, never>>; + +async function usage() { + owner.firstName.toUpperCase(); + owner.fullName(); + // @ts-expect-error optional + owner.age.toFixed(); + // @ts-expect-error + owner.role = 'guest'; + + await Owner.query().where('firstName', 'Jennifer').where('role', 'admin'); + + // Narrowing of hand-declared relations works as usual. + const pets = await Pet.query().withGraphFetched('owner'); + pets[0].owner.firstName; + const owners = await Owner.query().withGraphFetched('pets'); + owners[0].pets[0].name.toUpperCase(); + + // Inserts and patches take the derived properties. + await Pet.query().insert({ name: 'Fluffy', ownerId: 1 }); + await Pet.query().patch({ ownerId: null }); + // @ts-expect-error + await Pet.query().insert({ name: 1 }); +} + +// Models that subclasses extend with their own schema declare `static +// jsonSchema` with its general type, as an `as const` schema types it with +// its literal type, which the schema of a subclass can't be assigned to. +const baseSchema = { + type: 'object', + required: ['id'], + properties: { + id: { type: 'integer' }, + }, +} as const; + +class BaseRecord extends Model { + static jsonSchema: ReadonlyJSONSchema = baseSchema; +} + +interface BaseRecord extends FromSchema {} + +const noteSchema = { + type: 'object', + required: [...baseSchema.required, 'text'], + properties: { + ...baseSchema.properties, + text: { type: 'string' }, + }, +} as const; + +class Note extends BaseRecord { + static jsonSchema = noteSchema; +} + +interface Note extends FromSchema {} + +type _note = Expect, { id: number; text: string }>>; +declare const note: Note; +note.id.toFixed(); +note.text.toUpperCase(); + +class InlineBase extends Model { + static jsonSchema = baseSchema; +} + +// @ts-expect-error the literal type of the base schema +class InlineSub extends InlineBase { + static jsonSchema = noteSchema; +} + +// `static jsonSchema` takes mutable and `as const` schemas. +const mutableSchema: JSONSchema = { type: 'object', required: ['a'] }; +const readonlySchema: ReadonlyJSONSchema = { type: 'object', required: ['a'] } as const; + +class MutableSchemaModel extends Model { + static jsonSchema: JSONSchema = mutableSchema; +} + +class ReadonlySchemaModel extends Model { + static jsonSchema = readonlySchema; +} + +// Variants of schema details. + +type Nullable = FromSchema<{ type: ['integer', 'null'] }>; +type _nullable = Expect>; +type Items = FromSchema<{ + type: 'array'; + items: { type: 'object'; properties: { a: { type: 'string' } } }; +}>; +type _items = Expect>; +type NoRequired = FromSchema<{ type: 'object'; properties: { a: { type: 'string' } } }>; +type _noRequired = Expect>; +type OneOf = FromSchema<{ oneOf: readonly [{ type: 'string' }, { type: 'null' }] }>; +type _oneOf = Expect>; +type Untyped = FromSchema<{ description: 'x' }>; +type _untyped = Expect>; +type ConditionalRequired = FromSchema<{ + type: 'object'; + required: ['id']; + properties: { id: { type: 'integer' }; email: { type: 'string' } }; + anyOf: [{ required: ['email'] }, { required: ['id'] }]; +}>; +type _conditionalRequired = Expect>; +type TypeAndAnyOf = FromSchema<{ type: 'string'; anyOf: [{ format: 'email' }, { format: 'uri' }] }>; +type _typeAndAnyOf = Expect>; +type TypeAndOneOf = FromSchema<{ type: 'integer'; oneOf: [{ minimum: 0 }, { maximum: -10 }] }>; +type _typeAndOneOf = Expect>; +// `type` narrows `enum` and `const`, as validation checks both. +type NullableEnum = FromSchema<{ type: 'string'; enum: ['a', 'b']; nullable: true }>; +type _nullableEnum = Expect>; +type NullableEnumWithNull = FromSchema<{ type: 'string'; enum: ['a', null]; nullable: true }>; +type _nullableEnumWithNull = Expect>; +type TypedConst = FromSchema<{ type: 'integer'; const: 1 }>; +type _typedConst = Expect>; +type EmptyObject = FromSchema<{ type: 'object'; properties: {} }>; +type _emptyObject = Expect>; +type AdditionalItems = FromSchema<{ + type: 'array'; + items: [{ type: 'string' }]; + additionalItems: { type: 'number' }; +}>; +type _additionalItems = Expect>; +type Bool = FromSchema; +type _bool = Expect>; diff --git a/tests/ts/generated.ts b/tests/ts/generated.ts index 92f5df7dd..ee883176c 100644 --- a/tests/ts/generated.ts +++ b/tests/ts/generated.ts @@ -5,9 +5,9 @@ import { Model, ModelObject, PartialModelGraph, - PartialModelObject, + PartialModelRow, raw, -} from '../../'; +} from 'objection'; class Pet extends Model { id!: Generated; @@ -76,8 +76,8 @@ async function insertable() { // @ts-expect-error relations are left out, use InsertableGraph const withRelation: Insertable = { firstName: 'Jennifer', pets: [] }; - // Insertable data is accepted by insert() and PartialModelObject. - const partial: PartialModelObject = full; + // Insertable data is accepted by insert(), as PartialModelRow. + const partial: PartialModelRow = full; await Owner.query().insert(minimal); await Owner.query().insert([minimal, full]); await Owner.query().insertAndFetch({ firstName: 'Jennifer' } satisfies Insertable); diff --git a/tests/ts/model-class.ts b/tests/ts/model-class.ts index 3cef3c0c3..0f871ec56 100644 --- a/tests/ts/model-class.ts +++ b/tests/ts/model-class.ts @@ -1,6 +1,6 @@ -import { Person } from './fixtures/person'; -import { Animal } from './fixtures/animal'; -import { Model, ModelClass } from '../../'; +import { Person } from './fixtures/person.js'; +import { Animal } from './fixtures/animal.js'; +import { Model, ModelClass, StaticHookArguments, asArray } from 'objection'; (async () => { const query = Person.query(); @@ -67,3 +67,46 @@ import { Model, ModelClass } from '../../'; await CompositeKey.query().returning(CompositeKey.idColumn); await CompositeKey.query().returning(['a', 'b'] as const); })(); + +// The `result` of the static hooks has the shape of what the query returns, +// and is `undefined` in the before hooks. +(() => { + class Hooked extends Model { + static beforeFind({ result }: StaticHookArguments) { + const value: undefined = result; + } + + static afterFind({ result }: StaticHookArguments) { + const models: Model[] = asArray(result); + // @ts-expect-error `result` isn't always an array. + result.map((model) => model); + } + + static beforeInsert({ result }: StaticHookArguments) { + const value: undefined = result; + } + + // Overrides may narrow `result` down to what their queries return. + static afterInsert({ result }: StaticHookArguments) { + const models: Hooked[] = result; + } + + static afterUpdate({ + result, + }: StaticHookArguments) { + const count: number = typeof result === 'number' ? result : asArray(result).length; + } + + static afterDelete({ + result, + }: StaticHookArguments) { + // @ts-expect-error `result` isn't always a number. + const count: number = result; + } + } + + const numbers: number[] = asArray(1); + const sameNumbers: number[] = asArray([1, 2]); + const noNumbers: number[] = asArray(undefined as number | undefined); + const nullable: (number | null)[] = asArray(null as number | null); +})(); diff --git a/tests/ts/model/instance-methods.ts b/tests/ts/model/instance-methods.ts index 61e648304..8efc09a05 100644 --- a/tests/ts/model/instance-methods.ts +++ b/tests/ts/model/instance-methods.ts @@ -1,9 +1,39 @@ -import { Person } from '../fixtures/person'; -import { ModelObject } from '../../../typings/objection'; +import { Person } from '../fixtures/person.js'; +import { Animal } from '../fixtures/animal.js'; +import { Model, ModelJson } from 'objection'; -const takesPersonPojo = (person: ModelObject) => true; +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; + +const takesPersonJson = (person: ModelJson) => true; const person = Person.fromJson({ firstName: 'Jennifer' }); -const personPojo = person.toJSON(); +const personJson = person.toJSON(); + +takesPersonJson(personJson); +takesPersonJson(person.$toJson()); + +// The JSON has the properties, and the related models as JSON too. +type _firstName = Expect>; +type _pets = Expect[]>>; +type _owner = Expect< + Equal<(typeof personJson)['pets'][number]['owner'], ModelJson | undefined> +>; +// @ts-expect-error methods aren't part of the JSON +personJson.examplePersonMethod; +// @ts-expect-error neither are the methods of related models +personJson.pets[0].$query; + +// Columns typed `any` or `any[]` aren't relations, and readonly relations +// stay readonly. +class Tagged extends Model { + meta?: any; + tags!: any[]; + friends!: readonly Person[]; +} -takesPersonPojo(personPojo); +const taggedJson = new Tagged().toJSON(); +type _meta = Expect>; +type _tags = Expect>; +type _friends = Expect[]>>; diff --git a/tests/ts/partial-model-object.ts b/tests/ts/partial-model-object.ts index 3c8ef34eb..0b62376c5 100644 --- a/tests/ts/partial-model-object.ts +++ b/tests/ts/partial-model-object.ts @@ -1,11 +1,14 @@ -// Type tests for PartialModelObject, the data accepted by `insert()`, -// `patch()` and the other mutating methods. +// Type tests for PartialModelObject, the data accepted by `fetchGraph()`, and +// PartialModelRow, the data accepted by `insert()`, `patch()` and the other +// mutating methods. -import { Model, PartialModelObject, raw } from '../../'; +import { Model, PartialModelObject, PartialModelRow, raw } from 'objection'; class Pet extends Model { id!: number; name!: string; + meta?: any; + tags?: any[]; owner?: Owner; } @@ -43,7 +46,54 @@ const j: PartialModelObject = { partner: null }; // @ts-expect-error const k: PartialModelObject = { parent: 'Arnold' }; +// Rows take model instances and the properties without relations. +const l: PartialModelRow = { id: 1, name: 'Jennifer' }; +const m: PartialModelRow = new Owner(); +// @ts-expect-error +const n: PartialModelRow = { name: 'Jennifer', pets: [] }; +// @ts-expect-error +const o: PartialModelRow = { parent: null }; +// @ts-expect-error +const p: PartialModelRow = { name: 1 }; + +// The mutating methods don't write relations, so they reject relation data. async function queries() { + await Owner.query().insert({ name: 'Jennifer' }); + await Owner.query().insert([{ name: 'Jennifer' }, new Owner()]); + await Owner.query().patch({ name: 'Arnold' }); + await Owner.query().patch(new Owner()); + await Pet.relatedQuery('owner').for(1).relate({ id: 1 }); + await Pet.relatedQuery('owner').for(1).relate(new Owner()); + // @ts-expect-error await Owner.query().patch({ parent: { name: 'Arnold' } }); + // @ts-expect-error await Owner.query().insert({ name: 'Jennifer', parent: null }); + // @ts-expect-error + await Owner.query().insert({ name: 'Jennifer', pets: [{ name: 'Doggo' }] }); + // @ts-expect-error + await Owner.query().insertAndFetch([{ name: 'Jennifer', partner: new Owner() }]); + // @ts-expect-error + await Owner.query().update({ name: 'Jennifer', pets: [] }); + // @ts-expect-error + await Owner.query().patchAndFetchById(1, { partner: { name: 'Arnold' } }); + // @ts-expect-error + await Owner.query().insert({ name: 'Jennifer' }).onConflict('id').merge({ pets: [] }); + // @ts-expect-error + await Pet.relatedQuery('owner').for(1).relate({ id: 1, pets: [] }); + + // Columns typed `any`, e.g. json columns, take any value. + await Pet.query().insert({ name: 'Doggo', meta: { a: 1 } }); + await Pet.query().patch({ meta: [1, 2] }); + await Pet.query().insert({ name: 'Doggo', tags: ['a', 1] }); + + // Instances are also accepted after withGraphFetched(). + await Owner.query().withGraphFetched('pets').insert(new Owner()); + await Owner.query().withGraphFetched('pets').patch(new Owner()); + await Owner.query().withGraphFetched('pets').findById(1).patchAndFetch(new Owner()); + // @ts-expect-error a plain copy isn't an instance + await Owner.query().patch({ ...new Owner() }); + + // insertGraph() and upsertGraph() take the relations. + await Owner.query().insertGraph({ name: 'Jennifer', pets: [{ name: 'Doggo' }] }); + await Owner.query().upsertGraph({ id: 1, parent: null }); } diff --git a/tests/ts/query-builder-api/eager-loading-methods.ts b/tests/ts/query-builder-api/eager-loading-methods.ts index 1eca5f155..eff26e938 100644 --- a/tests/ts/query-builder-api/eager-loading-methods.ts +++ b/tests/ts/query-builder-api/eager-loading-methods.ts @@ -1,4 +1,4 @@ -import { Person } from '../fixtures/person'; +import { Person } from '../fixtures/person.js'; (async () => { await Person.query().where('firstName', 'Arnold').withGraphFetched('pets'); diff --git a/tests/ts/query-builder-api/find-methods.ts b/tests/ts/query-builder-api/find-methods.ts index 8572c9bb1..8410c5f93 100644 --- a/tests/ts/query-builder-api/find-methods.ts +++ b/tests/ts/query-builder-api/find-methods.ts @@ -1,6 +1,6 @@ -import { raw, ref } from '../../..'; -import { Movie } from '../fixtures/movie'; -import { Person } from '../fixtures/person'; +import { raw, ref } from 'objection'; +import { Movie } from '../fixtures/movie.js'; +import { Person } from '../fixtures/person.js'; (async () => { const person = await Person.query().findById(1); diff --git a/tests/ts/query-builder-api/join-methods.ts b/tests/ts/query-builder-api/join-methods.ts index 3bc312739..ef556ae9b 100644 --- a/tests/ts/query-builder-api/join-methods.ts +++ b/tests/ts/query-builder-api/join-methods.ts @@ -1,5 +1,5 @@ -import { Person } from '../fixtures/person'; -import { raw } from '../../..'; +import { Person } from '../fixtures/person.js'; +import { raw } from 'objection'; (async () => { await Person.query().joinRelated('pets').where('pets.species', 'dog'); @@ -39,6 +39,21 @@ import { raw } from '../../..'; .joinRelated('[pets as pt, parent as pr.[pets as pt, parent as pr]]') .where('pr:pt.species', 'dog'); + await Person.query() + .leftJoinRelated('pets') + .modifyJoin('pets', (join, { owner, related }) => { + const ownerAlias: string = owner; + const relatedAlias: string = related; + join.andOn(`${relatedAlias}.ownerId`, '=', `${ownerAlias}.id`); + }); + + await Person.query() + .withGraphJoined('movies') + .modifyJoin('movies', (join, { joinTable }) => { + const joinTableAlias: string | undefined = joinTable; + join.andOnVal(`${joinTableAlias}.role`, 'lead'); + }); + await Person.query().innerJoinRelated('pets'); await Person.query().outerJoinRelated('pets'); await Person.query().leftJoinRelated('pets'); diff --git a/tests/ts/query-builder-api/mutating-methods.ts b/tests/ts/query-builder-api/mutating-methods.ts index 9a98dbe9c..beef260bd 100644 --- a/tests/ts/query-builder-api/mutating-methods.ts +++ b/tests/ts/query-builder-api/mutating-methods.ts @@ -1,6 +1,6 @@ -import { raw, ref } from '../../../'; -import { Movie } from '../fixtures/movie'; -import { Person } from '../fixtures/person'; +import { raw, ref } from 'objection'; +import { Movie } from '../fixtures/movie.js'; +import { Person } from '../fixtures/person.js'; (async () => { await Person.query().insert({ firstName: 'Jennifer', lastName: 'Lawrence' }); @@ -64,8 +64,7 @@ import { Person } from '../fixtures/person'; age: Person.query().avg('age'), firstName: raw("'Jenni' || 'fer'"), oldLastName: ref('lastName'), - // Unable to support with TypeScript as of Sep 26, 2019 and typescript 3.5.3 - // 'detailsJsonColumn:address.street': 'Elm street' + 'detailsJsonColumn:address.street': 'Elm street', }); await Person.query().patchAndFetchById(134, { age: 24 }); @@ -79,6 +78,20 @@ import { Person } from '../fixtures/person'; let jennifer = await Person.query().findOne({ firstName: 'Jennifer' }); let updatedJennifer = await jennifer!.$query().patchAndFetch({ age: 24 }); + // Field expression keys write parts of json columns, only with patch(). + await Person.query().patchById(1, { 'address:street': 'Elm street', age: 24 }); + await jennifer!.$query().patchAndFetch({ 'address:tags[0]': 'x' }); + await Person.query() + .insert({ firstName: 'Jennifer' }) + .onConflict('id') + .merge({ 'address:street': 'Elm street' }); + // @ts-expect-error not a column + await Person.query().patch({ 'nonColumn:street': 'Elm street' }); + // @ts-expect-error + await Person.query().update({ 'address:street': 'Elm street' }); + // @ts-expect-error + await Person.query().insert({ 'address:street': 'Elm street' }); + await Person.query() .update({ firstName: 'Jennifer', lastName: 'Lawrence', age: 24 }) .where('id', 134); @@ -92,8 +105,6 @@ import { Person } from '../fixtures/person'; await Person.query().update({ lastName: ref('someJsonColumn:mother.lastName').castText(), - // Unable to support with TypeScript as of Sep 26, 2019 and typescript 3.5.3 - // 'detailsJsonColumn:address.street': 'Elm street' }); await Person.query().updateAndFetchById(134, { diff --git a/tests/ts/query-builder-api/other-methods.ts b/tests/ts/query-builder-api/other-methods.ts index 52a38ac43..fb0d37297 100644 --- a/tests/ts/query-builder-api/other-methods.ts +++ b/tests/ts/query-builder-api/other-methods.ts @@ -1,7 +1,6 @@ -import { Person } from '../fixtures/person'; -import { UniqueViolationError } from 'db-errors'; -import { Animal } from '../fixtures/animal'; -import { Model, transaction } from '../../../typings/objection'; +import { Person } from '../fixtures/person.js'; +import { Animal } from '../fixtures/animal.js'; +import { Model, transaction, UniqueViolationError } from 'objection'; (async () => { const debugResult = Person.query().joinRelated('children').where('age', '>', '21').debug(); @@ -113,7 +112,8 @@ import { Model, transaction } from '../../../typings/objection'; builder.toString(); - Person.query().skipUndefined().where('firstName', 'something'); + // @ts-expect-error: There's no skipUndefined(). + Person.query().skipUndefined(); const trx = await transaction.start(Person); builder.transacting(trx); diff --git a/tests/ts/query-examples/basic-queries/delete.ts b/tests/ts/query-examples/basic-queries/delete.ts index b3e0d65e5..bc795e90f 100644 --- a/tests/ts/query-examples/basic-queries/delete.ts +++ b/tests/ts/query-examples/basic-queries/delete.ts @@ -1,5 +1,5 @@ -import { raw } from '../../../../'; -import { Person } from '../../fixtures/person'; +import { raw } from 'objection'; +import { Person } from '../../fixtures/person.js'; (async () => { await Person.query().deleteById(1); diff --git a/tests/ts/query-examples/basic-queries/find.ts b/tests/ts/query-examples/basic-queries/find.ts index d80e147c3..ef984f2c0 100644 --- a/tests/ts/query-examples/basic-queries/find.ts +++ b/tests/ts/query-examples/basic-queries/find.ts @@ -1,5 +1,5 @@ -import { Animal } from '../../fixtures/animal'; -import { Person } from '../../fixtures/person'; +import { Animal } from '../../fixtures/animal.js'; +import { Person } from '../../fixtures/person.js'; (async () => { const person = await Person.query().findById(1); diff --git a/tests/ts/query-examples/basic-queries/insert.ts b/tests/ts/query-examples/basic-queries/insert.ts index 679b52084..a4db361e0 100644 --- a/tests/ts/query-examples/basic-queries/insert.ts +++ b/tests/ts/query-examples/basic-queries/insert.ts @@ -1,11 +1,7 @@ -import { Person } from '../../fixtures/person'; -import { Animal } from '../../fixtures/animal'; +import { Person } from '../../fixtures/person.js'; +import { Animal } from '../../fixtures/animal.js'; -type IPerson = Partial< - Pick & { - pets: Partial>[]; - } ->; +type IPerson = Partial>; (async () => { const jennifer = await Person.query().insert({ @@ -24,32 +20,26 @@ type IPerson = Partial< await Person.query().insert(jenniferObj); await Person.query().insert([jenniferObj]); - // Nested relation data can be plain objects... - await Person.query().insert({ - firstName: 'Jennifer', - mom: { firstName: 'Karen' }, - children: [{ firstName: 'Jane' }], - pets: [{ name: 'Fluffy' }], - }); + // Model instances work too. + await Person.query().insert(Person.fromJson({ firstName: 'Jennifer' })); - // ...or model instances. - await Person.query().insert({ + // insert() doesn't write relations, so relation data is rejected, as plain + // objects... + // @ts-expect-error + await Person.query().insert({ firstName: 'Jennifer', mom: { firstName: 'Karen' } }); + + // ...and as model instances. insertGraph() takes them. + const withPets = { firstName: 'Jennifer', pets: [Animal.fromJson({ name: 'Fluffy' })] }; + // @ts-expect-error + await Person.query().insert(withPets); + await Person.query().insertGraph({ firstName: 'Jennifer', mom: Person.fromJson({ firstName: 'Karen' }), - pets: [Animal.fromJson({ name: 'Fluffy' })], + pets: [{ name: 'Fluffy' }], }); - // Nested relation data is still type checked. // @ts-expect-error firstName must be a string - await Person.query().insert({ mom: { firstName: 1 } }); - // @ts-expect-error unknown property in nested relation data - await Person.query().insert({ mom: { notAProperty: 'foo' } }); - // @ts-expect-error name must be a string - await Person.query().insert({ pets: [{ name: 1 }] }); - // @ts-expect-error pets must be an array - await Person.query().insert({ pets: { name: 'Fluffy' } }); - - const wrongPetObj: { pets: { name: number }[] } = { pets: [{ name: 1 }] }; - // @ts-expect-error name of pets must be a string - await Person.query().insert(wrongPetObj); + await Person.query().insert({ firstName: 1 }); + // @ts-expect-error unknown property + await Person.query().insert({ notAProperty: 'foo' }); })(); diff --git a/tests/ts/query-examples/basic-queries/update.ts b/tests/ts/query-examples/basic-queries/update.ts index 2eacd7df3..33fb835ce 100644 --- a/tests/ts/query-examples/basic-queries/update.ts +++ b/tests/ts/query-examples/basic-queries/update.ts @@ -1,4 +1,4 @@ -import { Person } from '../../fixtures/person'; +import { Person } from '../../fixtures/person.js'; (async () => { let numUpdated = await Person.query().findById(1).patch({ diff --git a/tests/ts/query-examples/eager-loading.ts b/tests/ts/query-examples/eager-loading.ts index 5affb1139..3ed8dabe2 100644 --- a/tests/ts/query-examples/eager-loading.ts +++ b/tests/ts/query-examples/eager-loading.ts @@ -1,4 +1,4 @@ -import { Person } from '../fixtures/person'; +import { Person } from '../fixtures/person.js'; (async () => { await Person.query().withGraphFetched('pets'); diff --git a/tests/ts/query-examples/graph-inserts.ts b/tests/ts/query-examples/graph-inserts.ts index 2405fb3cc..696a505b7 100644 --- a/tests/ts/query-examples/graph-inserts.ts +++ b/tests/ts/query-examples/graph-inserts.ts @@ -1,4 +1,4 @@ -import { Person } from '../fixtures/person'; +import { Person } from '../fixtures/person.js'; (async () => { await Person.query().insertGraph({ @@ -159,6 +159,14 @@ import { Person } from '../fixtures/person'; await Person.query().insertGraph({ firstName: 'Jennifer' }, { allowRefs: true }); - // Deprecated, without effect: + await Person.query().insertGraph( + { firstName: 'Jennifer', pets: [{ name: 'Fluffy' }] }, + { relate: true, noRelate: 'pets', noInsert: ['pets'] }, + ); + + // @ts-expect-error `noDelete` is an option of `upsertGraph()` only + await Person.query().insertGraph({ firstName: 'Jennifer' }, { noDelete: true }); + + // @ts-expect-error: insertGraph() rejects markers, so there's no allowMarkers. await Person.query().insertGraph({ firstName: 'Jennifer' }, { allowMarkers: true }); })(); diff --git a/tests/ts/query-examples/graph-upserts.ts b/tests/ts/query-examples/graph-upserts.ts index e706a0456..7dbf13f79 100644 --- a/tests/ts/query-examples/graph-upserts.ts +++ b/tests/ts/query-examples/graph-upserts.ts @@ -1,6 +1,6 @@ -import { UpsertGraphOptions } from '../../../typings/objection'; -import { Person } from '../fixtures/person'; -import { Animal } from '../fixtures/animal'; +import { UpsertGraphOptions } from 'objection'; +import { Person } from '../fixtures/person.js'; +import { Animal } from '../fixtures/animal.js'; (async () => { await Person.query().upsertGraph({ diff --git a/tests/ts/query-examples/relation-queries/delete.ts b/tests/ts/query-examples/relation-queries/delete.ts index 49a65f091..34b8f1bae 100644 --- a/tests/ts/query-examples/relation-queries/delete.ts +++ b/tests/ts/query-examples/relation-queries/delete.ts @@ -1,4 +1,4 @@ -import { Person } from '../../fixtures/person'; +import { Person } from '../../fixtures/person.js'; (async () => { const numberOfDeletedRows = await Person.query().delete().where('age', '>', 100); diff --git a/tests/ts/query-examples/relation-queries/find.ts b/tests/ts/query-examples/relation-queries/find.ts index a057d3703..9a28b7b3f 100644 --- a/tests/ts/query-examples/relation-queries/find.ts +++ b/tests/ts/query-examples/relation-queries/find.ts @@ -1,4 +1,4 @@ -import { Person } from '../../fixtures/person'; +import { Person } from '../../fixtures/person.js'; (async () => { const person = await Person.query().findById(1); diff --git a/tests/ts/query-examples/relation-queries/insert.ts b/tests/ts/query-examples/relation-queries/insert.ts index ab1714074..39c3b2e17 100644 --- a/tests/ts/query-examples/relation-queries/insert.ts +++ b/tests/ts/query-examples/relation-queries/insert.ts @@ -1,4 +1,4 @@ -import { Person } from '../../fixtures/person'; +import { Person } from '../../fixtures/person.js'; (async () => { const person = (await Person.query().findById(1))!; diff --git a/tests/ts/query-examples/relation-queries/relate.ts b/tests/ts/query-examples/relation-queries/relate.ts index 6bd32e74a..52a8b4b3f 100644 --- a/tests/ts/query-examples/relation-queries/relate.ts +++ b/tests/ts/query-examples/relation-queries/relate.ts @@ -1,5 +1,5 @@ -import { Movie } from '../../fixtures/movie'; -import { Person } from '../../fixtures/person'; +import { Movie } from '../../fixtures/movie.js'; +import { Person } from '../../fixtures/person.js'; (async () => { const person = (await Person.query().findById(123))!; @@ -20,4 +20,11 @@ import { Person } from '../../fixtures/person'; id: 50, someExtra: "I'll be written to the join table", }); + + // With returning(), the rows depend on the relation: join rows, related rows + // or owner rows. They are plain objects, not models. + const rows = await person.$relatedQuery('movies').relate(50).returning('*'); + rows[0].movieId; + // @ts-expect-error + const movies: Movie[] = rows; })(); diff --git a/tests/ts/query-examples/relation-queries/unrelate.ts b/tests/ts/query-examples/relation-queries/unrelate.ts index 6d7fdd575..522758f2c 100644 --- a/tests/ts/query-examples/relation-queries/unrelate.ts +++ b/tests/ts/query-examples/relation-queries/unrelate.ts @@ -1,4 +1,4 @@ -import { Person } from '../../fixtures/person'; +import { Person } from '../../fixtures/person.js'; (async () => { const person = await Person.query().findById(123); diff --git a/tests/ts/query-examples/relation-queries/update.ts b/tests/ts/query-examples/relation-queries/update.ts index abc6b8d14..976878a46 100644 --- a/tests/ts/query-examples/relation-queries/update.ts +++ b/tests/ts/query-examples/relation-queries/update.ts @@ -1,5 +1,5 @@ -import { raw, ref } from '../../../../'; -import { Person } from '../../fixtures/person'; +import { raw, ref } from 'objection'; +import { Person } from '../../fixtures/person.js'; (async () => { const numberOfAffectedRows = await Person.query() @@ -14,7 +14,7 @@ import { Person } from '../../fixtures/person'; }); await Person.query().update({ + firstName: 'Jennifer', lastName: ref('someJsonColumn:mother.lastName').castText(), - 'detailsJsonColumn:address.street': 'Elm street', - } as any); + }); })(); diff --git a/tests/ts/rebind-query-builder.ts b/tests/ts/rebind-query-builder.ts new file mode 100644 index 000000000..30f9e1940 --- /dev/null +++ b/tests/ts/rebind-query-builder.ts @@ -0,0 +1,350 @@ +// Type tests for custom query builders, which declare their type through the +// `RebindType` member. + +import { + AnyQueryBuilder, + MaybeSingleQueryBuilder, + Model, + NumberQueryBuilder, + Page, + PageQueryBuilder, + QueryBuilder, + RelateQueryBuilder, + RelationExpression, + SingleQueryBuilder, + WithGraphOptions, + WithGraphQueryBuilder, +} from 'objection'; + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; + +class MyQueryBuilder extends QueryBuilder { + declare RebindType: MyQueryBuilder; + + someCustomMethod(): this { + return this; + } + + whereName(name: string) { + return this.where('name', name); + } + + firstWithName(name: string) { + return this.whereName(name).first(); + } +} + +class BaseModel extends Model { + static QueryBuilder = MyQueryBuilder; + declare QueryBuilderType: MyQueryBuilder; +} + +class Animal extends BaseModel { + static tableName = 'animals'; + id!: number; + name!: string; + owner?: Person; +} + +class Person extends BaseModel { + static tableName = 'persons'; + id!: number; + name!: string; + pets?: Animal[]; + parent?: Person; +} + +declare const person: Person; + +async function resultKinds() { + const a = Person.query().someCustomMethod(); + type _a = Expect>>; + + const b = Person.query().first().someCustomMethod(); + type _b = Expect>>; + + const c = Person.query().findById(1).someCustomMethod(); + type _c = Expect>>; + + const d = Person.query().findOne({ name: 'Jennifer' }).someCustomMethod(); + type _d = Expect>>; + + const e = Person.query().findById(1).throwIfNotFound().someCustomMethod(); + type _e = Expect>>; + + const f = Person.query().someCustomMethod().throwIfNotFound(); + type _f = Expect>>; + + const g = Person.query().patch({ name: 'Jennifer' }).someCustomMethod(); + type _g = Expect>>; + + const h = Person.query().count().someCustomMethod(); + type _h = Expect>>; + + const i = Person.query().resultSize(); + type _i = Expect>>; + + const j = Person.query().page(0, 10).someCustomMethod(); + type _j = Expect>>>; + + const k = Person.query().range(0, 10).someCustomMethod(); + type _k = Expect>>>; + + const l = Person.query().insert({ name: 'Jennifer' }).someCustomMethod(); + type _l = Expect>>; + + const m = Person.query() + .insert([{ name: 'Jennifer' }]) + .someCustomMethod(); + type _m = Expect>>; + + const n: number = await Person.query().deleteById(1).someCustomMethod(); + const nr = Person.query().deleteById(1).someCustomMethod().returning('*'); + type _nr = Expect>>; + + const o: Person | undefined = await Person.query().first().someCustomMethod(); + const p: number = await Person.query().patch({ name: 'Jennifer' }); + const q: Page = await Person.query().page(0, 10).someCustomMethod(); +} + +async function chainedCustomMethods() { + const a = Person.query().whereName('Jennifer').someCustomMethod(); + type _a = Expect>>; + + const b = Person.query().firstWithName('Jennifer').someCustomMethod(); + type _b = Expect>>; + + const c = Person.query().patch({ name: 'Jennifer' }).firstWithName('Jennifer'); + type _c = Expect>>; + + const d = Person.query() + .someCustomMethod() + .modify((qb) => qb.someCustomMethod().whereName('Jennifer')) + .modifyGraph('pets', (qb) => qb.someCustomMethod().whereName('Fluffy')) + .with('someAlias', (qb) => qb.someCustomMethod().select('id')) + .someCustomMethod(); + type _d = Expect>>; + + const e = Person.query().first().emptyInstance().someCustomMethod(); + type _e = Expect>>; +} + +async function relatedQueries() { + const a = person.$relatedQuery('pets').someCustomMethod(); + type _a = Expect>>; + + const b = person.$relatedQuery('parent').someCustomMethod(); + type _b = Expect>>; + + const c = Person.relatedQuery('pets').for(1).first().someCustomMethod(); + type _c = Expect>>; + + const d = person.$query().someCustomMethod(); + type _d = Expect>>; + + const e = person.$relatedQuery('pets').relate(1).someCustomMethod(); + type _e = Expect>>>; +} + +async function withGraphNarrowing() { + const a = Person.query().withGraphFetched('pets.owner').someCustomMethod(); + const qb: MyQueryBuilder = a; + const as = await a; + as[0].pets[0].owner.name; + // @ts-expect-error + as[0].parent.name; + + const b = await Person.query() + .withGraphJoined('parent') + .findById(1) + .someCustomMethod() + .throwIfNotFound() + .someCustomMethod(); + b.parent.name; + type _b = Expect>; + + const c = await Person.query().first().withGraphFetched('pets').someCustomMethod(); + c?.pets.length; + + const d = await Person.query().withGraphFetched('pets').page(0, 10).someCustomMethod(); + d.results[0].pets.length; + + const e = await Person.relatedQuery('pets').for(1).withGraphFetched('owner').someCustomMethod(); + e[0].owner.name; +} + +// The plain query builder still rebinds to itself. +class PlainModel extends Model { + id!: number; +} + +const plainFirst = PlainModel.query().first(); +type _plainFirst = Expect< + Equal> +>; +const plainPage = PlainModel.query().page(0, 10); +type _plainPage = Expect>>>; + +// Custom query builders that extend other custom query builders. +class MyOtherQueryBuilder extends MyQueryBuilder { + declare RebindType: MyOtherQueryBuilder; + + otherCustomMethod(): this { + return this; + } +} + +class OtherModel extends Model { + static QueryBuilder = MyOtherQueryBuilder; + declare QueryBuilderType: MyOtherQueryBuilder; + id!: number; + name!: string; +} + +const other = OtherModel.query().first().someCustomMethod().otherCustomMethod(); +type _other = Expect>>; + +// Without the `RebindType` declaration, the custom methods are lost. +class ForgetfulQueryBuilder extends QueryBuilder { + someCustomMethod(): this { + return this; + } +} + +class ForgetfulModel extends Model { + static QueryBuilder = ForgetfulQueryBuilder; + declare QueryBuilderType: ForgetfulQueryBuilder; + id!: number; +} + +ForgetfulModel.query().someCustomMethod(); +// @ts-expect-error +ForgetfulModel.query().first().someCustomMethod(); + +async function expectErrors() { + // @ts-expect-error first() may return undefined + const a: Person = await Person.query().first().someCustomMethod(); + // @ts-expect-error the result kind is a number + const b: Person[] = await Person.query().patch({ name: 'Jennifer' }).someCustomMethod(); + // @ts-expect-error not a custom method + Person.query().first().someOtherMethod(); + // @ts-expect-error the custom methods of MyOtherQueryBuilder are not on MyQueryBuilder + Person.query().first().otherCustomMethod(); + // @ts-expect-error a page is not an array + const c: Person[] = await Person.query().page(0, 10).someCustomMethod(); +} + +// Query builders that override methods with the helper types as return types. +class OverridingQueryBuilder extends QueryBuilder { + declare RebindType: OverridingQueryBuilder; + + withGraph>( + expr: E, + options?: WithGraphOptions, + ): WithGraphQueryBuilder { + return super.withGraph(expr, options); + } + + someCustomMethod(): this { + return this; + } + + findById(id: number): MaybeSingleQueryBuilder { + return super.findById(id); + } + + patchAndFetch(data: object): SingleQueryBuilder { + return super.patchAndFetch(data); + } + + delete(): NumberQueryBuilder { + return super.delete(); + } + + page(page: number, pageSize: number): PageQueryBuilder { + return super.page(page, pageSize); + } + + firstAny() { + const query: AnyQueryBuilder = this; + return this.first(); + } +} + +class OverridingModel extends Model { + static QueryBuilder = OverridingQueryBuilder; + declare QueryBuilderType: OverridingQueryBuilder; + id!: number; + name!: string; + parent?: OverridingModel; +} + +async function overridingQueryBuilders() { + type OM = OverridingModel; + const a = OverridingModel.query().first().someCustomMethod(); + type _a = Expect>>; + const b = OverridingModel.query().firstAny().someCustomMethod(); + type _b = Expect>>; + const c = await OverridingModel.query().withGraph('parent').findById(1).someCustomMethod(); + c?.parent.name; + const d: AnyQueryBuilder = OverridingModel.query().page(0, 10); +} + +// The helper types are what the methods return. +function helperTypes(query: MyQueryBuilder) { + const a = query.first(); + type _a = Expect>>>; + const b = query.findById(1).throwIfNotFound(); + type _b = Expect>>>; + const c = query.patch({ name: 'Jennifer' }); + type _c = Expect>>>; + const d = query.page(0, 10); + type _d = Expect>>>; + const e = query.withGraphFetched('pets'); + type _e = Expect, 'pets'>>>; +} + +// fetchGraph() keeps the custom query builder. +async function fetchGraph() { + const a = Person.fetchGraph(person, 'pets').someCustomMethod(); + (await a).pets.length; + const b = Person.fetchGraph([person], 'parent').someCustomMethod(); + (await b)[0].parent.name; + const c = person.$fetchGraph('pets.owner').someCustomMethod(); + (await c).pets[0].owner.name; +} + +// The five `*QueryBuilderType` members of 3.x are gone. +// @ts-expect-error +type _single = MyQueryBuilder['SingleQueryBuilderType']; + +// Inside the methods of a custom query builder, where `this` is generic, the +// methods that change the result kind keep the model and result types. +class GenericThisQueryBuilder extends QueryBuilder { + declare RebindType: GenericThisQueryBuilder; + + custom(): this { + return this; + } + + async methods() { + const a = await this.patch({}); + const n: number = a; + // @ts-expect-error + a.foo; + const b: GenericThisQueryBuilder = this.findById(1).custom(); + // @ts-expect-error + const c: number = this.findById(1); + const d: Page = await this.page(0, 10); + const e: M = await this.insert({}); + // The result kind of writes with returning() depends on R, which + // TypeScript can't resolve here. + const f: M[] | M | undefined = await this.patch({}).returning('*'); + const g: M[] | M | undefined = await this.delete().returning('*'); + const h: M[] | M | undefined = await this.update({}).returning('id'); + const i: M[] | M | undefined = await this.deleteById(1).returning('*').custom(); + const j: M[] | M | undefined = await this.findById(1).patch({}).returning('*'); + } +} diff --git a/tests/ts/returning.ts b/tests/ts/returning.ts new file mode 100644 index 000000000..1953df851 --- /dev/null +++ b/tests/ts/returning.ts @@ -0,0 +1,71 @@ +// Type tests for the result kind of writes with returning(): one row for +// single and maybe-single query builders, e.g. after findById(), first(), +// $query() or $relatedQuery() of a to-one relation, and arrays otherwise. + +import { MaybeSingleQueryBuilder, NumberQueryBuilder, QueryBuilder } from 'objection'; +import { Person } from './fixtures/person.js'; + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; + +async function afterWrite(person: Person) { + const patched = await person.$query().patch({ age: 1 }).returning('*'); + type _patched = Expect>; + const updated = await person.$query().update({ lastName: 'Lawrence' }).returning('*'); + type _updated = Expect>; + const deleted = await person.$query().delete().returning('*'); + type _deleted = Expect>; + + const byId = await Person.query().findById(1).patch({ age: 1 }).returning('*'); + type _byId = Expect>; + const first = await Person.query().first().delete().returning('*'); + type _first = Expect>; + const chained = await Person.query() + .findById(1) + .patch({ age: 1 }) + .where('age', '>', 1) + .returning('*'); + type _chained = Expect>; + const patchById = await Person.query().patchById(1, { age: 1 }).returning('*'); + type _patchById = Expect>; + const updateById = await Person.query().updateById(1, { lastName: 'x' }).returning('*'); + type _updateById = Expect>; + const deleteById = await Person.query().deleteById(1).returning('*'); + type _deleteById = Expect>; + const toOne = await person.$relatedQuery('mom').patch({ age: 1 }).returning('*'); + type _toOne = Expect>; + + const all = await Person.query().patch({ age: 1 }).returning('*'); + type _all = Expect>; + const toOneStatic = await Person.relatedQuery('mom').for(1).delete().returning('*'); + type _toOneStatic = Expect>; + const toMany = await person.$relatedQuery('children').update({ lastName: 'x' }).returning('*'); + type _toMany = Expect>; + + // The *ById() writes are writes of findById(), as the helper types say. + const query = Person.query().patchById(1, { age: 1 }); + type _query = Expect< + Equal>>> + >; + + // Without returning(), writes resolve to the number of rows. + const count = await Person.query().findById(1).patch({ age: 1 }); + type _count = Expect>; +} + +async function beforeWrite(person: Person) { + const patched = await person.$query().returning('*').patch({ age: 1 }); + type _patched = Expect>; + // patch() takes field expression keys, also after returning(). + const field = await person.$query().returning('*').patch({ 'address:street': 'x' }); + type _field = Expect>; + const deleted = await Person.query().findById(1).returning('*').delete(); + type _deleted = Expect>; + const toOne = await person.$relatedQuery('mom').returning('*').update({ lastName: 'x' }); + type _toOne = Expect>; + + // Finds keep their result. + const found = await Person.query().returning('*').where('age', 1); + type _found = Expect>; +} diff --git a/tests/ts/strict/columns.ts b/tests/ts/strict/columns.ts new file mode 100644 index 000000000..35a27ce06 --- /dev/null +++ b/tests/ts/strict/columns.ts @@ -0,0 +1,235 @@ +// Type tests for the column checks of strict mode: column names are checked +// against the model of the query, and qualified with relation paths, against +// the related models. + +import { Model, QueryBuilder, raw, ref } from 'objection'; +import { Animal, CustomPerson, Person } from './models.js'; + +async function rootColumns() { + // Columns of the model: its properties without methods and relations. + await Person.query().where('firstName', 'Jennifer'); + await Person.query().where('lastName', 'Lawrence'); + // @ts-expect-error typo + await Person.query().where('firstNme', 'Jennifer'); + // @ts-expect-error relations aren't columns + await Person.query().where('pets', 1); + // @ts-expect-error methods aren't columns + await Person.query().where('fullName', 1); + // @ts-expect-error neither are model members + await Person.query().where('$modelClass', 1); + + // Qualifiers that aren't relations, like table names and aliases, aren't + // checked, nor are their columns. + await Person.query().where('persons.firstName', 'Jennifer'); + await Person.query().alias('p').where('p.firstName', 'Jennifer'); + await Person.query().join('animals', 'persons.id', 'animals.ownerId').where('animals.x', 1); +} + +// Properties starting with `$` are internal, like in `toJSON()`, not columns. +class AppModel extends Model { + $app!: object; +} + +class Item extends AppModel { + id!: number; +} + +async function internalProperties() { + await Item.query().where('id', 1); + // @ts-expect-error + await Item.query().where('$app', 1); + // A base model with only internal properties has no columns to check. + await AppModel.query().where('anything', 1); +} + +async function relationColumns() { + // Columns qualified with a relation path are checked against the related + // model, in any order: the relations are joined by joinRelated() or + // withGraphJoined(), before or after. + await Person.query().joinRelated('pets').where('pets.name', 'Fluffy'); + await Person.query().where('pets.name', 'Fluffy').joinRelated('pets'); + await Person.query() + .withGraphJoined('pets.owner') + .where('pets:owner.firstName', 'Jennifer') + .orderBy('pets:owner.age'); + await Person.query().where('parent:parent:pets.name', 'x'); + // @ts-expect-error typo in the column of a relation + await Person.query().joinRelated('pets').where('pets.nme', 'Fluffy'); + // @ts-expect-error not a column of the related model + await Person.query().joinRelated('pets').where('pets.age', 1); + // @ts-expect-error typo deeper in the path + await Person.query().where('pets:owner.firstNme', 'Jennifer'); + // A path that isn't one of relations is a qualifier, e.g. an alias. + await Person.query().joinRelated('pets as p').where('p.name', 'Fluffy'); +} + +async function methods() { + const q = Person.query(); + + await q.orWhere('age', '>', 10).andWhereNot('firstName', 'Jennifer'); + await q.whereIn('id', [1, 2]).orWhereNotIn('firstName', ['a', 'b']); + await q.whereIn('id', Animal.query().select('ownerId')); + await q.whereIn(['id', 'age'], [[1, 2]]); + await q.whereBetween('age', [10, 20]); + await q.whereNull('age').orWhereNotNull('lastName'); + await q.whereColumn('firstName', 'lastName').orWhereColumn('id', '>', 'age'); + await q.whereColumn('pets.ownerId', ref('persons.id')); + await q.whereLike('firstName', 'Jen%').orWhereILike('lastName', '%ce'); + await q.whereComposite(['id', 'age'], '=', [1, 2]); + await q.whereInComposite(['id', 'age'], [[1, 2]]); + await q.whereJsonSupersetOf('address:street', { name: 'x' }); + await q.whereJsonIsArray('tags').whereJsonHasAny('address', 'street'); + await q.increment('age', 1).decrement('age'); + await q.orderBy('firstName').orderBy('persons.age', 'desc', 'last'); + await q.orderBy(['firstName', { column: 'age', order: 'desc' }]); + await q.groupBy('firstName', 'lastName').groupBy(['age']); + await q.having('age', '>', 1).havingIn('age', [1]).havingNull('age'); + await q.jsonExtract('address', '$.street', 'street'); + + // @ts-expect-error + await q.whereIn('ids', [1, 2]); + // @ts-expect-error + await q.whereIn(['id', 'agee'], [[1, 2]]); + // @ts-expect-error + await q.whereBetween('ages', [10, 20]); + // @ts-expect-error + await q.whereNull('agee'); + // @ts-expect-error + await q.whereColumn('firstName', 'lastNme'); + // @ts-expect-error + await q.whereLike('firstNme', 'Jen%'); + // @ts-expect-error + await q.whereComposite(['id', 'agee'], '=', [1, 2]); + // @ts-expect-error + await q.whereJsonSupersetOf('adress:street', { name: 'x' }); + // @ts-expect-error + await q.increment('agee', 1); + // @ts-expect-error + await q.orderBy('firstNme'); + // @ts-expect-error + await q.orderBy([{ column: 'agee' }]); + // @ts-expect-error + await q.groupBy('firstName', 'lastNme'); + // @ts-expect-error + await q.having('agee', '>', 1); + // @ts-expect-error + await q.jsonExtract('adress', '$.street'); + + // Objects are checked too. + await q.where({ firstName: 'Jennifer', 'pets.name': 'Fluffy' }); + // @ts-expect-error + await q.where({ firstNme: 'Jennifer' }); + + // `findOne()` takes the same arguments as `where()`. + await Person.query().findOne('firstName', 'Jennifer'); + await Person.query().findOne({ firstName: 'Jennifer' }); + // @ts-expect-error + await Person.query().findOne('firstNme', 'Jennifer'); + + // Callbacks get the query builder of the model. + await Person.query().where((qb) => qb.where('pets.name', 'Fluffy').orWhere('firstName', 'x')); + await Person.query().where((qb) => { + // @ts-expect-error + qb.where('firstNme', 'Jennifer'); + }); +} + +async function selections() { + await Person.query().select('id', 'persons.firstName', 'persons.*', 'pets.*', '*'); + await Person.query().select(['id', 'firstName']); + await Person.query().select('firstName as name', raw('1'), ref('age'), 1); + await Person.query().columns({ name: 'firstName' }).distinct('lastName'); + await Person.query().select(Animal.query().count().as('petCount')); + // @ts-expect-error + await Person.query().select('id', 'firstNme'); + // @ts-expect-error + await Person.query().select('firstNme as name'); + // @ts-expect-error + await Person.query().select({ name: 'firstNme' }); + + // Aggregates check their columns. + await Person.query().count().count('*').count('id', 'age').countDistinct('age'); + await Person.query().count('id as n').count({ n: 'id' }); + await Person.query().min('age').max('persons.age').avg('age').sumDistinct('age'); + // @ts-expect-error + await Person.query().count('agee'); + // @ts-expect-error + await Person.query().min('agee'); +} + +async function unchecked(column: string, personColumn: 'firstName' | 'lastName') { + // Raw and ref() are always accepted, e.g. for columns of other queries. + await Animal.query().whereColumn('animals.ownerId', ref('persons.id')); + await Person.query().where(ref('anything'), 1).where(raw('anything = 1')); + await Person.query().orderBy(raw('anything')).groupBy(ref('anything')); + + // Unions of literals are checked, plain strings need ref(). + await Person.query().where(personColumn, 'Jennifer').orderBy(personColumn); + // @ts-expect-error use ref() for dynamic columns + await Person.query().orderBy(column); + await Person.query().orderBy(ref(column)); + + // Query builders for `any` models and for models without declared + // columns aren't checked. + const anyQuery: QueryBuilder = Person.query(); + await anyQuery.where('anything', 1); + await Model.query().where('anything', 1); + class Untyped extends Model { + static tableName = 'untyped'; + } + await Untyped.query().where('anything', 1).orderBy('anything'); +} + +// Generic code is checked loosely, as the columns can't be resolved there. + +export class GenericQueryBuilder extends QueryBuilder { + declare RebindType: GenericQueryBuilder; + + byName(name: string) { + return this.where('name', name).orderBy('name').findById(1).where('name', name); + } +} + +export class PersonWithMethods extends Model { + static tableName = 'persons'; + id!: number; + pets?: Animal[]; + + petsByName(name: string) { + return this.$relatedQuery('pets').where('name', name); + } + + reload() { + return this.$query().where('id', this.id); + } + + // After withGraphFetched() on a generic query builder, TypeScript resolves + // the narrowed query builder to a union, on which only raw and ref() columns + // are accepted. + withPets() { + return this.$query().withGraphFetched('pets').where(ref('id'), this.id); + } + + withPetsByColumn() { + // @ts-expect-error + return this.$query().withGraphFetched('pets').where('id', this.id); + } +} + +function generic(query: QueryBuilder) { + return query.where('anything', 1).whereIn('anything', [1]).orderBy('anything'); +} + +function genericPersons>(query: QB) { + return query.withGraphFetched('pets').where(ref('anything'), 1); +} + +async function customQueryBuilders() { + await CustomPerson.query().where('firstName', 'Jennifer').someCustomMethod(); + await CustomPerson.query().someCustomMethod().where('firstName', 'Jennifer'); + await CustomPerson.query().first().someCustomMethod().where('pets.name', 'x'); + // @ts-expect-error + await CustomPerson.query().someCustomMethod().where('firstNme', 'Jennifer'); + // Custom query builders remain assignable to QueryBuilder. + const qb: QueryBuilder = CustomPerson.query(); +} diff --git a/tests/ts/strict/declarations.ts b/tests/ts/strict/declarations.ts new file mode 100644 index 000000000..0bb8cbdf4 --- /dev/null +++ b/tests/ts/strict/declarations.ts @@ -0,0 +1,38 @@ +// Type tests for declaration emit: exported functions that return queries +// and rows need nameable types, as in libraries and composite projects. The +// type tests are checked with `declaration: true`. + +import { raw } from 'objection'; +import { CustomPerson, Person, Program } from './models.js'; + +export function selection() { + return Person.query().select('id', 'firstName as name'); +} + +export function rawSelection() { + return Person.query().select(raw('1 as one')); +} + +export function aggregate() { + return Person.query().count('id as n').first(); +} + +export async function rows() { + return await Person.query().select('id').max('age as oldest').groupBy('id'); +} + +export function generated() { + return Program.query().select('id', 'createdAt'); +} + +export function graph() { + return Person.query().withGraphFetched('pets').select('id'); +} + +export function custom() { + return CustomPerson.query().select('id').someCustomMethod(); +} + +export function extraction() { + return Person.query().select('id').jsonExtract('address', '$.street', 'street'); +} diff --git a/tests/ts/strict/from-schema.ts b/tests/ts/strict/from-schema.ts new file mode 100644 index 000000000..8356066b4 --- /dev/null +++ b/tests/ts/strict/from-schema.ts @@ -0,0 +1,46 @@ +// Type tests for strict mode with model properties derived from the JSON +// schema with FromSchema. + +import { FromSchema, Model } from 'objection'; + +class Owner extends Model { + static tableName = 'owners'; + static jsonSchema = { + type: 'object', + required: ['id', 'firstName'], + properties: { + id: { type: 'integer' }, + firstName: { type: 'string' }, + role: { enum: ['admin', 'user'] }, + }, + } as const; + + pets?: Pet[]; +} + +interface Owner extends FromSchema {} + +class Pet extends Model { + static tableName = 'pets'; + static jsonSchema = { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + ownerId: { type: ['integer', 'null'] }, + }, + } as const; +} + +interface Pet extends FromSchema {} + +async function queries() { + await Owner.query().where('firstName', 'Jennifer').where('role', 'admin'); + await Owner.query().joinRelated('pets').where('pets.ownerId', null); + // @ts-expect-error + await Owner.query().where('firstNme', 'Jennifer'); + // @ts-expect-error + await Owner.query().where('role', 'guest'); + // @ts-expect-error + await Owner.query().joinRelated('pets').where('pets.nme', 'Fluffy'); +} diff --git a/tests/ts/strict/models.ts b/tests/ts/strict/models.ts new file mode 100644 index 000000000..ed878f589 --- /dev/null +++ b/tests/ts/strict/models.ts @@ -0,0 +1,88 @@ +import { + Generated, + Model, + Pojo, + QueryBuilder, + RelationExpression, + WithGraphOptions, + WithGraphQueryBuilder, +} from 'objection'; + +export class Program extends Model { + static tableName = 'programs'; + id!: Generated; + name!: string; + createdAt!: Generated; +} + +export class Animal extends Model { + static tableName = 'animals'; + id!: number; + name!: string; + species!: 'dog' | 'cat'; + ownerId!: number | null; + owner?: Person; +} + +export class Person extends Model { + static tableName = 'persons'; + id!: number; + firstName!: string; + lastName?: string; + age!: number | null; + active!: boolean; + createdAt!: Date; + tags!: string[]; + address!: Pojo; + deletedAt!: Date | null; + settings!: { theme: string } | null; + meta!: any; + extra!: unknown; + pets?: Animal[]; + program?: Program; + parent?: Person | null; + children?: Person[]; + + fullName() { + return this.firstName + ' ' + this.lastName; + } +} + +// A custom query builder, as in the custom query builder recipe. + +export class CustomQueryBuilder extends QueryBuilder { + declare RebindType: CustomQueryBuilder; + + someCustomMethod(): this { + return this; + } + + withGraph>( + expr: E, + options?: WithGraphOptions, + ): WithGraphQueryBuilder { + return super.withGraph(expr, options); + } + + firstByName(name: string) { + return this.where('name', name).first(); + } +} + +export class BaseModel extends Model { + declare QueryBuilderType: CustomQueryBuilder; +} + +export class CustomPet extends BaseModel { + static tableName = 'animals'; + id!: number; + name!: string; + owner?: CustomPerson; +} + +export class CustomPerson extends BaseModel { + static tableName = 'persons'; + id!: number; + firstName!: string; + pets?: CustomPet[]; +} diff --git a/tests/ts/strict/relations.ts b/tests/ts/strict/relations.ts new file mode 100644 index 000000000..aae382b58 --- /dev/null +++ b/tests/ts/strict/relations.ts @@ -0,0 +1,34 @@ +// Type tests for relation expressions in strict mode: unknown relations are +// rejected. + +import { Model } from 'objection'; +import { Person } from './models.js'; + +async function relationExpressions() { + await Person.query().joinRelated('pets.owner').withGraphFetched('[children.pets, parent]'); + await Person.query().withGraphJoined({ pets: { owner: true } }); + await Person.query().joinRelated('pets(onlyDogs) as p.owner'); + // @ts-expect-error + await Person.query().joinRelated('petz'); + // @ts-expect-error + await Person.query().joinRelated('[pets.ownr, children]'); + // @ts-expect-error + await Person.query().joinRelated({ petz: true }); + // @ts-expect-error + await Person.query().withGraphFetched('pets.ownr'); + // @ts-expect-error + await Person.query().withGraphJoined('chldren'); + // @ts-expect-error + await Person.query().withGraph('lastName'); + + // Expressions that can't be resolved aren't checked. + const expr: string = 'petz'; + await Person.query().joinRelated(expr).withGraphFetched(expr); + await Person.query().joinRelated('children.^').withGraphFetched('children.*'); + // Nor are models without declared columns. + await Model.query().joinRelated('anything').withGraphFetched('anything'); + + // The narrowing of withGraphFetched() works as in loose mode. + const people = await Person.query().withGraphFetched('pets.owner').where('pets.name', 'x'); + people[0].pets[0].owner.firstName; +} diff --git a/tests/ts/strict/select.ts b/tests/ts/strict/select.ts new file mode 100644 index 000000000..6037f9594 --- /dev/null +++ b/tests/ts/strict/select.ts @@ -0,0 +1,209 @@ +// Type tests for the narrowing of result rows by select() and the aggregates +// in strict mode. + +import { Page, raw, ref } from 'objection'; +import { Animal, CustomPerson, Person } from './models.js'; + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; + +async function selections() { + // Selected columns, also qualified, and aliases with the column's type. + const rows = await Person.query().select('id', 'persons.firstName', 'age as years'); + type Row = (typeof rows)[number]; + type _id = Expect>; + type _firstName = Expect>; + type _years = Expect>; + // @ts-expect-error not selected + rows[0].lastName; + // The rows keep the model's methods and relations. + rows[0].fullName(); + rows[0].pets?.length; + + // Objects select columns under aliases. + const named = await Person.query().columns({ name: 'firstName' }); + type _name = Expect>; + + // Repeated selections add up. + const added = await Person.query().select('id').select('firstName'); + added[0].id; + added[0].firstName; + + // `*` keeps the columns, and adds the other selections, also before and + // after them. + const all: Person[] = await Person.query().select('*'); + const before = await Person.query().select('*').select('id as key'); + type _before = Expect>; + before[0].key; + const after = await Person.query().select('id').select('persons.*'); + type _after = Expect>; + const counted = await Person.query().select('persons.*').count('id as n').groupBy('id'); + counted[0].firstName; + counted[0].n; + const extra = await Person.query().select('persons.*', raw('1 as one')); + extra[0].lastName; + extra[0].one; + + // Raw, ref() and subquery selections can have any alias, of unknown type. + const raws = await Person.query().select('id', raw('count(*) as n')); + type _raw = Expect>; + const counts = await Person.query().select('id', Animal.query().count().as('petCount')); + counts[0].petCount; +} + +async function resultKinds() { + const first = await Person.query().select('id').first(); + type _first = Expect['id'], number>>; + // @ts-expect-error not selected + first?.firstName; + const byId = await Person.query().findById(1).select('firstName').throwIfNotFound(); + byId.firstName; + const aliased = await Person.query().select('age as years').first().throwIfNotFound(); + type _aliased = Expect>; + // @ts-expect-error not selected + byId.age; + const page = await Person.query().select('id').page(0, 10); + page.results[0].id; + type _page = Expect>>; + + // withGraph*() adds the relations, before or after select(). + const before = await Person.query().withGraphFetched('pets').select('id'); + before[0].pets.length; + before[0].id; + const after = await Person.query().select('id').withGraphFetched('pets'); + after[0].pets.length; + // @ts-expect-error not selected + after[0].firstName; + + // Custom query builders keep their type. + const custom = CustomPerson.query().select('id').first().someCustomMethod(); + (await custom)?.id; +} + +async function aggregates() { + const counts = await Person.query().count('id as n').first(); + type _n = Expect['n'], number | string>>; + const named = await Person.query().count('id', { as: 'total' }).countDistinct({ ages: 'age' }); + named[0].total; + named[0].ages; + const extremes = await Person.query().max('age as oldest').min('createdAt as first'); + type _oldest = Expect>; + type _min = Expect>; + const sums = await Person.query().select('firstName').sum('age as total').groupBy('firstName'); + type _sum = Expect>; + sums[0].firstName; +} + +async function aliases() { + // Aliases of selections can be used in orderBy(), but not in where(), as in + // SQL. groupBy() and having() need ref(), as not all databases support them. + await Person.query().select('firstName as name').orderBy('name').groupBy(ref('name')); + await Person.query() + .columns({ name: 'firstName' }) + .orderBy([{ column: 'name' }]); + await Person.query().count('id as n').having(ref('n'), '>', 1).orderBy('n'); + await Person.query().select(raw('count(*) as cnt')).orderBy('cnt'); + await Person.query().count().orderBy('anything'); + await Person.query().select('*').select('id as key').orderBy('key'); + // @ts-expect-error + await Person.query().select('firstName as name').where('name', 'Jennifer'); + // @ts-expect-error + await Person.query().select('firstName as name').groupBy('name'); + // @ts-expect-error + await Person.query().count('id as n').having('n', '>', 1); + // @ts-expect-error + await Person.query().select('firstName as name').orderBy('nme'); + // @ts-expect-error + await Person.query().count('id as n').orderBy('count'); + // Columns that aren't selected can still be used, as in SQL. + await Person.query().select('id').where('age', '>', 1).orderBy('lastName'); + await Person.query().select('id').orderBy(ref('anything')); +} + +async function indexSignature() { + // The rows keep the implicit index signature of object types. + const row: Record = (await Person.query().select('id', 'firstName as name'))[0]; + const counts: Record | undefined = await Person.query().count('id as n').first(); +} + +async function distinctOn() { + // distinctOn() checks its columns, but doesn't select them. + const all = await Person.query().distinctOn('id'); + all[0].firstName; + const selected = await Person.query().distinctOn('id').select('firstName'); + selected[0].firstName; + // @ts-expect-error not selected + selected[0].id; + // @ts-expect-error + await Person.query().distinctOn('nme'); +} + +async function clearSelect() { + // clearSelect() selects all columns again. + const cleared = await Person.query().select('id as key').clearSelect(); + cleared[0].firstName; + // @ts-expect-error not selected + cleared[0].key; + const reselected = await Person.query().select('id').clearSelect().select('firstName'); + reselected[0].firstName; + // @ts-expect-error not selected + reselected[0].id; + const first = await Person.query().count('id as n').clearSelect().first(); + type _first = Expect>; + const custom = CustomPerson.query().select('id').clearSelect().someCustomMethod(); + (await custom)[0].firstName; +} + +async function jsonExtract() { + // jsonExtract() adds its alias, of unknown type, to all columns or to the + // selected ones, before or after select(). + const extracted = await Person.query().jsonExtract('address', '$.street', 'street'); + type _street = Expect>; + extracted[0].firstName; + const before = await Person.query().jsonExtract('address', '$.street', 'street').select('id'); + before[0].street; + // @ts-expect-error not selected + before[0].firstName; + const after = await Person.query().select('id').jsonExtract('address', '$.street', 'street'); + after[0].street; + // @ts-expect-error not selected + after[0].firstName; + await Person.query().jsonExtract('address', '$.street', 'street').orderBy('street'); + // @ts-expect-error + await Person.query().jsonExtract('address', '$.street', 'street').orderBy('town'); + const row: Record = after[0]; + // It survives clearSelect(), as it isn't a select, and withGraph*(). + const cleared = await Person.query().jsonExtract('address', '$.street', 'street').clearSelect(); + cleared[0].street; + const clearedBefore = await Person.query() + .jsonExtract('address', '$.street', 'street') + .select('id') + .clearSelect(); + clearedBefore[0].street; + clearedBefore[0].firstName; + const clearedAfter = await Person.query() + .select('id') + .jsonExtract('address', '$.street', 'street') + .clearSelect() + .first(); + type _clearedAfter = Expect['street'], unknown>>; + clearedAfter?.firstName; + const fetched = await Person.query() + .jsonExtract('address', '$.street', 'street') + .withGraphFetched('pets'); + fetched[0].street; + fetched[0].pets.length; + const joined = await Person.query() + .jsonExtract('address', '$.street', 'street') + .withGraphJoined('pets') + .first() + .throwIfNotFound(); + joined.street; + joined.pets.length; + // Extractions without aliases add any properties. + const unnamed = await Person.query() + .select('id') + .jsonExtract([['address', '$.street']]); + unnamed[0].street; +} diff --git a/tests/ts/strict/snake-case.ts b/tests/ts/strict/snake-case.ts new file mode 100644 index 000000000..aff51710e --- /dev/null +++ b/tests/ts/strict/snake-case.ts @@ -0,0 +1,116 @@ +// Type tests for models that map their columns with snakeCaseMappers(): they +// declare `ColumnNameMapping: 'snakeCase'`, and queries take the columns in +// snake case, while insert(), patch() and the results use the properties. + +import { Model, ModelObject, snakeCaseMappers } from 'objection'; + +class BaseModel extends Model { + static columnNameMappers = snakeCaseMappers(); + declare ColumnNameMapping: 'snakeCase'; +} + +class Animal extends BaseModel { + static tableName = 'animals'; + id!: number; + ownerId!: number | null; + owner?: Person; +} + +class Person extends BaseModel { + static tableName = 'persons'; + id!: number; + firstName!: string; + address2Line!: string; + homeURL!: string; + createdAt!: Date; + pets?: Animal[]; +} + +async function queries() { + await Person.query().where('first_name', 'Jennifer').orderBy('created_at'); + await Person.query().where('address2_line', 'x').where('home_url', 'x'); + await Person.query().select('id', 'first_name as name').whereIn('first_name', ['a']); + await Person.query().where({ first_name: 'Jennifer' }); + await Person.query().joinRelated('pets').where('pets.owner_id', 1); + await Person.query().where('pets:owner.first_name', 'x'); + // @ts-expect-error the property name isn't the column + await Person.query().where('firstName', 'Jennifer'); + // @ts-expect-error + await Person.query().where({ firstName: 'Jennifer' }); + // @ts-expect-error + await Person.query().where('pets.ownerId', 1); + // @ts-expect-error values are still checked + await Person.query().where('first_name', 1); + await Person.query().where('created_at', '<', '2020-01-01'); + + // Rows and results use the properties. + await Person.query().insert({ firstName: 'Jennifer', address2Line: 'x' }); + await Person.query().patch({ firstName: 'Jennifer' }); + const people = await Person.query().where('first_name', 'Jennifer'); + people[0].firstName; + + // Selected columns and aliases come back in camel case. + const rows = await Person.query().select('first_name', 'home_url as main_url'); + rows[0].firstName.toUpperCase(); + rows[0].mainUrl.toUpperCase(); + // @ts-expect-error + rows[0].first_name; + // @ts-expect-error not selected + rows[0].createdAt; + // orderBy() takes the aliases as written in SQL. + await Person.query().select('home_url as main_url').orderBy('main_url'); + // @ts-expect-error + await Person.query().select('home_url as main_url').orderBy('mainUrl'); +} + +// The declaration isn't a property of the model data. +// @ts-expect-error +type _object = ModelObject['ColumnNameMapping']; + +// Long property names convert too. +class Long extends BaseModel { + id!: number; + aVeryLongPropertyNameThatGoesOnAndOnForMoreThanSixtyCharacters!: string; +} + +async function longNames() { + await Long.query() + .where('id', 1) + .where('a_very_long_property_name_that_goes_on_and_on_for_more_than_sixty_characters', 'x'); +} + +// A typo in the mapping is an error. +class Typo extends BaseModel { + // @ts-expect-error + declare ColumnNameMapping: 'snake_case'; +} + +// snakeCaseMappers({ upperCase: true }) maps to upper case snake case. +class UpperModel extends Model { + static columnNameMappers = snakeCaseMappers({ upperCase: true }); + declare ColumnNameMapping: 'upperSnakeCase'; + id!: number; + firstName!: string; +} + +async function upperCase() { + await UpperModel.query().where('FIRST_NAME', 'Jennifer').orderBy('ID'); + // @ts-expect-error + await UpperModel.query().where('first_name', 'Jennifer'); + const rows = await UpperModel.query().select('FIRST_NAME', 'ID as RECORD_ID'); + rows[0].firstName.toUpperCase(); + rows[0].recordId.toFixed(); + // @ts-expect-error not selected + rows[0].id; + await UpperModel.query().select('ID as RECORD_ID').orderBy('RECORD_ID'); +} + +// Models with other column name mappers declare 'open', and aren't checked. +class Custom extends Model { + declare ColumnNameMapping: 'open'; + id!: number; +} + +async function open() { + await Custom.query().where('anything', 1); +} diff --git a/tests/ts/strict/tsconfig.json b/tests/ts/strict/tsconfig.json new file mode 100644 index 000000000..62db2e3fd --- /dev/null +++ b/tests/ts/strict/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["./**/*.ts"], + "exclude": [] +} diff --git a/tests/ts/strict/values.ts b/tests/ts/strict/values.ts new file mode 100644 index 000000000..aa17c5466 --- /dev/null +++ b/tests/ts/strict/values.ts @@ -0,0 +1,94 @@ +// Type tests for the value checks of strict mode: values of conditions are +// checked against the types of their columns. + +import { raw, ref, val } from 'objection'; +import { Animal, Person, Program } from './models.js'; + +async function values() { + await Person.query().where('age', 30).where('firstName', 'Jennifer'); + await Person.query().where('active', true).where('tags', ['a']); + // @ts-expect-error + await Person.query().where('age', 'x'); + // @ts-expect-error + await Person.query().where('firstName', 1); + // @ts-expect-error + await Person.query().where('active', 1); + + // null is always accepted, for `IS NULL` checks. + await Person.query().where('firstName', null); + + // Optional properties accept their defined type. + await Person.query().where('lastName', 'Lawrence'); + // @ts-expect-error + await Person.query().where('lastName', 1); + + // Literal union types, e.g. enums. + await Animal.query().where('species', 'dog'); + // @ts-expect-error + await Animal.query().where('species', 'cow'); + + // Dates accept strings too. + await Person.query().where('createdAt', '>', new Date()).where('createdAt', '<', '2020-01-01'); + // @ts-expect-error + await Person.query().where('createdAt', '>', 1); + + // Generated columns are checked against the types they wrap. + await Program.query().where('id', 1).where('createdAt', '<', '2020-01-01'); + // @ts-expect-error + await Program.query().where('id', 'x'); + // @ts-expect-error + await Program.query().where('createdAt', '<', 1); + + // Object (JSON) columns accept anything. + await Person.query().where('address', { street: 'x' }); + await Person.query().where('settings', '@>', { theme: 'dark', other: 1 }); + await Person.query().where('meta', { a: 1 }).where('extra', '@>', { a: 1 }); + await Person.query().where({ meta: { a: 1 } }); + + // Nullable dates accept strings too. + await Person.query().where('deletedAt', '<', '2020-01-01').where('deletedAt', null); + await Person.query().whereBetween('deletedAt', ['2020-01-01', new Date()]); + await Person.query().where({ deletedAt: '2020-01-01' }); + + // Expressions are accepted for any column. + await Person.query() + .where('age', ref('persons.id')) + .where('age', raw('1')) + .where('age', val(1)) + .where('age', Person.query().max('age')); + + // With operators, arrays are accepted too, for 'in' and 'between'. + await Person.query().where('age', '>', 30).where('age', 'in', [1, 2]); + // @ts-expect-error + await Person.query().where('age', '>', 'x'); + // @ts-expect-error + await Person.query().where('age', 'in', ['x']); + + // Columns of relations. + await Person.query().joinRelated('pets').where('pets.species', 'cat'); + // @ts-expect-error + await Person.query().joinRelated('pets').where('pets.species', 'cow'); + // Qualifiers that aren't relations accept any value. + await Person.query().where('persons.age', 'x'); + + // whereIn(), whereBetween() and objects. + await Person.query().whereIn('age', [1, 2]).whereBetween('age', [1, 2]); + await Person.query().where({ age: 30, firstName: 'Jennifer' }); + // @ts-expect-error + await Person.query().whereIn('age', ['x']); + // @ts-expect-error + await Person.query().whereBetween('age', [1, 'x']); + // @ts-expect-error + await Person.query().where({ age: 'x' }); + + // Operators other than comparisons take any value. + await Animal.query().where('species', 'like', 'd%').where('species', 'ILIKE', 'D%'); + await Person.query().where('age', '~', '^1'); + // @ts-expect-error comparisons are still checked + await Animal.query().where('species', '=', 'cow'); + // @ts-expect-error + await Animal.query().where('species', 'in', ['cow']); + + // Like methods take strings, regardless of the column type. + await Person.query().whereLike('age', '1%'); +} diff --git a/tests/ts/transactions/creating.ts b/tests/ts/transactions/creating.ts index 665556182..cb274cb56 100644 --- a/tests/ts/transactions/creating.ts +++ b/tests/ts/transactions/creating.ts @@ -1,5 +1,5 @@ -import { transaction } from '../../../'; -import { Person } from '../fixtures/person'; +import { transaction } from 'objection'; +import { Person } from '../fixtures/person.js'; (async () => { try { diff --git a/tests/ts/transactions/using.ts b/tests/ts/transactions/using.ts index d59b6172f..826b84cf3 100644 --- a/tests/ts/transactions/using.ts +++ b/tests/ts/transactions/using.ts @@ -1,6 +1,6 @@ -import { transaction, Transaction } from '../../../'; -import { Animal } from '../fixtures/animal'; -import { Person } from '../fixtures/person'; +import { PartialModelRow, transaction, Transaction } from 'objection'; +import { Animal } from '../fixtures/animal.js'; +import { Person } from '../fixtures/person.js'; import { Knex } from 'knex'; (async () => { @@ -22,8 +22,8 @@ import { Knex } from 'knex'; } async function insertPersonAndPet( - personAttrs: Partial, - petAttrs: Partial, + personAttrs: PartialModelRow, + petAttrs: PartialModelRow, trxOrKnex?: Transaction | Knex, ) { const person = await Person.query(trxOrKnex).insert(personAttrs); diff --git a/tests/ts/type-config.ts b/tests/ts/type-config.ts new file mode 100644 index 000000000..186cdb3be --- /dev/null +++ b/tests/ts/type-config.ts @@ -0,0 +1,11 @@ +// The tests in this directory check the loose typings: strict mode is turned +// off for the whole compilation, as the augmentation is global. The tests of +// strict mode in ./strict run with their own tsconfig.json. + +declare module 'objection' { + interface TypeConfig { + strict: false; + } +} + +export {}; diff --git a/tests/ts/typed-relation-mappings.ts b/tests/ts/typed-relation-mappings.ts index 75c23a883..a6b37ca2a 100644 --- a/tests/ts/typed-relation-mappings.ts +++ b/tests/ts/typed-relation-mappings.ts @@ -1,7 +1,7 @@ -import { Model, RelationMappings, TypedRelationMappings } from '../../'; -import { Animal } from './fixtures/animal'; -import { Movie } from './fixtures/movie'; -import { Person } from './fixtures/person'; +import { Model, RelationMappings, TypedRelationMappings } from 'objection'; +import { Animal } from './fixtures/animal.js'; +import { Movie } from './fixtures/movie.js'; +import { Person } from './fixtures/person.js'; // Thunk form: the classes referenced in the mappings don't need to be // initialized when the class is declared. @@ -70,6 +70,11 @@ class Pet extends Model { modelClass: Owner, join: { from: 'pets.ownerId', to: 'owners.id' }, }, + friends: { + relation: Model.BelongsToManyRelation, + modelClass: Pet, + join: { from: 'pets.friendIds', to: 'pets.id' }, + }, } satisfies TypedRelationMappings; } @@ -150,6 +155,19 @@ class WrongCardinalityMany extends Model { } satisfies TypedRelationMappings; } +class WrongCardinalityKeys extends Model { + owner?: Person; + + static relationMappings = { + owner: { + // @ts-expect-error a BelongsToManyRelation can't hold a single model + relation: Model.BelongsToManyRelation, + modelClass: Person, + join: { from: 'a.ownerIds', to: 'persons.id' }, + }, + } satisfies TypedRelationMappings; +} + class WrongModelClass extends Model { owner?: Person; diff --git a/tests/ts/validation.ts b/tests/ts/validation.ts index 9b60460c8..8d109259a 100644 --- a/tests/ts/validation.ts +++ b/tests/ts/validation.ts @@ -1,5 +1,5 @@ -import { ValidationError } from '../../typings/objection'; -import { Person } from './fixtures/person'; +import { ValidationError } from 'objection'; +import { Person } from './fixtures/person.js'; (async () => { const person = Person.fromJson({ firstName: 'jennifer', lastName: 'Lawrence' }); diff --git a/tests/ts/with-graph-narrowing/examples.ts b/tests/ts/with-graph-narrowing/examples.ts index aa7e60003..ce666d6b1 100644 --- a/tests/ts/with-graph-narrowing/examples.ts +++ b/tests/ts/with-graph-narrowing/examples.ts @@ -4,8 +4,8 @@ // Positive cases are plain property accesses or `Expect>` // assertions, negative cases use `@ts-expect-error`. -import { AnyQueryBuilder, Model, Page, QueryBuilder } from '../../../'; -import { Animal, CustomPerson, CustomPet, CustomQueryBuilder, Person, Program } from './models'; +import { AnyQueryBuilder, Model, Page, QueryBuilder } from 'objection'; +import { Animal, CustomPerson, CustomPet, CustomQueryBuilder, Person, Program } from './models.js'; type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; @@ -307,7 +307,7 @@ async function relatedQueries() { c[0].parent?.id; const d = await person.$relatedQuery('parent').withGraphFetched('pets'); - type _d = Expect>; + type _d = Expect['pets'], Animal[]>>; const e = await person.$query().withGraphFetched('pets'); type _e = Expect>; diff --git a/tests/ts/with-graph-narrowing/models.ts b/tests/ts/with-graph-narrowing/models.ts index 8eda2b23b..b07051f63 100644 --- a/tests/ts/with-graph-narrowing/models.ts +++ b/tests/ts/with-graph-narrowing/models.ts @@ -1,4 +1,4 @@ -import { Model, Page, QueryBuilder } from '../../../'; +import { Model, QueryBuilder } from 'objection'; export class Program extends Model { static tableName = 'programs'; @@ -33,11 +33,7 @@ export class Person extends Model { // Models with a custom query builder, as in the custom query builder recipe. export class CustomQueryBuilder extends QueryBuilder { - declare ArrayQueryBuilderType: CustomQueryBuilder; - declare SingleQueryBuilderType: CustomQueryBuilder; - declare MaybeSingleQueryBuilderType: CustomQueryBuilder; - declare NumberQueryBuilderType: CustomQueryBuilder; - declare PageQueryBuilderType: CustomQueryBuilder>; + declare RebindType: CustomQueryBuilder; someCustomMethod(): this { return this; diff --git a/tests/unit/model/AjvValidator.js b/tests/unit/model/AjvValidator.js index 486b60378..f291d6642 100644 --- a/tests/unit/model/AjvValidator.js +++ b/tests/unit/model/AjvValidator.js @@ -1,8 +1,9 @@ -const Ajv = require('ajv'); -const addFormats = require('ajv-formats'); -const { AjvValidator, Model, raw, val, ref } = require('../../../'); -const expect = require('expect.js'); -const Knex = require('knex'); +import { describe, it, expect } from 'vitest'; +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import { AjvValidator, Model, raw, val, ref } from 'objection'; +import Knex from 'knex'; +import { expectThrows } from '../../../testUtils/testUtils.js'; function modelClass(tableName, schema) { return class TestModel extends Model { @@ -34,15 +35,15 @@ describe('AjvValidator', () => { const ModelA = modelClass('test', schemaA); const ModelB = modelClass('test', schemaB); - expect(ModelA.uniqueTag()).to.equal(ModelB.uniqueTag()); + expect(ModelA.uniqueTag()).toBe(ModelB.uniqueTag()); const validatorA = validator.getValidator(ModelA, ModelA.getJsonSchema(), false); const validatorB = validator.getValidator(ModelB, ModelB.getJsonSchema(), false); - expect(validatorA).to.not.be(validatorB); - expect(validatorA({ a: 'x' })).to.be(true); - expect(validatorB({ a: 'x' })).to.be(false); - expect(validatorB({ b: 'x' })).to.be(true); + expect(validatorA).not.toBe(validatorB); + expect(validatorA({ a: 'x' })).toBe(true); + expect(validatorB({ a: 'x' })).toBe(false); + expect(validatorB({ b: 'x' })).toBe(true); }); it('should not recompile validators for bound model classes with a shared validator', () => { @@ -76,16 +77,16 @@ describe('AjvValidator', () => { const BoundModel1 = TestModel.bindKnex(Knex({ client: 'pg' })); const BoundModel2 = TestModel.bindKnex(Knex({ client: 'pg' })); - expect(BoundModel1).to.not.be(BoundModel2); - expect(BoundModel1.getJsonSchema()).to.not.be(BoundModel2.getJsonSchema()); + expect(BoundModel1).not.toBe(BoundModel2); + expect(BoundModel1.getJsonSchema()).not.toBe(BoundModel2.getJsonSchema()); BoundModel1.fromJson({ a: 'x' }); BoundModel2.fromJson({ a: 'x' }); TestModel.fromJson({ a: 'x' }); - expect(compileCount).to.be(1); - expect(validator.cache.size).to.be(1); - expect(() => BoundModel2.fromJson({})).to.throwException(); + expect(compileCount).toBe(1); + expect(validator.cache.size).toBe(1); + expect(() => BoundModel2.fromJson({})).toThrow(); }); it('should cache validators separately for patch and non-patch validation', () => { @@ -96,11 +97,11 @@ describe('AjvValidator', () => { const normalValidator = validator.getValidator(ModelA, jsonSchema, false); const patchValidator = validator.getValidator(ModelA, jsonSchema, true); - expect(normalValidator).to.not.be(patchValidator); - expect(validator.getValidator(ModelA, jsonSchema, false)).to.be(normalValidator); - expect(validator.getValidator(ModelA, jsonSchema, true)).to.be(patchValidator); - expect(normalValidator({})).to.be(false); - expect(patchValidator({})).to.be(true); + expect(normalValidator).not.toBe(patchValidator); + expect(validator.getValidator(ModelA, jsonSchema, false)).toBe(normalValidator); + expect(validator.getValidator(ModelA, jsonSchema, true)).toBe(patchValidator); + expect(normalValidator({})).toBe(false); + expect(patchValidator({})).toBe(true); }); it('should reuse validators for equal schemas returned by $beforeValidate', () => { @@ -110,9 +111,9 @@ describe('AjvValidator', () => { const validator1 = validator.getValidator(ModelA, JSON.parse(JSON.stringify(schemaB)), false); const validator2 = validator.getValidator(ModelA, JSON.parse(JSON.stringify(schemaB)), false); - expect(validator1).to.be(validator2); - expect(validator1({ b: 'x' })).to.be(true); - expect(validator1({ a: 'x' })).to.be(false); + expect(validator1).toBe(validator2); + expect(validator1({ b: 'x' })).toBe(true); + expect(validator1({ a: 'x' })).toBe(false); }); }); @@ -220,8 +221,8 @@ describe('AjvValidator', () => { const validators = validator.getValidator(modelClass('test', schema), schema, true); const definitions = Object.values(validators.schema.definitions); - expect(definitions.length).to.be(2); - definitions.forEach((d) => expect(d.required).to.be(undefined)); + expect(definitions.length).toBe(2); + definitions.forEach((d) => expect(d.required).toBeUndefined()); }); it('should remove required fields from $defs', () => { @@ -229,8 +230,8 @@ describe('AjvValidator', () => { const validators = validator.getValidator(modelClass('test', schemaBis), schemaBis, true); const $defs = Object.values(validators.schema.$defs); - expect($defs.length).to.be(2); - $defs.forEach((d) => expect(d.required).to.be(undefined)); + expect($defs.length).toBe(2); + $defs.forEach((d) => expect(d.required).toBeUndefined()); }); it('should not remove required fields if there is a discriminator', () => { @@ -240,21 +241,21 @@ describe('AjvValidator', () => { }, }); const validators = validator.getValidator(modelClass('test', schema2), schema2, true); - expect(validators.schema.required).to.eql(['foo']); + expect(validators.schema.required).toEqual(['foo']); }); it('should add ajv formats by default', () => { expect(() => { const validator = new AjvValidator({}); validator.getValidator(modelClass('test', schema3), schema3, true); - }).to.not.throwException(); + }).not.toThrow(); }); it('should remove required fields in inner properties', () => { const validator = new AjvValidator({}); const validators = validator.getValidator(modelClass('test', schema4), schema4, true); - expect(validators.schema.properties.address.properties).to.not.be(undefined); - expect(validators.schema.properties.address.required).to.be(undefined); + expect(validators.schema.properties.address.properties).not.toBeUndefined(); + expect(validators.schema.properties.address.required).toBeUndefined(); }); it('should not throw errors when adding formats in onCreateAjv hook', () => { @@ -264,7 +265,7 @@ describe('AjvValidator', () => { addFormats(ajv); }, }); - }).to.not.throwException(); + }).not.toThrow(); }); it('should handle empty definitions', () => { @@ -316,9 +317,9 @@ describe('AjvValidator', () => { }, }; const validate = patchValidator(schema); - expect(validate.schema.oneOf).to.eql([{ not: { required: ['a'] } }]); - expect(validate({ b: 'str' })).to.be(true); - expect(validate({ a: 'str' })).to.be(false); + expect(validate.schema.oneOf).toEqual([{ not: { required: ['a'] } }]); + expect(validate({ b: 'str' })).toBe(true); + expect(validate({ a: 'str' })).toBe(false); }); for (const prop of ['anyOf', 'oneOf']) { @@ -331,10 +332,10 @@ describe('AjvValidator', () => { }, }; const validate = patchValidator(schema); - expect(validate.schema[prop]).to.be(undefined); - expect(validate({ a: 'str', b: 'str' })).to.be(true); - expect(validate({ b: 1 })).to.be(true); - expect(validate({ a: 1 })).to.be(false); + expect(validate.schema[prop]).toBeUndefined(); + expect(validate({ a: 'str', b: 'str' })).toBe(true); + expect(validate({ b: 1 })).toBe(true); + expect(validate({ a: 1 })).toBe(false); }); it(`should not require nested objects to match other ${prop} options if one only has required`, () => { @@ -355,11 +356,11 @@ describe('AjvValidator', () => { }, }; const validate = patchValidator(schema); - expect(validate.schema.properties.data[prop]).to.be(undefined); - expect(validate({ data: {} })).to.be(true); - expect(validate({ data: { b: 1 } })).to.be(true); - expect(validate({ data: { b: 'str' } })).to.be(true); - expect(validate({ data: { a: 1 } })).to.be(false); + expect(validate.schema.properties.data[prop]).toBeUndefined(); + expect(validate({ data: {} })).toBe(true); + expect(validate({ data: { b: 1 } })).toBe(true); + expect(validate({ data: { b: 'str' } })).toBe(true); + expect(validate({ data: { a: 1 } })).toBe(false); }); } @@ -368,8 +369,8 @@ describe('AjvValidator', () => { allOf: [{ required: ['a'] }, { required: ['b'], properties: { b: { type: 'number' } } }], }; const validate = patchValidator(schema); - expect(validate.schema.allOf).to.eql([{ properties: { b: { type: 'number' } } }]); - expect(validate({ b: 'str' })).to.be(false); + expect(validate.schema.allOf).toEqual([{ properties: { b: { type: 'number' } } }]); + expect(validate({ b: 'str' })).toBe(false); }); it('should keep boolean schemas', () => { @@ -388,15 +389,15 @@ describe('AjvValidator', () => { }, }; const validate = patchValidator(schema); - expect(validate.schema.properties.b).to.be(false); - expect(validate.schema.properties.c).to.be(true); - expect(validate.schema.properties.d.anyOf).to.eql([false, { type: 'string' }]); - expect(validate.schema.properties.e.then).to.be(false); - expect(validate.schema.definitions.f).to.be(false); - expect(validate({ a: 'str', c: 1, d: 'str', e: 1 })).to.be(true); - expect(validate({ b: 1 })).to.be(false); - expect(validate({ d: 1 })).to.be(false); - expect(validate({ e: 'str' })).to.be(false); + expect(validate.schema.properties.b).toBe(false); + expect(validate.schema.properties.c).toBe(true); + expect(validate.schema.properties.d.anyOf).toEqual([false, { type: 'string' }]); + expect(validate.schema.properties.e.then).toBe(false); + expect(validate.schema.definitions.f).toBe(false); + expect(validate({ a: 'str', c: 1, d: 'str', e: 1 })).toBe(true); + expect(validate({ b: 1 })).toBe(false); + expect(validate({ d: 1 })).toBe(false); + expect(validate({ e: 'str' })).toBe(false); }); it('should keep top-level not schemas intact', () => { @@ -411,10 +412,10 @@ describe('AjvValidator', () => { }, }; const validate = patchValidator(schema); - expect(validate.schema.required).to.be(undefined); - expect(validate.schema.not).to.eql({ required: ['b', 'c'] }); - expect(validate({ b: 'str' })).to.be(true); - expect(validate({ b: 'str', c: 'str' })).to.be(false); + expect(validate.schema.required).toBeUndefined(); + expect(validate.schema.not).toEqual({ required: ['b', 'c'] }); + expect(validate({ b: 'str' })).toBe(true); + expect(validate({ b: 'str', c: 'str' })).toBe(false); }); it('should remove required fields in inner properties of nullable objects', () => { @@ -437,11 +438,11 @@ describe('AjvValidator', () => { }, }; const validate = patchValidator(schema); - expect(validate.schema.properties.address.required).to.be(undefined); - expect(validate.schema.properties.address.properties.inner.required).to.be(undefined); - expect(validate({ address: { zip: '123', inner: {} } })).to.be(true); - expect(validate({ address: null })).to.be(true); - expect(validate({ address: { zip: 123 } })).to.be(false); + expect(validate.schema.properties.address.required).toBeUndefined(); + expect(validate.schema.properties.address.properties.inner.required).toBeUndefined(); + expect(validate({ address: { zip: '123', inner: {} } })).toBe(true); + expect(validate({ address: null })).toBe(true); + expect(validate({ address: { zip: 123 } })).toBe(false); }); it('should remove required fields in inner properties of untyped schemas', () => { @@ -458,10 +459,10 @@ describe('AjvValidator', () => { }, }; const validate = patchValidator(schema); - expect(validate.schema.required).to.be(undefined); - expect(validate.schema.properties.address.required).to.be(undefined); - expect(validate({ address: { zip: '123' } })).to.be(true); - expect(validate({ address: { zip: 123 } })).to.be(false); + expect(validate.schema.required).toBeUndefined(); + expect(validate.schema.properties.address.required).toBeUndefined(); + expect(validate({ address: { zip: '123' } })).toBe(true); + expect(validate({ address: { zip: 123 } })).toBe(false); }); it('should keep properties named like schema keywords in untyped schemas', () => { @@ -474,12 +475,12 @@ describe('AjvValidator', () => { }, }; const validate = patchValidator(schema); - expect(validate.schema.properties).to.eql(schema.properties); - expect(validate({ required: 'a', not: 'b', anyOf: 'c', properties: 'd' })).to.be(true); - expect(validate({ required: 1 })).to.be(false); - expect(validate({ not: 1 })).to.be(false); - expect(validate({ anyOf: 1 })).to.be(false); - expect(validate({ properties: 1 })).to.be(false); + expect(validate.schema.properties).toEqual(schema.properties); + expect(validate({ required: 'a', not: 'b', anyOf: 'c', properties: 'd' })).toBe(true); + expect(validate({ required: 1 })).toBe(false); + expect(validate({ not: 1 })).toBe(false); + expect(validate({ anyOf: 1 })).toBe(false); + expect(validate({ properties: 1 })).toBe(false); }); }); @@ -531,7 +532,7 @@ describe('AjvValidator', () => { try { fn(); } catch (err) { - expect(err).to.be.a(TestModel.ValidationError); + expect(err).toBeInstanceOf(TestModel.ValidationError); return err.data; } throw new Error('expected a validation error'); @@ -539,17 +540,17 @@ describe('AjvValidator', () => { it('should validate field expression keys against the nested schema', () => { const model = TestModel.fromJson({ 'meta:b': 'foo' }, { patch: true }); - expect(model['meta:b']).to.equal('foo'); + expect(model['meta:b']).toBe('foo'); const data = validationErrorData(() => TestModel.fromJson({ 'meta:b': 1 }, { patch: true })); - expect(Object.keys(data)).to.eql(['meta.b']); - expect(data['meta.b'][0].keyword).to.equal('type'); + expect(Object.keys(data)).toEqual(['meta.b']); + expect(data['meta.b'][0].keyword).toBe('type'); }); it('should not fail on nested required properties of untouched siblings', () => { expect(() => { TestModel.fromJson({ 'meta:nested.y': 1, 'meta:b': 'foo' }, { patch: true }); - }).to.not.throwException(); + }).not.toThrow(); }); it('should validate multiple field expression keys of the same column', () => { @@ -558,61 +559,61 @@ describe('AjvValidator', () => { { name: 'foo', 'meta:a': 'a', 'meta:b': 'b', 'meta:nested.x': 1 }, { patch: true }, ); - }).to.not.throwException(); + }).not.toThrow(); const data = validationErrorData(() => TestModel.fromJson({ 'meta:a': 1, 'meta:nested.x': 'x' }, { patch: true }), ); - expect(Object.keys(data).sort()).to.eql(['meta.a', 'meta.nested.x']); + expect(Object.keys(data).sort()).toEqual(['meta.a', 'meta.nested.x']); }); it('should report additional properties inside the json column', () => { const data = validationErrorData(() => TestModel.fromJson({ 'meta:c': 'foo' }, { patch: true }), ); - expect(Object.keys(data)).to.eql(['meta.c']); - expect(data['meta.c'][0].keyword).to.equal('additionalProperties'); + expect(Object.keys(data)).toEqual(['meta.c']); + expect(data['meta.c'][0].keyword).toBe('additionalProperties'); }); it('should report unknown columns of field expression keys', () => { const data = validationErrorData(() => TestModel.fromJson({ 'unknown:b': 'foo' }, { patch: true }), ); - expect(Object.keys(data)).to.eql(['unknown']); - expect(data.unknown[0].keyword).to.equal('additionalProperties'); + expect(Object.keys(data)).toEqual(['unknown']); + expect(data.unknown[0].keyword).toBe('additionalProperties'); }); it('should support table prefixes in field expression keys', () => { expect(() => { TestModel.fromJson({ 'test.meta:b': 'foo' }, { patch: true }); - }).to.not.throwException(); + }).not.toThrow(); }); it('should not validate field expression keys with array access', () => { expect(() => { TestModel.fromJson({ 'tags:[0]': 'foo', 'meta:nested[0]': 1 }, { patch: true }); - }).to.not.throwException(); + }).not.toThrow(); }); it('should not validate field expression keys with numeric keys', () => { expect(() => { TestModel.fromJson({ 'tags:0': 'foo', 'tags:-1': 'bar' }, { patch: true }); TestModel.fromJson({ 'meta:nested.1.x': 'foo' }, { patch: true }); - }).to.not.throwException(); + }).not.toThrow(); }); it('should not modify the input json', () => { const json = { name: 'foo', 'meta:b': 'foo' }; const model = TestModel.fromJson(json, { patch: true }); - expect(json).to.eql({ name: 'foo', 'meta:b': 'foo' }); - expect(model).to.eql({ name: 'foo', 'meta:b': 'foo' }); + expect(json).toEqual({ name: 'foo', 'meta:b': 'foo' }); + expect(model).toEqual({ name: 'foo', 'meta:b': 'foo' }); }); it('should apply type coercion to field expression values', () => { const CoercingModel = createModelClass({ coerceTypes: true }); const model = CoercingModel.fromJson({ 'meta:b': 1, 'meta:nested.x': '2' }, { patch: true }); - expect(model['meta:b']).to.equal('1'); - expect(model['meta:nested.x']).to.equal(2); + expect(model['meta:b']).toBe('1'); + expect(model['meta:nested.x']).toBe(2); }); it('should validate keys with a colon that the schema declares as properties', () => { @@ -621,11 +622,12 @@ describe('AjvValidator', () => { additionalProperties: false, properties: { 'ns:name': { type: 'string' } }, }); - expect(ColonModel.fromJson({ 'ns:name': 'x' }, { patch: true })['ns:name']).to.equal('x'); - expect(() => ColonModel.fromJson({ 'ns:name': 1 }, { patch: true })).to.throwException( + expect(ColonModel.fromJson({ 'ns:name': 'x' }, { patch: true })['ns:name']).toBe('x'); + expectThrows( + () => ColonModel.fromJson({ 'ns:name': 1 }, { patch: true }), (err) => { - expect(Object.keys(err.data)).to.eql(['ns:name']); - expect(err.data['ns:name'][0].keyword).to.equal('type'); + expect(Object.keys(err.data)).toEqual(['ns:name']); + expect(err.data['ns:name'][0].keyword).toBe('type'); }, ); }); @@ -636,89 +638,7 @@ describe('AjvValidator', () => { { 'meta:b': 'foo', 'meta:c': 'bar', 'unknown:a': 1 }, { patch: true }, ); - expect(model).to.eql({ 'meta:b': 'foo' }); - }); - }); - - describe('update validation with field expression keys', () => { - const schema = { - type: 'object', - additionalProperties: false, - required: ['name', 'meta'], - properties: { - id: { type: 'integer' }, - name: { type: 'string' }, - status: { type: 'string', default: 'new' }, - meta: { - type: 'object', - additionalProperties: false, - required: ['a'], - default: { a: 'default' }, - properties: { - a: { type: 'string' }, - b: { type: 'string' }, - }, - }, - }, - }; - - const TestModel = modelClass('test', schema); - - function validationErrorData(fn) { - try { - fn(); - } catch (err) { - expect(err).to.be.a(TestModel.ValidationError); - return err.data; - } - throw new Error('expected a validation error'); - } - - it('should validate field expression keys against the nested schema', () => { - const data = validationErrorData(() => TestModel.fromJson({ name: 'foo', 'meta:b': 1 })); - expect(Object.keys(data)).to.eql(['meta.b']); - expect(data['meta.b'][0].keyword).to.equal('type'); - }); - - it('should count the json column as present, without its required properties', () => { - expect(TestModel.fromJson({ name: 'foo', 'meta:b': 'foo' })['meta:b']).to.equal('foo'); - expect(TestModel.fromJson({ name: 'foo', 'meta:[0]': 'foo' })).to.eql({ - name: 'foo', - status: 'new', - 'meta:[0]': 'foo', - }); - }); - - it('should report the errors of the field expressions and the other properties together', () => { - const data = validationErrorData(() => TestModel.fromJson({ status: 1, 'meta:b': 1 })); - expect(Object.keys(data).sort()).to.eql(['meta.b', 'name', 'status']); - }); - - it('should resolve $refs in the schema of the json column', () => { - const RefModel = modelClass('test', { - type: 'object', - required: ['name', 'meta'], - properties: { - name: { type: 'string' }, - meta: { $ref: '#/definitions/Meta' }, - }, - definitions: { - Meta: { - type: 'object', - required: ['size'], - properties: { - size: { type: 'integer' }, - color: { type: 'string' }, - }, - }, - }, - }); - - expect(RefModel.fromJson({ name: 'foo', 'meta:color': 'red' })['meta:color']).to.equal('red'); - expect(() => RefModel.fromJson({ name: 'foo', 'meta:color': 1 })).to.throwException((err) => { - expect(Object.keys(err.data)).to.eql(['meta.color']); - expect(err.data['meta.color'][0].keyword).to.equal('type'); - }); + expect(model).toEqual({ 'meta:b': 'foo' }); }); it('should resolve external $refs in the schema of the json column', () => { @@ -748,109 +668,18 @@ describe('AjvValidator', () => { } }; - expect(RefModel.fromJson({ name: 'foo', 'meta:color': 'red' })['meta:color']).to.equal('red'); - expect(() => RefModel.fromJson({ name: 'foo', 'meta:color': 1 })).to.throwException((err) => { - expect(Object.keys(err.data)).to.eql(['meta.color']); - expect(err.data['meta.color'][0].keyword).to.equal('type'); - }); - // Other properties with the same schema are still validated normally: - expect(() => - RefModel.fromJson({ name: 'foo', 'meta:color': 'red', other: { color: 'red' } }), - ).to.throwException((err) => { - expect(Object.keys(err.data)).to.eql(['other.size']); - expect(err.data['other.size'][0].keyword).to.equal('required'); - }); - expect(RefModel.fromJson({ 'meta:color': 'red' }, { patch: true })['meta:color']).to.equal( - 'red', - ); - }); - - it('should validate keys with a colon that the schema declares as properties', () => { - const ColonModel = modelClass('test', { - type: 'object', - additionalProperties: false, - required: ['ns:name'], - properties: { 'ns:name': { type: 'string' } }, - }); - expect(ColonModel.fromJson({ 'ns:name': 'x' })['ns:name']).to.equal('x'); - expect(() => ColonModel.fromJson({})).to.throwException((err) => { - expect(Object.keys(err.data)).to.eql(['ns:name']); - expect(err.data['ns:name'][0].keyword).to.equal('required'); - }); - }); - - it('should not compile new validators for each set of written json columns', () => { - const fromJson = (column) => { - expect(() => TestModel.fromJson({ name: 'foo', [`${column}:a`]: 1 })).to.throwException( - (err) => { - expect(Object.keys(err.data)).to.eql([column]); - }, - ); - }; - - fromJson('unknown'); - // Count the compilations of all Ajv instances: - const { compile } = Ajv.prototype; - let compileCount = 0; - Ajv.prototype.compile = function (...args) { - ++compileCount; - return compile.apply(this, args); - }; - - try { - for (let i = 0; i < 10; ++i) { - fromJson(`unknown${i}`); - } - } finally { - Ajv.prototype.compile = compile; - } - - expect(compileCount).to.be(0); - }); - - it('should still require the other required properties', () => { - const data = validationErrorData(() => TestModel.fromJson({ 'meta:b': 'foo' })); - expect(Object.keys(data)).to.eql(['name']); - expect(data.name[0].keyword).to.equal('required'); - }); - - it('should not set the default value of the json column', () => { - const model = TestModel.fromJson({ name: 'foo', 'meta:b': 'foo' }); - expect(model).to.eql({ name: 'foo', status: 'new', 'meta:b': 'foo' }); - }); - - it('should work together with required properties given as query properties', () => { - expect(TestModel.fromJson({ name: raw('?', 'foo'), 'meta:b': 'foo' })['meta:b']).to.equal( - 'foo', - ); - const data = validationErrorData(() => - TestModel.fromJson({ name: raw('?', 'foo'), 'meta:b': 1 }), + expect(RefModel.fromJson({ 'meta:color': 'red' }, { patch: true })['meta:color']).toBe('red'); + expectThrows( + () => RefModel.fromJson({ 'meta:color': 1 }, { patch: true }), + (err) => { + expect(Object.keys(err.data)).toEqual(['meta.color']); + expect(err.data['meta.color'][0].keyword).toBe('type'); + }, ); - expect(Object.keys(data)).to.eql(['meta.b']); - }); - - it('should count the json column of field expressions with query property values as present', () => { - const model = TestModel.fromJson({ name: 'foo', 'meta:b': ref('name') }); - expect(model.name).to.equal('foo'); - expect(model.status).to.equal('new'); - expect(model.meta).to.be(undefined); - const data = validationErrorData(() => TestModel.fromJson({ 'meta:b': raw('?', 'foo') })); - expect(Object.keys(data)).to.eql(['name']); - - const NoDefaultModel = modelClass('test', { - type: 'object', - required: ['name', 'meta'], - properties: { name: { type: 'string' }, meta: { type: 'object' } }, + // Whole values of the referenced schema are patched without its required properties too: + expect(RefModel.fromJson({ other: { color: 'red' } }, { patch: true }).other).toEqual({ + color: 'red', }); - expect(NoDefaultModel.fromJson({ name: 'foo', 'meta:b': ref('name') }).name).to.equal('foo'); - }); - - it('should not change the validation of the json column without field expressions', () => { - const data = validationErrorData(() => - TestModel.fromJson({ name: 'foo', meta: { b: 'foo' } }), - ); - expect(Object.keys(data)).to.eql(['meta.a']); - expect(TestModel.fromJson({ name: 'foo' }).meta).to.eql({ a: 'default' }); }); }); @@ -869,7 +698,7 @@ describe('AjvValidator', () => { try { fn(); } catch (err) { - expect(err).to.be.a(TestModel.ValidationError); + expect(err).toBeInstanceOf(TestModel.ValidationError); return err.data; } throw new Error('expected a validation error'); @@ -880,30 +709,30 @@ describe('AjvValidator', () => { a: val(['x']).asArray().castTo('uuid[]'), b: raw('?', 1), }); - expect(model.c).to.equal('c'); - expect(TestModel.fromJson({ a: ['x'], b: ref('c') }).a).to.eql(['x']); - expect(TestModel.fromJson({ a: TestModel.query().select('a'), b: 1 }).b).to.equal(1); + expect(model.c).toBe('c'); + expect(TestModel.fromJson({ a: ['x'], b: ref('c') }).a).toEqual(['x']); + expect(TestModel.fromJson({ a: TestModel.query().select('a'), b: 1 }).b).toBe(1); }); it('should still require the other required properties', () => { const data = validationErrorData(() => TestModel.fromJson({ a: raw('?', 1) })); - expect(Object.keys(data)).to.eql(['b']); - expect(data.b[0].keyword).to.equal('required'); - expect(Object.keys(validationErrorData(() => TestModel.fromJson({ b: raw('1') })))).to.eql([ + expect(Object.keys(data)).toEqual(['b']); + expect(data.b[0].keyword).toBe('required'); + expect(Object.keys(validationErrorData(() => TestModel.fromJson({ b: raw('1') })))).toEqual([ 'a', ]); }); it('should still validate the other properties', () => { const data = validationErrorData(() => TestModel.fromJson({ a: raw('?', 1), b: 'x' })); - expect(Object.keys(data)).to.eql(['b']); - expect(data.b[0].keyword).to.equal('type'); + expect(Object.keys(data)).toEqual(['b']); + expect(data.b[0].keyword).toBe('type'); }); it('should not affect the validation of models without query properties', () => { TestModel.fromJson({ a: raw('?', 1), b: raw('?', 1) }); const data = validationErrorData(() => TestModel.fromJson({})); - expect(Object.keys(data).sort()).to.eql(['a', 'b']); + expect(Object.keys(data).sort()).toEqual(['a', 'b']); }); it('should work with schemas that have an $id', () => { @@ -922,12 +751,15 @@ describe('AjvValidator', () => { }); IdModel.fromJson({ a: 'x', b: 1 }); - expect(IdModel.fromJson({ a: 'x', b: raw('?', 1) }).a).to.equal('x'); - expect(IdModel.fromJson({ a: raw('?', 'x'), b: 1 }).b).to.equal(1); - expect(IdModel.fromJson({ a: raw('?', 'x'), b: raw('?', 1) })).to.be.an(IdModel); - expect(() => IdModel.fromJson({ a: raw('?', 'x'), b: 'x' })).to.throwException((err) => { - expect(err.data.b[0].keyword).to.equal('type'); - }); + expect(IdModel.fromJson({ a: 'x', b: raw('?', 1) }).a).toBe('x'); + expect(IdModel.fromJson({ a: raw('?', 'x'), b: 1 }).b).toBe(1); + expect(IdModel.fromJson({ a: raw('?', 'x'), b: raw('?', 1) })).toBeInstanceOf(IdModel); + expectThrows( + () => IdModel.fromJson({ a: raw('?', 'x'), b: 'x' }), + (err) => { + expect(err.data.b[0].keyword).toBe('type'); + }, + ); }); it('should resolve $refs to the schemas of other model classes', () => { @@ -955,10 +787,11 @@ describe('AjvValidator', () => { }); RefTarget.fromJson({ a: 'x' }); - expect(RefSource.fromJson({ a: raw('1'), target: { a: 'x' } }).target).to.eql({ a: 'x' }); - expect(() => RefSource.fromJson({ a: raw('1'), target: { a: 1 } })).to.throwException( + expect(RefSource.fromJson({ a: raw('1'), target: { a: 'x' } }).target).toEqual({ a: 'x' }); + expectThrows( + () => RefSource.fromJson({ a: raw('1'), target: { a: 1 } }), (err) => { - expect(err.data['target.a'][0].keyword).to.equal('type'); + expect(err.data['target.a'][0].keyword).toBe('type'); }, ); }); diff --git a/tests/unit/model/Model.js b/tests/unit/model/Model.js index 1cf650b3d..76d9b1cff 100644 --- a/tests/unit/model/Model.js +++ b/tests/unit/model/Model.js @@ -1,7 +1,7 @@ -const expect = require('expect.js'); -const { Model, QueryBuilder, ValidationError, raw, fn } = require('../../../'); -const { snakeCase, camelCase } = require('../../../lib/utils/identifierMapping'); -const { range, sortBy } = require('../../../testUtils/testUtils'); +import { describe, it, expect, beforeEach } from 'vitest'; +import { Model, QueryBuilder, ValidationError, raw, fn } from 'objection'; +import { snakeCase, camelCase } from '../../../lib/utils/identifierMapping.js'; +import { expectThrows, range, sortBy } from '../../../testUtils/testUtils.js'; describe('Model', () => { describe('fromJson', () => { @@ -15,26 +15,26 @@ describe('Model', () => { let json = { a: 1, b: 2, c: { d: 'str1' }, e: [3, 4, { f: 'str2' }] }; let model = Model1.fromJson(json); - expect(model.a).to.equal(1); - expect(model.b).to.equal(2); - expect(model.c.d).to.equal('str1'); - expect(model.e[0]).to.equal(3); - expect(model.e[1]).to.equal(4); - expect(model.e[2].f).to.equal('str2'); + expect(model.a).toBe(1); + expect(model.b).toBe(2); + expect(model.c.d).toBe('str1'); + expect(model.e[0]).toBe(3); + expect(model.e[1]).toBe(4); + expect(model.e[2].f).toBe('str2'); }); it('should skip properties starting with $', () => { let model = Model1.fromJson({ a: 1, $b: 2 }); - expect(model.a).to.equal(1); - expect(model).to.not.have.property('$b'); + expect(model.a).toBe(1); + expect(model).not.toHaveProperty('$b'); }); it('should skip functions', () => { let model = Model1.fromJson({ a: 1, b: () => {} }); - expect(model.a).to.equal(1); - expect(model).to.not.have.property('b'); + expect(model.a).toBe(1); + expect(model).not.toHaveProperty('b'); }); it('should call $parseJson', () => { @@ -44,16 +44,16 @@ describe('Model', () => { Model1.prototype.$parseJson = function (jsn, opt) { ++calls; - expect(jsn).to.eql(json); - expect(opt).to.eql(options); + expect(jsn).toEqual(json); + expect(opt).toEqual(options); return { c: 3 }; }; let model = Model1.fromJson(json, options); - expect(model).to.not.have.property('a'); - expect(model.c).to.equal(3); - expect(calls).to.equal(1); + expect(model).not.toHaveProperty('a'); + expect(model.c).toBe(3); + expect(calls).toBe(1); }); it('should validate if jsonSchema is defined', () => { @@ -85,62 +85,80 @@ describe('Model', () => { expect(() => { Model1.fromJson({ a: 'str', b: 1 }); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({ a: 'str' }); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({ a: 'a', c: { d: 'test' } }); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({ a: 'a', c: { d: 'test', e: [{ f: 1 }] } }); - }).to.not.throwException(); + }).not.toThrow(); - expect(() => { - Model1.fromJson({ a: 1, b: '1' }); - }).to.throwException((exp) => { - expect(exp).to.be.a(ValidationError); - expect(exp.data).to.have.property('a'); - expect(exp.data).to.have.property('b'); - }); + expectThrows( + () => { + Model1.fromJson({ a: 1, b: '1' }); + }, + (exp) => { + expect(exp).toBeInstanceOf(ValidationError); + expect(exp.data).toHaveProperty('a'); + expect(exp.data).toHaveProperty('b'); + }, + ); - expect(() => { - Model1.fromJson({ b: 1 }); - }).to.throwException((exp) => { - expect(exp).to.be.a(ValidationError); - expect(exp.data).to.have.property('a'); - }); + expectThrows( + () => { + Model1.fromJson({ b: 1 }); + }, + (exp) => { + expect(exp).toBeInstanceOf(ValidationError); + expect(exp.data).toHaveProperty('a'); + }, + ); - expect(() => { - Model1.fromJson({ a: 'a', additional: 1 }); - }).to.throwException((exp) => { - expect(exp).to.be.a(ValidationError); - expect(exp.data).to.have.property('additional'); - }); + expectThrows( + () => { + Model1.fromJson({ a: 'a', additional: 1 }); + }, + (exp) => { + expect(exp).toBeInstanceOf(ValidationError); + expect(exp.data).toHaveProperty('additional'); + }, + ); - expect(() => { - Model1.fromJson({ a: 'a', c: { d: 10 } }); - }).to.throwException((exp) => { - expect(exp).to.be.a(ValidationError); - expect(exp.data).to.have.property('c.d'); - }); + expectThrows( + () => { + Model1.fromJson({ a: 'a', c: { d: 10 } }); + }, + (exp) => { + expect(exp).toBeInstanceOf(ValidationError); + expect(exp.data).toHaveProperty(['c.d']); + }, + ); - expect(() => { - Model1.fromJson({ a: 'a', c: { d: 'test', e: [{ f: 'not a number' }] } }); - }).to.throwException((exp) => { - expect(exp).to.be.a(ValidationError); - expect(exp.data).to.have.property('c.e.0.f'); - }); + expectThrows( + () => { + Model1.fromJson({ a: 'a', c: { d: 'test', e: [{ f: 'not a number' }] } }); + }, + (exp) => { + expect(exp).toBeInstanceOf(ValidationError); + expect(exp.data).toHaveProperty(['c.e.0.f']); + }, + ); - expect(() => { - Model1.fromJson({ a: 'a', c: { d: 'test', e: [{ additional: true }] } }); - }).to.throwException((exp) => { - expect(exp).to.be.a(ValidationError); - expect(exp.data).to.have.property('c.e.0.additional'); - }); + expectThrows( + () => { + Model1.fromJson({ a: 'a', c: { d: 'test', e: [{ additional: true }] } }); + }, + (exp) => { + expect(exp).toBeInstanceOf(ValidationError); + expect(exp.data).toHaveProperty(['c.e.0.additional']); + }, + ); }); it('should call $validate if jsonSchema is defined', () => { @@ -160,17 +178,15 @@ describe('Model', () => { Model.prototype.$validate.call(this, jsn, opt); ++calls; - expect(opt).to.eql(options); - expect(jsn).to.eql(json); + expect(opt).toEqual(options); + expect(jsn).toEqual(json); }; expect(() => { Model1.fromJson(json, options); - }).to.not.throwException((err) => { - console.log(err.stack); - }); + }).not.toThrow(); - expect(calls).to.equal(1); + expect(calls).toBe(1); }); it('should only call jsonSchema once if jsonSchema is a getter', () => { @@ -199,7 +215,7 @@ describe('Model', () => { model.$toJson(); model.$toDatabaseJson(); - expect(calls).to.equal(1); + expect(calls).toBe(1); }); it('should call $beforeValidate if jsonSchema is defined', () => { @@ -218,9 +234,9 @@ describe('Model', () => { Model1.prototype.$beforeValidate = function (schema, jsn, opt) { ++calls; - expect(opt).to.eql(options); - expect(jsn).to.eql(json); - expect(schema).to.eql(Model1.jsonSchema); + expect(opt).toEqual(options); + expect(jsn).toEqual(json); + expect(schema).toEqual(Model1.jsonSchema); schema.properties.a.type = 'number'; return schema; @@ -228,9 +244,9 @@ describe('Model', () => { expect(() => { Model1.fromJson(json, options); - }).to.not.throwException(); + }).not.toThrow(); - expect(calls).to.equal(1); + expect(calls).toBe(1); }); it('should call $afterValidate if jsonSchema is defined', () => { @@ -248,15 +264,15 @@ describe('Model', () => { Model1.prototype.$afterValidate = function (jsn, opt) { ++calls; - expect(opt).to.eql(options); - expect(jsn).to.eql(json); + expect(opt).toEqual(options); + expect(jsn).toEqual(json); }; expect(() => { Model1.fromJson(json, options); - }).to.not.throwException(); + }).not.toThrow(); - expect(calls).to.equal(1); + expect(calls).toBe(1); }); it('should skip requirement validation if options.patch == true', () => { @@ -270,24 +286,27 @@ describe('Model', () => { expect(() => { Model1.fromJson({ a: 'str', b: 1 }, { patch: true }); - }).to.not.throwException(); + }).not.toThrow(); // b is not required. expect(() => { Model1.fromJson({ a: 'str' }, { patch: true }); - }).to.not.throwException(); + }).not.toThrow(); - expect(() => { - Model1.fromJson({ a: 1, b: '1' }, { patch: true }); - }).to.throwException((exp) => { - expect(exp).to.be.a(ValidationError); - expect(exp.data).to.have.property('a'); - expect(exp.data).to.have.property('b'); - }); + expectThrows( + () => { + Model1.fromJson({ a: 1, b: '1' }, { patch: true }); + }, + (exp) => { + expect(exp).toBeInstanceOf(ValidationError); + expect(exp.data).toHaveProperty('a'); + expect(exp.data).toHaveProperty('b'); + }, + ); expect(() => { Model1.fromJson({ b: 1 }, { patch: true }); - }).to.not.throwException(); + }).not.toThrow(); }); it('should skip requirement validation if options.patch == true (oneOf)', () => { @@ -310,19 +329,19 @@ describe('Model', () => { expect(() => { Model1.fromJson({ c: 'str' }); - }).to.throwException(); + }).toThrow(); expect(() => { Model1.fromJson({ a: 'str' }); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({ b: 1 }); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({ c: 'str' }, { patch: true }); - }).to.not.throwException((err) => console.log(err)); + }).not.toThrow(); }); it('should skip requirement validation if options.patch == true (anyOf)', () => { @@ -345,19 +364,19 @@ describe('Model', () => { expect(() => { Model1.fromJson({ c: 'str' }); - }).to.throwException(); + }).toThrow(); expect(() => { Model1.fromJson({ a: 'str' }); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({ b: 1 }); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({ c: 'str' }, { patch: true }); - }).to.not.throwException((err) => console.log(err)); + }).not.toThrow(); }); it('should skip requirement validation if options.patch == true (if/then)', () => { @@ -385,27 +404,27 @@ describe('Model', () => { expect(() => { Model1.fromJson({ a: 'foo' }); - }).to.throwException(); + }).toThrow(); expect(() => { Model1.fromJson({ a: 'bar' }); - }).to.throwException(); + }).toThrow(); expect(() => { Model1.fromJson({ a: 'foo', b: 1 }); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({ a: 'bar', c: 'baz' }); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({ a: 'foo' }, { patch: true }); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({ a: 'bar' }, { patch: true }); - }).to.not.throwException(); + }).not.toThrow(); }); it('should skip validation if options.skipValidation == true', () => { @@ -419,19 +438,19 @@ describe('Model', () => { expect(() => { Model1.fromJson({ a: 'str', b: 1 }, { skipValidation: true }); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({ a: 'str' }, { skipValidation: true }); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({ a: 1, b: '1' }, { skipValidation: true }); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({ b: 1 }, { skipValidation: true }); - }).to.not.throwException(); + }).not.toThrow(); }); it('should merge default values from jsonSchema', () => { @@ -448,10 +467,10 @@ describe('Model', () => { let model = Model1.fromJson({ a: 'str' }); - expect(model.a).to.equal('str'); - expect(model.b).to.equal(666); - expect(model.c).to.eql(obj); - expect(model.c).to.not.equal(obj); + expect(model.a).toBe('str'); + expect(model.b).toBe(666); + expect(model.c).toEqual(obj); + expect(model.c).not.toBe(obj); }); it('should merge default values from jsonSchema when validating a model instance', () => { @@ -468,15 +487,15 @@ describe('Model', () => { let model = Model1.fromJson({ a: 'str' }, { skipValidation: true }); - expect(model.b).to.equal(undefined); - expect(model.c).to.equal(undefined); + expect(model.b).toBeUndefined(); + expect(model.c).toBeUndefined(); model.$validate(); - expect(model.a).to.equal('str'); - expect(model.b).to.equal(666); - expect(model.c).to.eql(obj); - expect(model.c).to.not.equal(obj); + expect(model.a).toBe('str'); + expect(model.b).toBe(666); + expect(model.c).toEqual(obj); + expect(model.c).not.toBe(obj); }); // regression introduced in 0.6 @@ -488,7 +507,7 @@ describe('Model', () => { let model = Model1.fromJson({ a: 100 }); - expect(model.a).to.equal(100); + expect(model.a).toBe(100); }); it('should validate but not pass if jsonSchema.required exists and jsonSchema.properties == undefined', () => { @@ -498,9 +517,7 @@ describe('Model', () => { expect(() => { Model1.fromJson({ b: 200 }); - }).to.throwException((exp) => { - expect(exp).to.be.a(ValidationError); - }); + }).toThrow(ValidationError); }); it('should not merge default values from jsonSchema if options.patch == true', () => { @@ -517,9 +534,9 @@ describe('Model', () => { let model = Model1.fromJson({ b: 10 }, { patch: true }); - expect(model).to.not.have.property('a'); - expect(model.b).to.equal(10); - expect(model).to.not.have.property('c'); + expect(model).not.toHaveProperty('a'); + expect(model.b).toBe(10); + expect(model).not.toHaveProperty('c'); }); it('should throw with error context if validation fails', () => { @@ -531,27 +548,30 @@ describe('Model', () => { }, }; - expect(() => { - Model1.fromJson({ b: 'abc' }); - }).to.throwException((exp) => { - expect(exp).to.be.a(ValidationError); - expect(exp.data).to.have.property('a'); - expect(exp.data['a']).to.be.a(Array); - expect(exp.data['a'].length).to.be.above(0); - expect(exp.data['a'][0]).to.have.property('message'); - expect(exp.data['a'][0]).to.have.property('keyword'); - expect(exp.data['a'][0]).to.have.property('params'); - expect(exp.data['a'][0].keyword).to.equal('required'); - expect(exp.data).to.have.property('b'); - expect(exp.data['b']).to.be.a(Array); - expect(exp.data['b'].length).to.be.above(0); - expect(exp.data['b'][0]).to.have.property('message'); - expect(exp.data['b'][0]).to.have.property('keyword'); - expect(exp.data['b'][0]).to.have.property('params'); - expect(exp.data['b'][0].keyword).to.equal('minLength'); - expect(exp.data['b'][0].params).to.have.property('limit'); - expect(exp.data['b'][0].params.limit).to.equal(4); - }); + expectThrows( + () => { + Model1.fromJson({ b: 'abc' }); + }, + (exp) => { + expect(exp).toBeInstanceOf(ValidationError); + expect(exp.data).toHaveProperty('a'); + expect(exp.data['a']).toBeInstanceOf(Array); + expect(exp.data['a'].length).toBeGreaterThan(0); + expect(exp.data['a'][0]).toHaveProperty('message'); + expect(exp.data['a'][0]).toHaveProperty('keyword'); + expect(exp.data['a'][0]).toHaveProperty('params'); + expect(exp.data['a'][0].keyword).toBe('required'); + expect(exp.data).toHaveProperty('b'); + expect(exp.data['b']).toBeInstanceOf(Array); + expect(exp.data['b'].length).toBeGreaterThan(0); + expect(exp.data['b'][0]).toHaveProperty('message'); + expect(exp.data['b'][0]).toHaveProperty('keyword'); + expect(exp.data['b'][0]).toHaveProperty('params'); + expect(exp.data['b'][0].keyword).toBe('minLength'); + expect(exp.data['b'][0].params).toHaveProperty('limit'); + expect(exp.data['b'][0].params.limit).toBe(4); + }, + ); }); it('should throw if anything non-object is given', () => { @@ -559,59 +579,59 @@ describe('Model', () => { expect(() => { Model1.fromJson(); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson(null); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson(undefined); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson({}); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson(new SomeClass()); - }).to.not.throwException(); + }).not.toThrow(); expect(() => { Model1.fromJson('hello'); - }).to.throwException(); + }).toThrow(); expect(() => { Model1.fromJson(new String('hello')); - }).to.throwException(); + }).toThrow(); expect(() => { Model1.fromJson(1); - }).to.throwException(); + }).toThrow(); expect(() => { Model1.fromJson(new Number(1)); - }).to.throwException(); + }).toThrow(); expect(() => { Model1.fromJson([{ a: 1 }]); - }).to.throwException(); + }).toThrow(); expect(() => { Model1.fromJson(/.*/); - }).to.throwException(); + }).toThrow(); expect(() => { Model1.fromJson(new Date()); - }).to.throwException(); + }).toThrow(); expect(() => { Model1.fromJson(() => {}); - }).to.throwException(); + }).toThrow(); expect(() => { Model1.fromJson(new Int16Array(100)); - }).to.throwException(); + }).toThrow(); }); it('should be capable to return multiple validation errors per property', () => { @@ -626,22 +646,25 @@ describe('Model', () => { }, }; - expect(() => { - Model1.fromJson({ a: 'four' }); - }).to.throwException((exp) => { - expect(exp).to.be.a(ValidationError); - expect(exp.data).to.have.property('a'); - expect(exp.data['a']).to.be.a(Array); - expect(exp.data['a']).to.have.length(2); - expect(exp.data['a'][0]).to.have.property('message'); - expect(exp.data['a'][0]).to.have.property('keyword'); - expect(exp.data['a'][0]).to.have.property('params'); - expect(exp.data['a'][0].keyword).to.equal('pattern'); - expect(exp.data['a'][1]).to.have.property('message'); - expect(exp.data['a'][1]).to.have.property('keyword'); - expect(exp.data['a'][1]).to.have.property('params'); - expect(exp.data['a'][1].keyword).to.equal('minLength'); - }); + expectThrows( + () => { + Model1.fromJson({ a: 'four' }); + }, + (exp) => { + expect(exp).toBeInstanceOf(ValidationError); + expect(exp.data).toHaveProperty('a'); + expect(exp.data['a']).toBeInstanceOf(Array); + expect(exp.data['a']).toHaveLength(2); + expect(exp.data['a'][0]).toHaveProperty('message'); + expect(exp.data['a'][0]).toHaveProperty('keyword'); + expect(exp.data['a'][0]).toHaveProperty('params'); + expect(exp.data['a'][0].keyword).toBe('pattern'); + expect(exp.data['a'][1]).toHaveProperty('message'); + expect(exp.data['a'][1]).toHaveProperty('keyword'); + expect(exp.data['a'][1]).toHaveProperty('params'); + expect(exp.data['a'][1].keyword).toBe('minLength'); + }, + ); }); it('should parse relations into Model instances and remove them from database representation', () => { @@ -676,19 +699,19 @@ describe('Model', () => { relation2: { id: 13, model1Id: null }, }); - expect(model.relation1[0]).to.be.a(Model2); - expect(model.relation1[1]).to.be.a(Model2); - expect(model.relation2).to.be.a(Model1); + expect(model.relation1[0]).toBeInstanceOf(Model2); + expect(model.relation1[1]).toBeInstanceOf(Model2); + expect(model.relation2).toBeInstanceOf(Model1); let json = model.$toDatabaseJson(); - expect(json).to.not.have.property('relation1'); - expect(json).to.not.have.property('relation2'); + expect(json).not.toHaveProperty('relation1'); + expect(json).not.toHaveProperty('relation2'); json = model.$toJson(); - expect(json).to.have.property('relation1'); - expect(json).to.have.property('relation2'); + expect(json).toHaveProperty('relation1'); + expect(json).toHaveProperty('relation2'); }); it('should parse relations into Model instances if source that is being parsed is already a Model instance', () => { @@ -725,9 +748,9 @@ describe('Model', () => { let modelWithRelationships = Model1.fromJson(model); - expect(modelWithRelationships.relation1[0]).to.be.a(Model2); - expect(modelWithRelationships.relation1[1]).to.be.a(Model2); - expect(modelWithRelationships.relation2).to.be.a(Model1); + expect(modelWithRelationships.relation1[0]).toBeInstanceOf(Model2); + expect(modelWithRelationships.relation1[1]).toBeInstanceOf(Model2); + expect(modelWithRelationships.relation2).toBeInstanceOf(Model1); }); it('should NOT parse relations into Model instances if skipParseRelations option is given', () => { @@ -765,9 +788,9 @@ describe('Model', () => { { skipParseRelations: true }, ); - expect(model.relation1[0]).to.not.be.a(Model2); - expect(model.relation1[1]).to.not.be.a(Model2); - expect(model.relation2).to.not.be.a(Model1); + expect(model.relation1[0]).not.toBeInstanceOf(Model2); + expect(model.relation1[1]).not.toBeInstanceOf(Model2); + expect(model.relation2).not.toBeInstanceOf(Model1); }); it('should NOT try to parse non-object relations into Model instances', () => { @@ -802,8 +825,8 @@ describe('Model', () => { { skipParseRelations: true }, ); - expect(model.relation1).to.eql([1, 2, '3', null, undefined, 6]); - expect(model.relation2).to.eql('5'); + expect(model.relation1).toEqual([1, 2, '3', null, undefined, 6]); + expect(model.relation2).toBe('5'); }); it('null relations should be null in the result', () => { @@ -821,7 +844,7 @@ describe('Model', () => { }; let model = Model.fromJson({ a: 1, b: 2, someRelation: null }); - expect(model.someRelation).to.equal(null); + expect(model.someRelation).toBeNull(); }); }); @@ -865,11 +888,11 @@ describe('Model', () => { let model2 = Model1.ensureModel(model1); - expect(model2 === model1).to.equal(true); - expect(model2.relation1[0]).to.be.a(Model2); - expect(model2.relation1[1]).to.be.a(Model2); - expect(model2.relation2).to.be.a(Model1); - expect(model2.relation2.relation1[0]).to.be.a(Model2); + expect(model2 === model1).toBe(true); + expect(model2.relation1[0]).toBeInstanceOf(Model2); + expect(model2.relation1[1]).toBeInstanceOf(Model2); + expect(model2.relation2).toBeInstanceOf(Model1); + expect(model2.relation2.relation1[0]).toBeInstanceOf(Model2); }); it('should not mutate if the whole tree already is models', () => { @@ -882,12 +905,12 @@ describe('Model', () => { let model2 = Model1.ensureModel(model1); - expect(model2 === model1).to.equal(true); - expect(model2.relation1 === model2.relation1).to.equal(true); - expect(model2.relation1[0] === model2.relation1[0]).to.equal(true); - expect(model2.relation1[1] === model2.relation1[1]).to.equal(true); - expect(model2.relation2 === model2.relation2).to.equal(true); - expect(model2.relation2.relation1[0] === model2.relation2.relation1[0]).to.equal(true); + expect(model2 === model1).toBe(true); + expect(model2.relation1 === model2.relation1).toBe(true); + expect(model2.relation1[0] === model2.relation1[0]).toBe(true); + expect(model2.relation1[1] === model2.relation1[1]).toBe(true); + expect(model2.relation2 === model2.relation2).toBe(true); + expect(model2.relation2.relation1[0] === model2.relation2.relation1[0]).toBe(true); }); it('should work with circular references', () => { @@ -898,11 +921,11 @@ describe('Model', () => { obj2.relation2 = obj1; const model = Model1.ensureModel(obj1); - expect(model).to.be.a(Model1); - expect(model.relation2).to.be.a(Model1); - expect(model.relation2.relation2 === model).to.equal(true); - expect(model.value).to.equal(1); - expect(model.relation2.value).to.equal(2); + expect(model).toBeInstanceOf(Model1); + expect(model.relation2).toBeInstanceOf(Model1); + expect(model.relation2.relation2 === model).toBe(true); + expect(model.value).toBe(1); + expect(model.relation2.value).toBe(2); }); }); @@ -917,12 +940,12 @@ describe('Model', () => { let json = { a: 1, b: 2, c: { d: 'str1' }, e: [3, 4, { f: 'str2' }] }; let model = Model1.fromDatabaseJson(json); - expect(model.a).to.equal(1); - expect(model.b).to.equal(2); - expect(model.c.d).to.equal('str1'); - expect(model.e[0]).to.equal(3); - expect(model.e[1]).to.equal(4); - expect(model.e[2].f).to.equal('str2'); + expect(model.a).toBe(1); + expect(model.b).toBe(2); + expect(model.c.d).toBe('str1'); + expect(model.e[0]).toBe(3); + expect(model.e[1]).toBe(4); + expect(model.e[2].f).toBe('str2'); }); it('should call $parseDatabaseJson', () => { @@ -931,15 +954,36 @@ describe('Model', () => { Model1.prototype.$parseDatabaseJson = (jsn) => { ++calls; - expect(jsn).to.eql(json); + expect(jsn).toEqual(json); return { c: 3 }; }; let model = Model1.fromDatabaseJson(json); - expect(model).to.not.have.property('a'); - expect(model.c).to.equal(3); - expect(calls).to.equal(1); + expect(model).not.toHaveProperty('a'); + expect(model.c).toBe(3); + expect(calls).toBe(1); + }); + + it('should parse JSON attributes that hold JSON text and keep other strings', () => { + Model1.jsonAttributes = ['a', 'b', 'c']; + const model = Model1.fromDatabaseJson({ a: '{"d":1}', b: '[1,2]', c: 'not json', d: '[]' }); + expect(model).toEqual({ a: { d: 1 }, b: [1, 2], c: 'not json', d: '[]' }); + }); + + it("should parse JSON attributes strictly with jsonColumnFormat 'text'", () => { + Model1.jsonAttributes = ['a']; + Model1.jsonColumnFormat = 'text'; + expect(Model1.fromDatabaseJson({ a: '"[1]"' }).a).toBe('[1]'); + expect(() => Model1.fromDatabaseJson({ a: 'not json' })).toThrow( + "Invalid JSON in JSON attribute 'a' of model 'Model1'", + ); + }); + + it("should not parse JSON attributes with jsonColumnFormat 'parsed'", () => { + Model1.jsonAttributes = ['a']; + Model1.jsonColumnFormat = 'parsed'; + expect(Model1.fromDatabaseJson({ a: '{"d":1}' }).a).toBe('{"d":1}'); }); }); @@ -951,7 +995,7 @@ describe('Model', () => { }); it('should return the internal representation by default', () => { - expect(Model1.fromJson({ a: 1, b: 2, c: { d: [1, 3] } }).$toJson()).to.eql({ + expect(Model1.fromJson({ a: 1, b: 2, c: { d: [1, 3] } }).$toJson()).toEqual({ a: 1, b: 2, c: { d: [1, 3] }, @@ -964,7 +1008,7 @@ describe('Model', () => { Model1.prototype.$formatJson = (jsn) => { ++calls; - expect(jsn).to.eql(json); + expect(jsn).toEqual(json); jsn.b = 2; return jsn; }; @@ -972,9 +1016,9 @@ describe('Model', () => { let model = Model1.fromJson(json); let output = model.$toJson(); - expect(output.a).to.equal(1); - expect(output.b).to.equal(2); - expect(calls).to.equal(1); + expect(output.a).toBe(1); + expect(output.b).toBe(2); + expect(calls).toBe(1); }); it('should call $formatJson with formatting options', () => { @@ -984,8 +1028,8 @@ describe('Model', () => { Model1.prototype.$formatJson = (jsn, o) => { ++calls; - expect(jsn).to.eql(json); - expect(o).to.eql(opt); + expect(jsn).toEqual(json); + expect(o).toEqual(opt); jsn.b = 2; return jsn; }; @@ -993,9 +1037,9 @@ describe('Model', () => { let model = Model1.fromJson(json); let output = model.$toJson({ format: opt }); - expect(output.a).to.equal(1); - expect(output.b).to.equal(2); - expect(calls).to.equal(1); + expect(output.a).toBe(1); + expect(output.b).toBe(2); + expect(calls).toBe(1); }); it('should call $toJson for properties of class Model', () => { @@ -1010,7 +1054,7 @@ describe('Model', () => { model.b = Model2.fromJson({ c: 2 }); model.e = [Model2.fromJson({ f: 100 })]; - expect(model.$toJson()).to.eql({ a: 1, b: { c: 2, d: 3 }, e: [{ f: 100, d: 3 }] }); + expect(model.$toJson()).toEqual({ a: 1, b: { c: 2, d: 3 }, e: [{ f: 100, d: 3 }] }); }); it('should pass formatting options to $formatJson of nested models', () => { @@ -1026,7 +1070,7 @@ describe('Model', () => { model.b = Model2.fromJson({ c: 2 }); model.e = [Model2.fromJson({ f: 100 })]; - expect(model.$toJson({ format: opt })).to.eql({ + expect(model.$toJson({ format: opt })).toEqual({ a: 1, d: 3, b: { c: 2, d: 3 }, @@ -1039,10 +1083,10 @@ describe('Model', () => { let model = Model1.fromJson(json); let output = model.$toJson(); - expect(output).to.eql(json); - expect(output.b).to.not.equal(json.b); - expect(output.b[0]).to.not.equal(json.b[0]); - expect(output.d).to.not.equal(json.d); + expect(output).toEqual(json); + expect(output.b).not.toBe(json.b); + expect(output.b[0]).not.toBe(json.b[0]); + expect(output.d).not.toBe(json.d); }); it('should be called by JSON.stringify', () => { @@ -1052,14 +1096,14 @@ describe('Model', () => { }; let model = Model1.fromJson({ a: 1 }); - expect(JSON.stringify(model)).to.equal('{"a":1,"b":2}'); + expect(JSON.stringify(model)).toBe('{"a":1,"b":2}'); }); it('properties registered using $omitFromJson method should be removed from the json', () => { let model = Model1.fromJson({ a: 1, b: 2, c: 3 }); model.$omitFromJson(['b', 'c']); - expect(model.$toJson()).to.eql({ a: 1 }); - expect(model).to.eql({ a: 1, b: 2, c: 3 }); + expect(model.$toJson()).toEqual({ a: 1 }); + expect(model).toEqual({ a: 1, b: 2, c: 3 }); }); it('properties registered using $omitFromJson method should be removed from the json (multiple calls)', () => { @@ -1067,8 +1111,8 @@ describe('Model', () => { model.$omitFromJson(['b']); model.$omitFromJson(['c']); model.$omitFromDatabaseJson(['a']); - expect(model.$toJson()).to.eql({ a: 1 }); - expect(model).to.eql({ a: 1, b: 2, c: 3 }); + expect(model.$toJson()).toEqual({ a: 1 }); + expect(model).toEqual({ a: 1, b: 2, c: 3 }); }); }); @@ -1080,7 +1124,7 @@ describe('Model', () => { }); it('should return then internal representation by default', () => { - expect(Model1.fromJson({ a: 1, b: 2, c: { d: [1, 3] } }).$toDatabaseJson()).to.eql({ + expect(Model1.fromJson({ a: 1, b: 2, c: { d: [1, 3] } }).$toDatabaseJson()).toEqual({ a: 1, b: 2, c: { d: [1, 3] }, @@ -1105,7 +1149,7 @@ describe('Model', () => { k: null, l: undefined, }).$toDatabaseJson(), - ).to.eql({ + ).toEqual({ a: '1', b: '"one"', c: '{"d":[1,3]}', @@ -1127,7 +1171,7 @@ describe('Model', () => { Model1.prototype.$formatDatabaseJson = (jsn) => { ++calls; - expect(jsn).to.eql(json); + expect(jsn).toEqual(json); jsn.b = 2; return jsn; }; @@ -1135,9 +1179,9 @@ describe('Model', () => { let model = Model1.fromJson(json); let output = model.$toDatabaseJson(); - expect(output.a).to.equal(1); - expect(output.b).to.equal(2); - expect(calls).to.equal(1); + expect(output.a).toBe(1); + expect(output.b).toBe(2); + expect(calls).toBe(1); }); it('should return a deep copy', () => { @@ -1145,17 +1189,17 @@ describe('Model', () => { let model = Model1.fromJson(json); let output = model.$toDatabaseJson(); - expect(output).to.eql(json); - expect(output.b).to.not.equal(json.b); - expect(output.b[0]).to.not.equal(json.b[0]); - expect(output.d).to.not.equal(json.d); + expect(output).toEqual(json); + expect(output.b).not.toBe(json.b); + expect(output.b[0]).not.toBe(json.b[0]); + expect(output.d).not.toBe(json.d); }); it('properties registered using $omitFromDatabaseJson method should be removed from the json', () => { let model = Model1.fromJson({ a: 1, b: 2, c: 3 }); model.$omitFromDatabaseJson(['b', 'c']); - expect(model.$toDatabaseJson()).to.eql({ a: 1 }); - expect(model).to.eql({ a: 1, b: 2, c: 3 }); + expect(model.$toDatabaseJson()).toEqual({ a: 1 }); + expect(model).toEqual({ a: 1, b: 2, c: 3 }); }); it('properties registered using $omitFromDatabaseJson method should be removed from the json (multiple calls)', () => { @@ -1163,8 +1207,8 @@ describe('Model', () => { model.$omitFromDatabaseJson(['b']); model.$omitFromDatabaseJson(['c']); model.$omitFromJson(['a']); - expect(model.$toDatabaseJson()).to.eql({ a: 1 }); - expect(model).to.eql({ a: 1, b: 2, c: 3 }); + expect(model.$toDatabaseJson()).toEqual({ a: 1 }); + expect(model).toEqual({ a: 1, b: 2, c: 3 }); }); }); @@ -1189,9 +1233,9 @@ describe('Model', () => { let clone = model.$clone(); - expect(clone).to.eql(model); - expect(clone.$toJson()).to.eql(model.$toJson()); - expect(clone.$toJson()).to.eql({ + expect(clone).toEqual(model); + expect(clone.$toJson()).toEqual(model.$toJson()); + expect(clone.$toJson()).toEqual({ a: 1, g: { h: 100 }, r: [{ h: 50 }], @@ -1199,10 +1243,10 @@ describe('Model', () => { e: [{ f: 100, d: 3 }], }); - expect(clone.g).to.not.equal(model.g); - expect(clone.r[0]).to.not.equal(model.r[0]); - expect(clone.b).to.not.equal(model.b); - expect(clone.e[0]).to.not.equal(model.e[0]); + expect(clone.g).not.toBe(model.g); + expect(clone.r[0]).not.toBe(model.r[0]); + expect(clone.b).not.toBe(model.b); + expect(clone.e[0]).not.toBe(model.e[0]); }); it('should copy buffers', () => { @@ -1212,9 +1256,9 @@ describe('Model', () => { clone.a[0] = 0; clone.b[0][0] = 0; - expect(Buffer.isBuffer(clone.a)).to.equal(true); - expect(model.a.toString()).to.equal('abc'); - expect(model.b[0].toString()).to.equal('def'); + expect(Buffer.isBuffer(clone.a)).toBe(true); + expect(model.a.toString()).toBe('abc'); + expect(model.b[0].toString()).toBe('def'); }); it('should keep URLs and other built-ins with internal state', () => { @@ -1233,11 +1277,11 @@ describe('Model', () => { const model = Model.fromJson({ url }); for (const json of [model.toJSON(), model.$clone()]) { - expect(json.url).to.be(url); - expect(json.url.href).to.equal('http://example.com/path'); + expect(json.url).toBe(url); + expect(json.url.href).toBe('http://example.com/path'); } - expect(JSON.parse(JSON.stringify(model))).to.eql({ url: 'http://example.com/path' }); + expect(JSON.parse(JSON.stringify(model))).toEqual({ url: 'http://example.com/path' }); }); it('should shallow clone', () => { @@ -1256,8 +1300,8 @@ describe('Model', () => { let model = Model.fromJson({ a: 1, b: 2, someRelation: { a: 3, b: 4 } }); - expect(model.$clone()).to.eql({ a: 1, b: 2, someRelation: { a: 3, b: 4 } }); - expect(model.$clone({ shallow: true })).to.eql({ a: 1, b: 2 }); + expect(model.$clone()).toEqual({ a: 1, b: 2, someRelation: { a: 3, b: 4 } }); + expect(model.$clone({ shallow: true })).toEqual({ a: 1, b: 2 }); }); }); @@ -1277,7 +1321,7 @@ describe('Model', () => { }); it('should convert a property name to column name', () => { - expect(Model1.propertyNameToColumnName('someProperty')).to.equal('some_property'); + expect(Model1.propertyNameToColumnName('someProperty')).toBe('some_property'); }); }); @@ -1297,7 +1341,7 @@ describe('Model', () => { }); it('should convert a column name to property name', () => { - expect(Model1.columnNameToPropertyName('some_property')).to.equal('someProperty'); + expect(Model1.columnNameToPropertyName('some_property')).toBe('someProperty'); }); }); @@ -1324,7 +1368,7 @@ describe('Model', () => { rel1: Model1.fromJson({ a: 101, b: 11 }), rel2: [Model1.fromJson({ a: 102, b: 12 }), Model1.fromJson({ a: 103, b: 13 })], }).toJSON(), - ).to.eql({ + ).toEqual({ a: 100, b: 10, foo: 110, @@ -1364,7 +1408,7 @@ describe('Model', () => { rel1: Model1.fromJson({ a: 101, b: 11 }), rel2: [Model1.fromJson({ a: 102, b: 12 }), Model1.fromJson({ a: 103, b: 13 })], }).toJSON({ virtuals: false }), - ).to.eql({ + ).toEqual({ a: 100, b: 10, @@ -1402,7 +1446,7 @@ describe('Model', () => { rel1: Model1.fromJson({ a: 101, b: 11 }), rel2: [Model1.fromJson({ a: 102, b: 12 }), Model1.fromJson({ a: 103, b: 13 })], }).$toJson({ virtuals: false }), - ).to.eql({ + ).toEqual({ a: 100, b: 10, @@ -1440,7 +1484,7 @@ describe('Model', () => { rel1: Model1.fromJson({ a: 101, b: 11 }), rel2: [Model1.fromJson({ a: 102, b: 12 }), Model1.fromJson({ a: 103, b: 13 })], }).$toJson({ virtuals: ['foo', 'bar'] }), - ).to.eql({ + ).toEqual({ a: 100, b: 10, foo: 110, @@ -1502,7 +1546,7 @@ describe('Model', () => { }, }); - expect(model2.toJSON()).to.eql({ + expect(model2.toJSON()).toEqual({ a: 'a', bar: 'bar', model1: { @@ -1555,7 +1599,7 @@ describe('Model', () => { }, }); - expect(model2.toJSON({})).to.eql({ + expect(model2.toJSON({})).toEqual({ a: 'a', bar: 'bar', model1: { @@ -1581,7 +1625,7 @@ describe('Model', () => { } } - expect(Model1.fromJson({ a: 100, b: 10 }).toJSON()).to.eql({ + expect(Model1.fromJson({ a: 100, b: 10 }).toJSON()).toEqual({ a: 100, b: 10, foo: 110, @@ -1625,7 +1669,7 @@ describe('Model', () => { notEvenVirtual: 2000, }); - expect(model.toJSON()).to.eql({ + expect(model.toJSON()).toEqual({ a: 10, b: 100, c: 1000, @@ -1634,7 +1678,7 @@ describe('Model', () => { baz: 20, }); - expect(model.$toDatabaseJson()).to.eql({ + expect(model.$toDatabaseJson()).toEqual({ a: 10, b: 100, c: 1000, @@ -1654,13 +1698,13 @@ describe('Model', () => { class Model1 extends BaseModel {} - expect(Model1.fromJson({ a: 100, b: 10, foo: 666 }).toJSON()).to.eql({ + expect(Model1.fromJson({ a: 100, b: 10, foo: 666 }).toJSON()).toEqual({ a: 100, b: 10, foo: 110, }); - expect(Model1.fromJson({ a: 100, b: 10, foo: 666 }).$toDatabaseJson()).to.eql({ + expect(Model1.fromJson({ a: 100, b: 10, foo: 666 }).$toDatabaseJson()).toEqual({ a: 100, b: 10, }); @@ -1681,25 +1725,25 @@ describe('Model', () => { objectField: obj, }); - expect(person.objectField).to.equal(obj); + expect(person.objectField).toBe(obj); let json = person.$toDatabaseJson(); - expect(person.objectField).to.equal(obj); - expect(json.objectField).to.eql(obj); - expect(json.objectField).to.not.equal(obj); + expect(person.objectField).toBe(obj); + expect(json.objectField).toEqual(obj); + expect(json.objectField).not.toBe(obj); json = person.$toJson(); - expect(person.objectField).to.equal(obj); - expect(json.objectField).to.eql(obj); - expect(json.objectField).to.not.equal(obj); + expect(person.objectField).toBe(obj); + expect(json.objectField).toEqual(obj); + expect(json.objectField).not.toBe(obj); json = person.toJSON(); - expect(person.objectField).to.equal(obj); - expect(json.objectField).to.eql(obj); - expect(json.objectField).to.not.equal(obj); + expect(person.objectField).toBe(obj); + expect(json.objectField).toEqual(obj); + expect(json.objectField).not.toBe(obj); }); it('should NOT clone object attributes when calling $toJson or $toDatabaseJson if Model.cloneObjectAttributes = false', () => { @@ -1719,22 +1763,22 @@ describe('Model', () => { objectField: obj, }); - expect(person.objectField).to.equal(obj); + expect(person.objectField).toBe(obj); let json = person.$toDatabaseJson(); - expect(person.objectField).to.equal(obj); - expect(json.objectField).to.equal(obj); + expect(person.objectField).toBe(obj); + expect(json.objectField).toBe(obj); json = person.$toJson(); - expect(person.objectField).to.equal(obj); - expect(json.objectField).to.equal(obj); + expect(person.objectField).toBe(obj); + expect(json.objectField).toBe(obj); json = person.toJSON(); - expect(person.objectField).to.equal(obj); - expect(json.objectField).to.equal(obj); + expect(person.objectField).toBe(obj); + expect(json.objectField).toBe(obj); }); }); @@ -1755,7 +1799,7 @@ describe('Model', () => { }; }; - expect(Model1.getRelation('relation1').relatedModelClass).to.equal(Model2); + expect(Model1.getRelation('relation1').relatedModelClass).toBe(Model2); }); it('if pickJsonSchemaProperties = true and jsonSchema is given, should remove all but schema properties from database representation', () => { @@ -1780,22 +1824,22 @@ describe('Model', () => { let json = model.$toDatabaseJson(); - expect(json.prop1).to.equal(10); - expect(json.prop2).to.equal('10'); - expect(json.prop3).to.equal(undefined); - expect(json.prop4).to.equal(undefined); + expect(json.prop1).toBe(10); + expect(json.prop2).toBe('10'); + expect(json.prop3).toBeUndefined(); + expect(json.prop4).toBeUndefined(); - expect(model.prop1).to.equal(10); - expect(model.prop2).to.equal('10'); - expect(model.prop3).to.equal('should be removed'); - expect(model.prop4).to.eql({ also: 'this' }); + expect(model.prop1).toBe(10); + expect(model.prop2).toBe('10'); + expect(model.prop3).toBe('should be removed'); + expect(model.prop4).toEqual({ also: 'this' }); json = model.$toJson(); - expect(json.prop1).to.equal(10); - expect(json.prop2).to.equal('10'); - expect(json.prop3).to.equal('should be removed'); - expect(json.prop4).to.eql({ also: 'this' }); + expect(json.prop1).toBe(10); + expect(json.prop2).toBe('10'); + expect(json.prop3).toBe('should be removed'); + expect(json.prop4).toEqual({ also: 'this' }); }); it('if pickJsonSchemaProperties = true and jsonSchema is given, should pick field expressions by their json column', () => { @@ -1820,7 +1864,7 @@ describe('Model', () => { { skipValidation: true }, ); - expect(model.$toDatabaseJson()).to.eql({ 'meta:a': 1, 'meta:b.c': 2 }); + expect(model.$toDatabaseJson()).toEqual({ 'meta:a': 1, 'meta:b.c': 2 }); }); it('if pickJsonSchemaProperties = true and jsonSchema is given, should not pick inherited object properties', () => { @@ -1844,7 +1888,7 @@ describe('Model', () => { { skipValidation: true }, ); - expect(model.$toDatabaseJson()).to.eql({ prop1: 10 }); + expect(model.$toDatabaseJson()).toEqual({ prop1: 10 }); }); it('if pickJsonSchemaProperties = true and jsonSchema is given, should omit relations even if defined in jsonSchema', () => { @@ -1877,10 +1921,10 @@ describe('Model', () => { }); let json = model.$toDatabaseJson(); - expect(json.someRelation).to.equal(undefined); - expect(model.someRelation).to.eql({ value: 'should be removed' }); + expect(json.someRelation).toBeUndefined(); + expect(model.someRelation).toEqual({ value: 'should be removed' }); json = model.$toJson(); - expect(json.someRelation).to.eql({ value: 'should be removed' }); + expect(json.someRelation).toEqual({ value: 'should be removed' }); }); it('if pickJsonSchemaProperties = false, should select all properties even if jsonSchema is defined', () => { @@ -1904,22 +1948,22 @@ describe('Model', () => { let json = model.$toDatabaseJson(); - expect(json.prop1).to.equal(10); - expect(json.prop2).to.equal('10'); - expect(json.prop3).to.equal('should not be removed'); - expect(json.prop4).to.eql({ also: 'this' }); + expect(json.prop1).toBe(10); + expect(json.prop2).toBe('10'); + expect(json.prop3).toBe('should not be removed'); + expect(json.prop4).toEqual({ also: 'this' }); - expect(model.prop1).to.equal(10); - expect(model.prop2).to.equal('10'); - expect(model.prop3).to.equal('should not be removed'); - expect(model.prop4).to.eql({ also: 'this' }); + expect(model.prop1).toBe(10); + expect(model.prop2).toBe('10'); + expect(model.prop3).toBe('should not be removed'); + expect(model.prop4).toEqual({ also: 'this' }); json = model.$toJson(); - expect(json.prop1).to.equal(10); - expect(json.prop2).to.equal('10'); - expect(json.prop3).to.equal('should not be removed'); - expect(json.prop4).to.eql({ also: 'this' }); + expect(json.prop1).toBe(10); + expect(json.prop2).toBe('10'); + expect(json.prop3).toBe('should not be removed'); + expect(json.prop4).toEqual({ also: 'this' }); }); it('should convert objects to json based on jsonSchema type', () => { @@ -1981,19 +2025,33 @@ describe('Model', () => { let model = Model.fromJson(inputJson); - expect(model).to.eql(inputJson); + expect(model).toEqual(inputJson); let dbJson = model.$toDatabaseJson(); - expect(dbJson.prop1).to.equal('text'); - expect(dbJson.prop2).to.equal('{"subProp1":1000}'); - expect(dbJson.prop3).to.equal('[{"subProp2":true},{"subProp2":false}]'); - expect(dbJson.prop4).to.equal('[1,2,3]'); - expect(dbJson.prop5).to.equal('{"subProp3":"str"}'); + expect(dbJson.prop1).toBe('text'); + expect(dbJson.prop2).toBe('{"subProp1":1000}'); + expect(dbJson.prop3).toBe('[{"subProp2":true},{"subProp2":false}]'); + expect(dbJson.prop4).toBe('[1,2,3]'); + expect(dbJson.prop5).toBe('{"subProp3":"str"}'); let model2 = Model.fromDatabaseJson(dbJson); - expect(model2).to.eql(inputJson); + expect(model2).toEqual(inputJson); + }); + + it('should use anyOf and oneOf for the json attributes if type is null', () => { + let Model = modelClass('Model'); + + Model.jsonSchema = { + type: 'object', + properties: { + prop1: { type: null, anyOf: [{ type: 'array' }, { type: 'string' }] }, + prop2: { type: null, oneOf: [{ type: 'object' }, { type: 'string' }] }, + }, + }; + + expect(Model.getJsonAttributes()).toEqual(['prop1', 'prop2']); }); it('should convert objects to json based on jsonAttributes array', () => { @@ -2044,24 +2102,24 @@ describe('Model', () => { let model = TestModel.fromJson(inputJson); - expect(model).to.eql(inputJson); + expect(model).toEqual(inputJson); let dbJson = model.$toDatabaseJson(); - expect(dbJson.prop1).to.equal('text'); - expect(dbJson.prop2).to.equal('{"subProp1":1000}'); - expect(dbJson.prop3).to.eql(inputJson.prop3); + expect(dbJson.prop1).toBe('text'); + expect(dbJson.prop2).toBe('{"subProp1":1000}'); + expect(dbJson.prop3).toEqual(inputJson.prop3); let model2 = TestModel.fromDatabaseJson(dbJson); - expect(model2).to.eql(inputJson); + expect(model2).toEqual(inputJson); }); it('$setJson should do nothing if null is given', () => { let Model = modelClass('Model'); let model = Model.fromJson({ a: 1, b: 2 }); model.$setJson(null); - expect(model).to.eql({ a: 1, b: 2 }); + expect(model).toEqual({ a: 1, b: 2 }); }); it('$setRelated should set related model instances', () => { @@ -2102,23 +2160,256 @@ describe('Model', () => { const model1 = Model1.fromJson({}); const setResult = model1.$setRelated('hasMany', Model2.fromJson({ id: 1 })); - expect(model1.hasMany).to.eql([{ id: 1 }]); - expect(setResult === model1).to.equal(true); + expect(model1.hasMany).toEqual([{ id: 1, model1Id: null }]); + expect(setResult === model1).toBe(true); model1.$setRelated('hasMany', [Model2.fromJson({ id: 2 })]); - expect(model1.hasMany).to.eql([{ id: 2 }]); + expect(model1.hasMany).toEqual([{ id: 2, model1Id: null }]); model1.$setRelated('belongsToOne', Model1.fromJson({ id: 1 })); - expect(model1.belongsToOne).to.eql({ id: 1 }); + expect(model1.belongsToOne).toEqual({ id: 1 }); model1.$setRelated('belongsToOne', [Model1.fromJson({ id: 2 })]); - expect(model1.belongsToOne).to.eql({ id: 2 }); + expect(model1.belongsToOne).toEqual({ id: 2 }); model1.$setRelated('manyToMany', Model1.fromJson({ id: 1 })); - expect(model1.manyToMany).to.eql([{ id: 1 }]); + expect(model1.manyToMany).toEqual([{ id: 1 }]); model1.$setRelated('manyToMany', [Model1.fromJson({ id: 2 })]); - expect(model1.manyToMany).to.eql([{ id: 2 }]); + expect(model1.manyToMany).toEqual([{ id: 2 }]); + }); + + describe('$setRelated / $appendRelated foreign keys (#2001)', () => { + let Post; + let User; + + beforeEach(() => { + User = modelClass('User'); + Post = modelClass('Post'); + + Post.relationMappings = { + author: { + relation: Model.BelongsToOneRelation, + modelClass: User, + join: { from: 'Post.authorId', to: 'User.id' }, + }, + compositeAuthor: { + relation: Model.BelongsToOneRelation, + modelClass: User, + join: { from: ['Post.authorA', 'Post.authorB'], to: ['User.a', 'User.b'] }, + }, + editors: { + relation: Model.BelongsToManyRelation, + modelClass: User, + join: { from: 'Post.editorIds', to: 'User.id' }, + }, + }; + + User.relationMappings = { + posts: { + relation: Model.HasManyRelation, + modelClass: Post, + join: { from: 'User.id', to: 'Post.authorId' }, + }, + latestPost: { + relation: Model.HasOneRelation, + modelClass: Post, + join: { from: 'User.id', to: 'Post.authorId' }, + }, + likedPosts: { + relation: Model.ManyToManyRelation, + modelClass: Post, + join: { + from: 'User.id', + through: { from: 'Like.userId', to: 'Like.postId' }, + to: 'Post.id', + }, + }, + }; + }); + + it('should set the foreign key of a BelongsToOneRelation', () => { + const post = Post.fromJson({}); + post.$setRelated('author', User.fromJson({ id: 2 })); + + expect(post.author.id).toBe(2); + expect(post.authorId).toBe(2); + }); + + it('should overwrite a conflicting foreign key of a BelongsToOneRelation', () => { + const post = Post.fromJson({ authorId: 1 }); + post.$setRelated('author', [User.fromJson({ id: 2 })]); + + expect(post.authorId).toBe(2); + + post.$appendRelated('author', User.fromJson({ id: 3 })); + expect(post.authorId).toBe(3); + }); + + it('should set composite foreign keys of a BelongsToOneRelation', () => { + const post = Post.fromJson({}); + post.$setRelated('compositeAuthor', [User.fromJson({ a: 1, b: 2 })]); + + expect(post.authorA).toBe(1); + expect(post.authorB).toBe(2); + }); + + it('should clear the foreign key of a cleared BelongsToOneRelation', () => { + const post = Post.fromJson({ authorId: 1, authorA: 1, authorB: 2 }); + post.$setRelated('author', null); + post.$setRelated('compositeAuthor', []); + + expect(post.author).toBeNull(); + expect(post.authorId).toBeNull(); + expect(post.compositeAuthor).toBeNull(); + expect(post.authorA).toBeNull(); + expect(post.authorB).toBeNull(); + }); + + it('should clear the foreign key if the related model of a BelongsToOneRelation has no key', () => { + const post = Post.fromJson({ authorId: 1, authorA: 1, authorB: 2 }); + post.$setRelated('author', User.fromJson({ name: 'new' })); + post.$setRelated('compositeAuthor', User.fromJson({ a: 3 })); + + expect(post.authorId).toBeNull(); + expect(post.authorA).toBeNull(); + expect(post.authorB).toBeNull(); + }); + + it('should set the foreign keys of the related models of a HasManyRelation', () => { + const user = User.fromJson({ id: 2 }); + user.$setRelated('posts', [Post.fromJson({ authorId: 1 }), Post.fromJson({})]); + + expect(user.posts.map((post) => post.authorId)).toEqual([2, 2]); + + user.$appendRelated('posts', Post.fromJson({ authorId: 3 })); + expect(user.posts.map((post) => post.authorId)).toEqual([2, 2, 2]); + }); + + it('should clear the foreign keys of the models removed from a HasManyRelation', () => { + const user = User.fromJson({ id: 2 }); + const [post1, post2] = [Post.fromJson({ id: 1 }), Post.fromJson({ id: 2 })]; + user.$setRelated('posts', [post1, post2]); + user.$setRelated('posts', [post2]); + + expect(post1.authorId).toBeNull(); + expect(post2.authorId).toBe(2); + + user.$setRelated('posts', []); + expect(post2.authorId).toBeNull(); + }); + + it('should keep the foreign keys of the models in a HasManyRelation when appending', () => { + const user = User.fromJson({ id: 2 }); + const post1 = Post.fromJson({ id: 1 }); + user.$setRelated('posts', [post1]); + user.$appendRelated('posts', [Post.fromJson({ id: 2 })]); + + expect(user.posts.map((post) => post.authorId)).toEqual([2, 2]); + }); + + it('should keep the foreign keys of removed models that moved to another owner', () => { + const user1 = User.fromJson({ id: 1 }); + const user2 = User.fromJson({ id: 2 }); + const post = Post.fromJson({ id: 1 }); + user1.$setRelated('posts', [post]); + user2.$setRelated('posts', [post]); + user1.$setRelated('posts', []); + + expect(post.authorId).toBe(2); + }); + + it('should set the foreign key of the related model of a HasOneRelation', () => { + const user = User.fromJson({ id: 2 }); + user.$setRelated('latestPost', Post.fromJson({ authorId: 1 })); + + expect(user.latestPost.authorId).toBe(2); + }); + + it('should clear the foreign key of the model replaced in a HasOneRelation', () => { + const user = User.fromJson({ id: 2 }); + const [post1, post2, post3] = [1, 2, 3].map((id) => Post.fromJson({ id })); + user.$setRelated('latestPost', post1); + user.$appendRelated('latestPost', post2); + + expect(post1.authorId).toBeNull(); + expect(post2.authorId).toBe(2); + + user.$setRelated('latestPost', post3); + expect(post2.authorId).toBeNull(); + + user.$setRelated('latestPost', null); + expect(post3.authorId).toBeNull(); + }); + + it("should clear the related models' foreign keys if the owner has no key", () => { + const user = User.fromJson({ name: 'new' }); + const [post1, post2] = [Post.fromJson({ authorId: 1 }), Post.fromJson({ authorId: 1 })]; + user.$setRelated('posts', [post1]); + user.$appendRelated('posts', post2); + + expect(post1.authorId).toBeNull(); + expect(post2.authorId).toBeNull(); + + const post3 = Post.fromJson({ authorId: 1 }); + user.$setRelated('latestPost', post3); + expect(post3.authorId).toBeNull(); + }); + + it('should set the array of keys of a BelongsToManyRelation', () => { + const post = Post.fromJson({ editorIds: [9] }); + post.$setRelated('editors', [User.fromJson({ id: 2 }), User.fromJson({ id: 1 })]); + + expect(post.editorIds).toEqual([2, 1]); + + post.$setRelated('editors', User.fromJson({ id: 3 })); + expect(post.editorIds).toEqual([3]); + + post.$setRelated('editors', null); + expect(post.editorIds).toEqual([]); + }); + + it('should append to the array of keys of a BelongsToManyRelation', () => { + const post = Post.fromJson({ editorIds: [1, 2] }); + post.$appendRelated('editors', [User.fromJson({ id: 2 }), User.fromJson({ id: 3 })]); + + expect(post.editorIds).toEqual([1, 2, 3]); + expect(post.editors.map((user) => user.id)).toEqual([2, 3]); + + post.$appendRelated('editors', User.fromJson({ id: 4 })); + expect(post.editorIds).toEqual([1, 2, 3, 4]); + }); + + it('should leave models without a key out of the array of a BelongsToManyRelation', () => { + const post = Post.fromJson({}); + post.$setRelated('editors', [User.fromJson({ name: 'new' }), User.fromJson({ id: 1 })]); + + expect(post.editorIds).toEqual([1]); + + post.$appendRelated('editors', User.fromJson({ name: 'other' })); + expect(post.editorIds).toEqual([1]); + expect(post.editors).toHaveLength(3); + }); + + it('should not set any keys for ManyToManyRelation', () => { + const user = User.fromJson({ id: 2 }); + const post = Post.fromJson({ id: 5, authorId: 1 }); + user.$setRelated('likedPosts', [post]); + user.$setRelated('likedPosts', []); + + expect(post.toJSON()).toEqual({ id: 5, authorId: 1 }); + expect(user.toJSON()).toEqual({ id: 2, likedPosts: [] }); + }); + + it('should not sync the foreign keys in fromJson() and $setJson()', () => { + const user = User.fromJson({ id: 2, posts: [{ id: 1, authorId: 1 }] }); + const post = Post.fromJson({ authorId: 1, author: { id: 2 } }); + post.$setJson({ compositeAuthor: { a: 1, b: 2 } }); + + expect(user.posts[0].authorId).toBe(1); + expect(post.authorId).toBe(1); + expect(post.authorA).toBeUndefined(); + }); }); it('appendRelated should append related model instances', () => { @@ -2159,23 +2450,26 @@ describe('Model', () => { const model1 = Model1.fromJson({}); const appendResult = model1.$appendRelated('hasMany', Model2.fromJson({ id: 1 })); - expect(model1.hasMany).to.eql([{ id: 1 }]); - expect(appendResult === model1).to.equal(true); + expect(model1.hasMany).toEqual([{ id: 1, model1Id: null }]); + expect(appendResult === model1).toBe(true); model1.$appendRelated('hasMany', [Model2.fromJson({ id: 2 })]); - expect(model1.hasMany).to.eql([{ id: 1 }, { id: 2 }]); + expect(model1.hasMany).toEqual([ + { id: 1, model1Id: null }, + { id: 2, model1Id: null }, + ]); model1.$appendRelated('belongsToOne', Model1.fromJson({ id: 1 })); - expect(model1.belongsToOne).to.eql({ id: 1 }); + expect(model1.belongsToOne).toEqual({ id: 1 }); model1.$appendRelated('belongsToOne', [Model1.fromJson({ id: 2 })]); - expect(model1.belongsToOne).to.eql({ id: 2 }); + expect(model1.belongsToOne).toEqual({ id: 2 }); model1.$appendRelated('manyToMany', Model1.fromJson({ id: 1 })); - expect(model1.manyToMany).to.eql([{ id: 1 }]); + expect(model1.manyToMany).toEqual([{ id: 1 }]); model1.$appendRelated('manyToMany', [Model1.fromJson({ id: 2 })]); - expect(model1.manyToMany).to.eql([{ id: 1 }, { id: 2 }]); + expect(model1.manyToMany).toEqual([{ id: 1 }, { id: 2 }]); }); it('$toJson should return result without relations if {shallow: true} is given as argument', () => { @@ -2194,8 +2488,8 @@ describe('Model', () => { let model = Model.fromJson({ a: 1, b: 2, someRelation: { a: 3, b: 4 } }); - expect(model.$toJson()).to.eql({ a: 1, b: 2, someRelation: { a: 3, b: 4 } }); - expect(model.$toJson({ shallow: true })).to.eql({ a: 1, b: 2 }); + expect(model.$toJson()).toEqual({ a: 1, b: 2, someRelation: { a: 3, b: 4 } }); + expect(model.$toJson({ shallow: true })).toEqual({ a: 1, b: 2 }); }); it('toJSON should return result without relations if {shallow: true} is given as argument', () => { @@ -2214,46 +2508,46 @@ describe('Model', () => { let model = Model.fromJson({ a: 1, b: 2, someRelation: { a: 3, b: 4 } }); - expect(model.toJSON()).to.eql({ a: 1, b: 2, someRelation: { a: 3, b: 4 } }); - expect(model.toJSON({ shallow: true })).to.eql({ a: 1, b: 2 }); + expect(model.toJSON()).toEqual({ a: 1, b: 2, someRelation: { a: 3, b: 4 } }); + expect(model.toJSON({ shallow: true })).toEqual({ a: 1, b: 2 }); }); it('Model.raw should return objection.raw', () => { - expect(modelClass('Model').raw).to.equal(raw); + expect(modelClass('Model').raw).toBe(raw); }); it('ensureModel should return null for null input', () => { let Model = modelClass('Model'); - expect(Model.ensureModel(null)).to.equal(null); + expect(Model.ensureModel(null)).toBeNull(); }); it('ensureModelArray should return [] for null input', () => { let Model = modelClass('Model'); - expect(Model.ensureModelArray(null)).to.eql([]); + expect(Model.ensureModelArray(null)).toEqual([]); }); it('fetchGraph should return a QueryBuilder', () => { let Model = modelClass('Model1'); - expect(Model.fetchGraph([], '[]')).to.be.a(QueryBuilder); + expect(Model.fetchGraph([], '[]')).toBeInstanceOf(QueryBuilder); }); it('$fetchGraph should return a QueryBuilder', () => { let Model = modelClass('Model1'); - expect(Model.fromJson({}).$fetchGraph('[]')).to.be.a(QueryBuilder); + expect(Model.fromJson({}).$fetchGraph('[]')).toBeInstanceOf(QueryBuilder); }); it('loadRelated should throw if an invalid expression is given', () => { let Model = modelClass('Model1'); expect(() => { Model.loadRelated([], 'notAValidExpression.'); - }).to.throwException(); + }).toThrow(); }); it('loadRelated should throw if an invalid expression is given', () => { let Model = modelClass('Model1'); expect(() => { Model.loadRelated([], 'notAValidExpression.'); - }).to.throwException(); + }).toThrow(); }); it('should use Model.QueryBuilder to create `query()` and `$query()`', () => { @@ -2277,25 +2571,25 @@ describe('Model', () => { Model1.QueryBuilder = MyQueryBuilder1; Model2.QueryBuilder = MyQueryBuilder2; - expect(Model1.query()).to.be.a(MyQueryBuilder1); - expect(Model1.fromJson({}).$query()).to.be.a(MyQueryBuilder1); - expect(Model1.fromJson({}).$relatedQuery('someRelation')).to.be.a(MyQueryBuilder2); + expect(Model1.query()).toBeInstanceOf(MyQueryBuilder1); + expect(Model1.fromJson({}).$query()).toBeInstanceOf(MyQueryBuilder1); + expect(Model1.fromJson({}).$relatedQuery('someRelation')).toBeInstanceOf(MyQueryBuilder2); }); it('$modelClass should return this.constructor', () => { let Model1 = modelClass('Model1'); let model = Model1.fromJson({ id: 1 }); - expect(model.$modelClass === model.constructor).to.equal(true); + expect(model.$modelClass === model.constructor).toBe(true); }); it('$propKey should key dates by their time and tell composite values with commas apart', () => { let Model1 = modelClass('Model1'); let key = (json) => Model1.fromJson(json).$propKey(Object.keys(json)); - expect(key({ a: new Date(5) })).to.equal(key({ a: 5 })); - expect(key({ a: new Date(5), b: 1 })).to.equal(key({ a: 5, b: '1' })); - expect(key({ a: 'a,b', b: 'c' })).to.not.equal(key({ a: 'a', b: 'b,c' })); - expect(key({ a: 'a\\', b: ',c' })).to.not.equal(key({ a: 'a\\,', b: 'c' })); + expect(key({ a: new Date(5) })).toBe(key({ a: 5 })); + expect(key({ a: new Date(5), b: 1 })).toBe(key({ a: 5, b: '1' })); + expect(key({ a: 'a,b', b: 'c' })).not.toBe(key({ a: 'a', b: 'b,c' })); + expect(key({ a: 'a\\', b: ',c' })).not.toBe(key({ a: 'a\\,', b: 'c' })); }); describe('traverse() and $traverse()', () => { @@ -2382,50 +2676,50 @@ describe('Model', () => { } }); - expect(sortBy(model1Ids)).to.eql([1, 2, 3]); - expect(sortBy(model2Ids)).to.eql(range(4, 26)); + expect(sortBy(model1Ids)).toEqual([1, 2, 3]); + expect(sortBy(model2Ids)).toEqual(range(4, 26)); }); it('traverse([], traverser) should not throw', () => { expect(() => { Model1.traverse([], function () {}); - }).to.not.throwException(); + }).not.toThrow(); }); it('traverse(undefined, traverser) should not throw', () => { expect(() => { Model1.traverse(undefined, function () {}); - }).to.not.throwException(); + }).not.toThrow(); }); it('traverse callback should be passed the model, its parent (if any) and the relation it is in (if any)', () => { Model1.traverse([model], (model, parent, relationName) => { if (model instanceof Model1) { if (model.id === 1) { - expect(parent).to.equal(null); - expect(relationName).to.equal(null); + expect(parent).toBeNull(); + expect(relationName).toBeNull(); } else if (model.id === 2) { - expect(parent.id).to.equal(1); - expect(relationName).to.equal('relation2'); + expect(parent.id).toBe(1); + expect(relationName).toBe('relation2'); } else if (model.id === 3) { - expect(parent.id).to.equal(2); - expect(relationName).to.equal('relation2'); + expect(parent.id).toBe(2); + expect(relationName).toBe('relation2'); } else { throw new Error('should never get here'); } } else if (model instanceof Model2) { if (model.id >= 4 && model.id <= 5) { - expect(parent).to.be.a(Model1); - expect(parent.id).to.equal(1); - expect(relationName).to.equal('relation1'); + expect(parent).toBeInstanceOf(Model1); + expect(parent.id).toBe(1); + expect(relationName).toBe('relation1'); } else if (model.id >= 6 && model.id <= 7) { - expect(parent).to.be.a(Model1); - expect(parent.id).to.equal(2); - expect(relationName).to.equal('relation1'); + expect(parent).toBeInstanceOf(Model1); + expect(parent.id).toBe(2); + expect(relationName).toBe('relation1'); } else if (model.id >= 8 && model.id <= 25) { - expect(parent).to.be.a(Model1); - expect(parent.id).to.equal(3); - expect(relationName).to.equal('relation1'); + expect(parent).toBeInstanceOf(Model1); + expect(parent.id).toBe(3); + expect(relationName).toBe('relation1'); } else { throw new Error('should never get here'); } @@ -2445,8 +2739,8 @@ describe('Model', () => { } }); - expect(sortBy(model1Ids)).to.eql([1, 2, 3]); - expect(sortBy(model2Ids)).to.eql(range(4, 26)); + expect(sortBy(model1Ids)).toEqual([1, 2, 3]); + expect(sortBy(model2Ids)).toEqual(range(4, 26)); }); it('traverse(null, singleModel, traverser) should traverse through the relation tree', () => { @@ -2461,8 +2755,8 @@ describe('Model', () => { } }); - expect(sortBy(model1Ids)).to.eql([1, 2, 3]); - expect(sortBy(model2Ids)).to.eql(range(4, 26)); + expect(sortBy(model1Ids)).toEqual([1, 2, 3]); + expect(sortBy(model2Ids)).toEqual(range(4, 26)); }); it('traverse(ModelClass, model, traverser) should traverse through all ModelClass instances in the relation tree', () => { @@ -2475,8 +2769,8 @@ describe('Model', () => { model1Ids.push(model.id); }); - expect(sortBy(model1Ids)).to.eql([1, 2, 3]); - expect(sortBy(model2Ids)).to.eql(range(4, 26)); + expect(sortBy(model1Ids)).toEqual([1, 2, 3]); + expect(sortBy(model2Ids)).toEqual(range(4, 26)); }); it('$traverse(traverser) should traverse through the relation tree', () => { @@ -2491,8 +2785,8 @@ describe('Model', () => { } }); - expect(sortBy(model1Ids)).to.eql([1, 2, 3]); - expect(sortBy(model2Ids)).to.eql(range(4, 26)); + expect(sortBy(model1Ids)).toEqual([1, 2, 3]); + expect(sortBy(model2Ids)).toEqual(range(4, 26)); }); it('$traverse(ModelClass, traverser) should traverse through the ModelClass instances in the relation tree', () => { @@ -2507,8 +2801,8 @@ describe('Model', () => { model2Ids.push(model.id); }); - expect(sortBy(model1Ids)).to.eql([1, 2, 3]); - expect(sortBy(model2Ids)).to.eql(range(4, 26)); + expect(sortBy(model1Ids)).toEqual([1, 2, 3]); + expect(sortBy(model2Ids)).toEqual(range(4, 26)); }); }); @@ -2600,8 +2894,8 @@ describe('Model', () => { }, 5); }); }).then(() => { - expect(sortBy(model1Ids)).to.eql([1, 2, 3]); - expect(sortBy(model2Ids)).to.eql(range(4, 26)); + expect(sortBy(model1Ids)).toEqual([1, 2, 3]); + expect(sortBy(model2Ids)).toEqual(range(4, 26)); }); }); @@ -2621,8 +2915,8 @@ describe('Model', () => { }, 5); }); }).then(() => { - expect(sortBy(model1Ids)).to.eql([1, 2, 3]); - expect(sortBy(model2Ids)).to.eql(range(4, 26)); + expect(sortBy(model1Ids)).toEqual([1, 2, 3]); + expect(sortBy(model2Ids)).toEqual(range(4, 26)); }); }); @@ -2630,30 +2924,30 @@ describe('Model', () => { return Model1.traverseAsync([model], (model, parent, relationName) => { if (model instanceof Model1) { if (model.id === 1) { - expect(parent).to.equal(null); - expect(relationName).to.equal(null); + expect(parent).toBeNull(); + expect(relationName).toBeNull(); } else if (model.id === 2) { - expect(parent.id).to.equal(1); - expect(relationName).to.equal('relation2'); + expect(parent.id).toBe(1); + expect(relationName).toBe('relation2'); } else if (model.id === 3) { - expect(parent.id).to.equal(2); - expect(relationName).to.equal('relation2'); + expect(parent.id).toBe(2); + expect(relationName).toBe('relation2'); } else { throw new Error('should never get here'); } } else if (model instanceof Model2) { if (model.id >= 4 && model.id <= 5) { - expect(parent).to.be.a(Model1); - expect(parent.id).to.equal(1); - expect(relationName).to.equal('relation1'); + expect(parent).toBeInstanceOf(Model1); + expect(parent.id).toBe(1); + expect(relationName).toBe('relation1'); } else if (model.id >= 6 && model.id <= 7) { - expect(parent).to.be.a(Model1); - expect(parent.id).to.equal(2); - expect(relationName).to.equal('relation1'); + expect(parent).toBeInstanceOf(Model1); + expect(parent.id).toBe(2); + expect(relationName).toBe('relation1'); } else if (model.id >= 8 && model.id <= 25) { - expect(parent).to.be.a(Model1); - expect(parent.id).to.equal(3); - expect(relationName).to.equal('relation1'); + expect(parent).toBeInstanceOf(Model1); + expect(parent.id).toBe(3); + expect(relationName).toBe('relation1'); } else { throw new Error('should never get here'); } @@ -2674,8 +2968,8 @@ describe('Model', () => { }); }) .then(() => { - expect(sortBy(model1Ids)).to.eql([1, 2, 3]); - expect(sortBy(model2Ids)).to.eql(range(4, 26)); + expect(sortBy(model1Ids)).toEqual([1, 2, 3]); + expect(sortBy(model2Ids)).toEqual(range(4, 26)); }); }); @@ -2697,8 +2991,8 @@ describe('Model', () => { }); }) .then(() => { - expect(sortBy(model1Ids)).to.eql([1, 2, 3]); - expect(sortBy(model2Ids)).to.eql(range(4, 26)); + expect(sortBy(model1Ids)).toEqual([1, 2, 3]); + expect(sortBy(model2Ids)).toEqual(range(4, 26)); }); }); }); @@ -2739,15 +3033,15 @@ describe('Model', () => { let model = Model1.fromJson({ foo: '10' }); model.someRelation = Model1.fromJson({ foo: '20' }); - expect(model.foo).to.equal(10); + expect(model.foo).toBe(10); model.$validate(); - expect(model.foo).to.equal(10); + expect(model.foo).toBe(10); - expect(model.$toJson().foo).to.equal('10'); + expect(model.$toJson().foo).toBe('10'); }); it('Model.fn should return objection.fn', () => { - expect(modelClass('Model1').fn).to.equal(fn); + expect(modelClass('Model1').fn).toBe(fn); }); it('make sure JSON.stringify works with toJSON (#869)', () => { diff --git a/tests/unit/queryBuilder/GraphOptions.js b/tests/unit/queryBuilder/GraphOptions.js index a576d1b4a..6abb447bd 100644 --- a/tests/unit/queryBuilder/GraphOptions.js +++ b/tests/unit/queryBuilder/GraphOptions.js @@ -1,5 +1,5 @@ -const expect = require('expect.js'); -const { GraphOptions } = require('../../../lib/queryBuilder/graph/GraphOptions'); +import { describe, it, expect } from 'vitest'; +import { GraphOptions } from '../../../lib/queryBuilder/graph/GraphOptions.js'; describe('GraphOptions', () => { function createNode(relationPathKey) { @@ -7,9 +7,8 @@ describe('GraphOptions', () => { return { relationPath, relationPathKey }; } - function matches(noInsert, relationPathKey, rootRelationPath) { - const options = new GraphOptions({ noInsert }, rootRelationPath); - return options._hasOption(createNode(relationPathKey), 'noInsert'); + function matches(noInsert, relationPathKey) { + return new GraphOptions({ noInsert }).has(createNode(relationPathKey), 'noInsert'); } describe('relation expressions', () => { @@ -37,59 +36,33 @@ describe('GraphOptions', () => { ['a.^2', 'a.a.a', false], ]) { it(`'${expression}' should ${expected ? '' : 'not '}match '${relationPathKey}'`, () => { - expect(matches(expression, relationPathKey)).to.equal(expected); + expect(matches(expression, relationPathKey)).toBe(expected); }); } - it('should match the full relation path in recursive upserts', () => { - expect(matches('a.b', 'b', ['a'])).to.equal(true); - expect(matches('a.b', '', ['a'])).to.equal(false); - expect(matches('a', '', ['a'])).to.equal(true); - }); - for (const [expression, message] of [ ['', 'the expression is empty'], [' ', 'the expression is empty'], ['[]', 'the expression is empty'], ['a as b', 'aliases like "a as b" are not supported'], ['a.[b, c as d]', 'aliases like "c as d" are not supported'], - ['a.[b, ^]', '"^" is only supported at the end of a relation path'], - ['a.[b, ^2]', '"^2" is only supported at the end of a relation path'], - ['[a, *]', '"*" is only supported at the end of a relation path'], + ['a.[b, ^]', '"^" is only supported at the end of a relation path.'], + ['a.[b, ^2]', '"^2" is only supported at the end of a relation path.'], + ['[a, *]', '"*" is only supported at the end of a relation path.'], + ['a.^.b', '"^" is only supported at the end of a relation path.'], + ['*.a', '"*" is only supported at the end of a relation path.'], + [ + '[a.b, a.c]', + 'Duplicate relation name "a". For example, use "a.[foo, bar]" instead of "[a.foo, a.bar]".', + ], ]) { it(`should throw for '${expression}'`, () => { - expect(() => new GraphOptions({ noInsert: expression })).to.throwException((err) => { - expect(err.message).to.equal( - `invalid relation expression "${expression}" in noInsert option: ${message}`, - ); - }); - }); - } - }); - - describe('rebasedOptions', () => { - function rebasedMatches(noInsert, rootRelationPathKey, relationPathKey) { - const options = new GraphOptions({ noInsert }).rebasedOptions( - createNode(rootRelationPathKey), - ); - return options._hasOption(createNode(relationPathKey), 'noInsert'); - } - - for (const noInsert of [['a', 'a.b', 'ab.c', 'x.a'], '[a.b, ab.c, x.a]']) { - it(`should match ${JSON.stringify(noInsert)} against the full relation path`, () => { - expect(rebasedMatches(noInsert, 'a', 'b')).to.equal(true); - expect(rebasedMatches(noInsert, 'a', 'c')).to.equal(false); - expect(rebasedMatches(noInsert, 'a', 'a')).to.equal(false); - expect(rebasedMatches(noInsert, 'x', 'a')).to.equal(true); + expect(() => new GraphOptions({ noInsert: expression })).toThrow( + expect.objectContaining({ + message: `invalid relation expression "${expression}" in noInsert option: ${message}`, + }), + ); }); } - - it('should match the new root', () => { - expect(rebasedMatches(['a'], 'a', '')).to.equal(true); - expect(rebasedMatches('a', 'a', '')).to.equal(true); - expect(rebasedMatches(['a'], 'b', '')).to.equal(false); - expect(rebasedMatches(['a.b'], 'a.b', '')).to.equal(true); - expect(rebasedMatches(['b'], 'a.b', '')).to.equal(false); - }); }); }); diff --git a/tests/unit/queryBuilder/JoinBuilder.js b/tests/unit/queryBuilder/JoinBuilder.js index b7b9b9285..bf27b5310 100644 --- a/tests/unit/queryBuilder/JoinBuilder.js +++ b/tests/unit/queryBuilder/JoinBuilder.js @@ -1,8 +1,8 @@ -const expect = require('expect.js'); -const objection = require('../../../'); +import { describe, it, expect } from 'vitest'; +import * as objection from 'objection'; const Model = objection.Model; -const { JoinBuilder } = require('../../../lib/queryBuilder/JoinBuilder'); -const JoinClause = require('knex/lib/query/joinclause'); +import { JoinBuilder } from '../../../lib/queryBuilder/JoinBuilder.js'; +import JoinClause from 'knex/lib/query/joinclause.js'; describe('JoinBuilder', () => { it('should have knex.JoinClause methods', () => { @@ -19,9 +19,9 @@ describe('JoinBuilder', () => { for (let name in knexJoinClause) { let func = knexJoinClause[name]; if (typeof func === 'function' && ignore.indexOf(name) === -1) { - if (typeof builder[name] !== 'function') { - expect().to.fail("knex method '" + name + "' is missing from JoinBuilder"); - } + expect(builder[name], `knex method '${name}' is missing from JoinBuilder`).toBeTypeOf( + 'function', + ); } } }); diff --git a/tests/unit/queryBuilder/QueryBuilder.js b/tests/unit/queryBuilder/QueryBuilder.js index e705441e2..0dfa220b3 100644 --- a/tests/unit/queryBuilder/QueryBuilder.js +++ b/tests/unit/queryBuilder/QueryBuilder.js @@ -1,12 +1,14 @@ -const Knex = require('knex'), - expect = require('expect.js'), - chai = require('chai'), - objection = require('../../../'), - knexUtils = require('../../../lib/utils/knexUtils'), - knexMocker = require('../../../testUtils/mockKnex'), - { delay } = require('../../../testUtils/testUtils'), - { resetWarnings } = require('../../../lib/utils/warnings'), - ref = objection.ref, +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import Knex from 'knex'; +import * as objection from 'objection'; +import * as knexUtils from '../../../lib/utils/knexUtils.js'; +import knexMocker from '../../../testUtils/mockKnex.js'; +import { delay, expectThrows } from '../../../testUtils/testUtils.js'; +import { resetWarnings } from '../../../lib/utils/warnings.js'; +import { JoinEagerOperation } from '../../../lib/queryBuilder/operations/eager/JoinEagerOperation.js'; +import { WhereInEagerOperation } from '../../../lib/queryBuilder/operations/eager/WhereInEagerOperation.js'; + +const ref = objection.ref, raw = objection.raw, val = objection.val, Model = objection.Model, @@ -20,7 +22,7 @@ describe('QueryBuilder', () => { let mockKnex = null; let TestModel = null; - before(() => { + beforeAll(() => { let knex = Knex({ client: 'pg' }); mockKnex = knexMocker(knex, function (mock, oldImpl, args) { @@ -47,18 +49,18 @@ describe('QueryBuilder', () => { TestModel.knex(mockKnex); }); - it("should throw if model doesn't have a `tableName`", (done) => { + it("should throw if model doesn't have a `tableName`", () => { class TestModel extends Model { // no tableName } - TestModel.query(mockKnex) - .then(() => done(new Error('should not get here'))) - .catch((err) => { - expect(err.message).to.equal('Model TestModel must have a static property tableName'); - done(); + return TestModel.query(mockKnex) + .then(() => { + throw new Error('should not get here'); }) - .catch(done); + .catch((err) => { + expect(err.message).toBe('Model TestModel must have a static property tableName'); + }); }); it('should have knex methods', () => { @@ -86,15 +88,15 @@ describe('QueryBuilder', () => { for (let name in mockKnex) { let func = mockKnex[name]; if (typeof func === 'function' && name.charAt(0) !== '_' && ignore.indexOf(name) === -1) { - if (typeof builder[name] !== 'function') { - expect().to.fail("knex method '" + name + "' is missing from QueryBuilder"); - } + expect(builder[name], `knex method '${name}' is missing from QueryBuilder`).toBeTypeOf( + 'function', + ); } } }); it('modelClass() should return the model class', () => { - expect(QueryBuilder.forClass(TestModel).modelClass() === TestModel).to.equal(true); + expect(QueryBuilder.forClass(TestModel).modelClass() === TestModel).toBe(true); }); it('modify() should execute the given function and pass the builder to it', () => { @@ -103,11 +105,11 @@ describe('QueryBuilder', () => { builder.modify(function (b) { called = true; - expect(b === builder).to.equal(true); - expect(this === builder).to.equal(true); + expect(b === builder).toBe(true); + expect(this === builder).toBe(true); }); - expect(called).to.equal(true); + expect(called).toBe(true); }); it('should be able to pass arguments to modify', () => { @@ -119,15 +121,15 @@ describe('QueryBuilder', () => { builder.modify( (query, arg1, arg2) => { called1 = true; - expect(query === builder).to.equal(true); - expect(arg1).to.equal('foo'); - expect(arg2).to.equal(1); + expect(query === builder).toBe(true); + expect(arg1).toBe('foo'); + expect(arg2).toBe(1); }, 'foo', 1, ); - expect(called1).to.equal(true); + expect(called1).toBe(true); called1 = false; called2 = false; @@ -136,24 +138,24 @@ describe('QueryBuilder', () => { [ (query, arg1, arg2) => { called1 = true; - expect(query === builder).to.equal(true); - expect(arg1).to.equal('foo'); - expect(arg2).to.equal(1); + expect(query === builder).toBe(true); + expect(arg1).toBe('foo'); + expect(arg2).toBe(1); }, (query, arg1, arg2) => { called2 = true; - expect(query === builder).to.equal(true); - expect(arg1).to.equal('foo'); - expect(arg2).to.equal(1); + expect(query === builder).toBe(true); + expect(arg1).toBe('foo'); + expect(arg2).toBe(1); }, ], 'foo', 1, ); - expect(called1).to.equal(true); - expect(called2).to.equal(true); + expect(called1).toBe(true); + expect(called2).toBe(true); }); it('should be able to pass arguments to modify when using named modifiers', () => { @@ -165,22 +167,22 @@ describe('QueryBuilder', () => { TestModel.modifiers = { modifier1: (query, arg1, arg2) => { called1 = true; - expect(query === builder).to.equal(true); - expect(arg1).to.equal('foo'); - expect(arg2).to.equal(1); + expect(query === builder).toBe(true); + expect(arg1).toBe('foo'); + expect(arg2).toBe(1); }, modifier2: (query, arg1, arg2) => { called2 = true; - expect(query === builder).to.equal(true); - expect(arg1).to.equal('foo'); - expect(arg2).to.equal(1); + expect(query === builder).toBe(true); + expect(arg1).toBe('foo'); + expect(arg2).toBe(1); }, }; // Should accept a single modifier. builder.modify('modifier1', 'foo', 1); - expect(called1).to.equal(true); + expect(called1).toBe(true); called1 = false; called2 = false; @@ -188,8 +190,8 @@ describe('QueryBuilder', () => { // Should accept an array of modifiers. builder.modify(['modifier1', 'modifier2'], 'foo', 1); - expect(called1).to.equal(true); - expect(called2).to.equal(true); + expect(called1).toBe(true); + expect(called2).toBe(true); }); it('should throw if an unknown modifier is specified', () => { @@ -199,11 +201,11 @@ describe('QueryBuilder', () => { expect(() => { builder.modify('unknown'); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'Unable to determine modify function from provided value: "unknown".', - ); - }); + }).toThrow( + expect.objectContaining({ + message: 'Unable to determine modify function from provided value: "unknown".', + }), + ); }); it('modify() should do nothing when receiving `undefined`', () => { @@ -211,8 +213,8 @@ describe('QueryBuilder', () => { let res; expect(() => { res = builder.modify(undefined); - }).to.not.throwException(); - expect(res === builder).to.equal(true); + }).not.toThrow(); + expect(res === builder).toBe(true); }); it('modify accept a list of strings and call the corresponding modifiers', () => { @@ -238,32 +240,32 @@ describe('QueryBuilder', () => { aCalled = false; bCalled = false; builder.modify('a'); - expect(aCalled).to.equal(true); - expect(bCalled).to.equal(false); + expect(aCalled).toBe(true); + expect(bCalled).toBe(false); aCalled = false; bCalled = false; builder.modify('b'); - expect(aCalled).to.equal(false); - expect(bCalled).to.equal(true); + expect(aCalled).toBe(false); + expect(bCalled).toBe(true); aCalled = false; bCalled = false; builder.modify(['a', 'b']); - expect(aCalled).to.equal(true); - expect(bCalled).to.equal(true); + expect(aCalled).toBe(true); + expect(bCalled).toBe(true); aCalled = false; bCalled = false; builder.modify([['a', [[['b']]]]]); - expect(aCalled).to.equal(true); - expect(bCalled).to.equal(true); + expect(aCalled).toBe(true); + expect(bCalled).toBe(true); aCalled = false; bCalled = false; builder.modify('d'); - expect(aCalled).to.equal(true); - expect(bCalled).to.equal(true); + expect(aCalled).toBe(true); + expect(bCalled).toBe(true); }); it('modify calls the modifierNotFound() hook for unknown modifiers', () => { @@ -285,19 +287,19 @@ describe('QueryBuilder', () => { caughtModifiers = []; builder.modify('a'); - expect(caughtModifiers).to.eql(['a']); + expect(caughtModifiers).toEqual(['a']); caughtModifiers = []; builder.modify('b'); - expect(caughtModifiers).to.eql(['b']); + expect(caughtModifiers).toEqual(['b']); caughtModifiers = []; builder.modify('c'); - expect(caughtModifiers).to.eql(['a']); + expect(caughtModifiers).toEqual(['a']); caughtModifiers = []; builder.modify('d'); - expect(caughtModifiers).to.eql(['a', 'b']); + expect(caughtModifiers).toEqual(['a', 'b']); }); it('should still throw if modifierNotFound() delegate to the definition in the super class', () => { @@ -309,11 +311,11 @@ describe('QueryBuilder', () => { expect(() => { builder.modify('unknown'); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'Unable to determine modify function from provided value: "unknown".', - ); - }); + }).toThrow( + expect.objectContaining({ + message: 'Unable to determine modify function from provided value: "unknown".', + }), + ); }); it('should not throw if modifierNotFound() handles an unknown modifier', () => { @@ -326,45 +328,39 @@ describe('QueryBuilder', () => { expect(() => { builder.modify('unknown'); - }).to.not.throwException(); - expect(caughtModifier).to.equal('unknown'); + }).not.toThrow(); + expect(caughtModifier).toBe('unknown'); }); - it('should call the callback passed to .then after execution', (done) => { + it('should call the callback passed to .then after execution', () => { mockKnexQueryResults = [[{ a: 1 }, { a: 2 }]]; - // Make sure the callback is called by not returning a promise from the test. - // Instead call the `done` function so that the test times out if the callback - // is not called. - QueryBuilder.forClass(TestModel) - .then((result) => { - expect(result).to.eql(mockKnexQueryResults[0]); - done(); - }) - .catch(done); + return QueryBuilder.forClass(TestModel).then((result) => { + expect(result).toEqual(mockKnexQueryResults[0]); + }); }); it('should return a promise from .then method', () => { let promise = QueryBuilder.forClass(TestModel).then((it) => it); - expect(promise).to.be.a(Promise); + expect(promise).toBeInstanceOf(Promise); return promise; }); it('should return a promise from .execute method', () => { let promise = QueryBuilder.forClass(TestModel).execute(); - expect(promise).to.be.a(Promise); + expect(promise).toBeInstanceOf(Promise); return promise; }); it('should return a promise from .catch method', () => { let promise = QueryBuilder.forClass(TestModel).catch(() => {}); - expect(promise).to.be.a(Promise); + expect(promise).toBeInstanceOf(Promise); return promise; }); it('should select all from the model table if no query methods are called', () => { let queryBuilder = QueryBuilder.forClass(TestModel); return queryBuilder.then(() => { - expect(executedQueries).to.eql(['select "Model".* from "Model"']); + expect(executedQueries).toEqual(['select "Model".* from "Model"']); }); }); @@ -380,12 +376,12 @@ describe('QueryBuilder', () => { .orWhere(function (builder) { // The builder passed to these functions should be a QueryBuilderBase instead of // knex query builder. - expect(this).to.equal(builder); - expect(this).to.be.a(QueryBuilderBase); + expect(this).toBe(builder); + expect(this).toBeInstanceOf(QueryBuilderBase); this.where('age', '<', 10).andWhere('eyeColor', 'blue'); }) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ [ 'select "name", "id", "age" from "Model"', 'inner join "AnotherTable" on "AnotherTable"."modelId" = "Model"."id"', @@ -401,7 +397,7 @@ describe('QueryBuilder', () => { it('should return a QueryBuilder from .timeout method', () => { const builder = QueryBuilder.forClass(TestModel).timeout(3000); - expect(builder).to.be.a(QueryBuilder); + expect(builder).toBeInstanceOf(QueryBuilder); return builder; }); @@ -410,7 +406,7 @@ describe('QueryBuilder', () => { return QueryBuilder.forClass(TestModel) .where('SomeTable.someColumn', ref('SomeOtherTable.someOtherColumn')) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "Model".* from "Model" where "SomeTable"."someColumn" = "SomeOtherTable"."someOtherColumn"', ]); }); @@ -420,7 +416,7 @@ describe('QueryBuilder', () => { return QueryBuilder.forClass(TestModel) .where('SomeTable.someColumn', '>', ref('SomeOtherTable.someOtherColumn')) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "Model".* from "Model" where "SomeTable"."someColumn" > "SomeOtherTable"."someOtherColumn"', ]); }); @@ -432,9 +428,7 @@ describe('QueryBuilder', () => { .where('SomeTable.someColumn', 'lol', ref('SomeOtherTable.someOtherColumn')) .toKnexQuery() .toString(); - }).to.throwException((err) => { - expect(err.message).to.equal('The operator "lol" is not permitted'); - }); + }).toThrow(expect.objectContaining({ message: 'The operator "lol" is not permitted' })); }); it('orWhere(..., ref(...)) should create a where clause using column references instead of values', () => { @@ -442,7 +436,7 @@ describe('QueryBuilder', () => { .where('id', 10) .orWhere('SomeTable.someColumn', ref('SomeOtherTable.someOtherColumn')) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "Model".* from "Model" where "id" = 10 or "SomeTable"."someColumn" = "SomeOtherTable"."someOtherColumn"', ]); }); @@ -456,7 +450,7 @@ describe('QueryBuilder', () => { .orderBy([ref('a'), ref('Model.b')]) .toKnexQuery() .toString(), - ).to.equal('select "Model".* from "Model" order by "a" asc, "Model"."b" asc'); + ).toBe('select "Model".* from "Model" order by "a" asc, "Model"."b" asc'); }); it('should support mixed strings, refs, raws and objects as array items', () => { @@ -471,7 +465,7 @@ describe('QueryBuilder', () => { ]) .toKnexQuery() .toString(), - ).to.equal( + ).toBe( 'select "Model".* from "Model" order by "a" asc, "b" asc, lower("c") asc, "d" desc, "e" desc nulls last', ); }); @@ -482,7 +476,7 @@ describe('QueryBuilder', () => { return QueryBuilder.forClass(TestModel) .whereComposite(['A.a', 'B.b'], '>', [1, 2]) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "Model".* from "Model" where ("A"."a" > 1 and "B"."b" > 2)', ]); }); @@ -494,16 +488,14 @@ describe('QueryBuilder', () => { .whereComposite('SomeTable.someColumn', 'lol', 'SomeOtherTable.someOtherColumn') .toKnexQuery() .toString(); - }).to.throwException((err) => { - expect(err.message).to.equal('The operator "lol" is not permitted'); - }); + }).toThrow(expect.objectContaining({ message: 'The operator "lol" is not permitted' })); }); it('operator should default to `=`', () => { return QueryBuilder.forClass(TestModel) .whereComposite(['A.a', 'B.b'], [1, 2]) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "Model".* from "Model" where ("A"."a" = 1 and "B"."b" = 2)', ]); }); @@ -513,7 +505,7 @@ describe('QueryBuilder', () => { return QueryBuilder.forClass(TestModel) .whereComposite(['A.a'], 1) .then(() => { - expect(executedQueries).to.eql(['select "Model".* from "Model" where "A"."a" = 1']); + expect(executedQueries).toEqual(['select "Model".* from "Model" where "A"."a" = 1']); }); }); @@ -521,7 +513,7 @@ describe('QueryBuilder', () => { return QueryBuilder.forClass(TestModel) .whereComposite('A.a', 1) .then(() => { - expect(executedQueries).to.eql(['select "Model".* from "Model" where "A"."a" = 1']); + expect(executedQueries).toEqual(['select "Model".* from "Model" where "A"."a" = 1']); }); }); }); @@ -531,7 +523,7 @@ describe('QueryBuilder', () => { return QueryBuilder.forClass(TestModel) .whereInComposite(['A.a', 'B.b'], [1, 2]) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "Model".* from "Model" where ("A"."a", "B"."b") in ((1, 2))', ]); }); @@ -547,7 +539,7 @@ describe('QueryBuilder', () => { ], ) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "Model".* from "Model" where ("A"."a", "B"."b") in ((1, 2), (3, 4))', ]); }); @@ -557,7 +549,9 @@ describe('QueryBuilder', () => { return QueryBuilder.forClass(TestModel) .whereInComposite(['A.a'], [[1], [3]]) .then(() => { - expect(executedQueries).to.eql(['select "Model".* from "Model" where "A"."a" in (1, 3)']); + expect(executedQueries).toEqual([ + 'select "Model".* from "Model" where "A"."a" in (1, 3)', + ]); }); }); @@ -565,7 +559,9 @@ describe('QueryBuilder', () => { return QueryBuilder.forClass(TestModel) .whereInComposite('A.a', [[1], [3]]) .then(() => { - expect(executedQueries).to.eql(['select "Model".* from "Model" where "A"."a" in (1, 3)']); + expect(executedQueries).toEqual([ + 'select "Model".* from "Model" where "A"."a" in (1, 3)', + ]); }); }); @@ -573,7 +569,9 @@ describe('QueryBuilder', () => { return QueryBuilder.forClass(TestModel) .whereInComposite('A.a', [1, 3]) .then(() => { - expect(executedQueries).to.eql(['select "Model".* from "Model" where "A"."a" in (1, 3)']); + expect(executedQueries).toEqual([ + 'select "Model".* from "Model" where "A"."a" in (1, 3)', + ]); }); }); @@ -581,7 +579,7 @@ describe('QueryBuilder', () => { return QueryBuilder.forClass(TestModel) .whereInComposite('A.a', TestModel.query().select('a')) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "Model".* from "Model" where "A"."a" in (select "a" from "Model")', ]); }); @@ -591,7 +589,7 @@ describe('QueryBuilder', () => { return QueryBuilder.forClass(TestModel) .whereInComposite('A.a', 1) .then(() => { - expect(executedQueries).to.eql(['select "Model".* from "Model" where "A"."a" in (1)']); + expect(executedQueries).toEqual(['select "Model".* from "Model" where "A"."a" in (1)']); }); }); @@ -599,7 +597,7 @@ describe('QueryBuilder', () => { return QueryBuilder.forClass(TestModel) .whereInComposite(['A.a', 'B.b'], TestModel.query().select('a', 'b')) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "Model".* from "Model" where ("A"."a","B"."b") in (select "a", "b" from "Model")', ]); }); @@ -612,7 +610,7 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql).to.equal( + expect(sql).toBe( 'select [Model].* from [Model] where exists (select * from (select [a], [b] from [Model]) AS V([objectiontmpjoin0],[objectiontmpjoin1]) where [objectiontmpjoin0] = [A].[a] and [objectiontmpjoin1] = [B].[b])', ); }); @@ -621,15 +619,13 @@ describe('QueryBuilder', () => { const knex = Knex({ client: 'mssql' }); const sql = (builder) => builder.alias('m').where('m.a', 1).toKnexQuery().toString(); - expect(sql(TestModel.query(knex).delete())).to.equal( + expect(sql(TestModel.query(knex).delete())).toBe( 'with [m] as (select * from [Model]) delete from [m] where [m].[a] = 1;select @@rowcount', ); - expect(sql(TestModel.query(knex).patch({ b: 2 }))).to.equal( + expect(sql(TestModel.query(knex).patch({ b: 2 }))).toBe( 'with [m] as (select * from [Model]) update [m] set [b] = 2 where [m].[a] = 1;select @@rowcount', ); - expect(sql(TestModel.query(knex))).to.equal( - 'select [m].* from [Model] as [m] where [m].[a] = 1', - ); + expect(sql(TestModel.query(knex))).toBe('select [m].* from [Model] as [m] where [m].[a] = 1'); }); it('should create a where-in query for composite id and a subquery in sqlite', () => { @@ -640,10 +636,10 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql('whereInComposite')).to.equal( + expect(sql('whereInComposite')).toBe( 'select `Model`.* from `Model` where (`A`.`a`, `B`.`b`) in (select `a`, `b` from `Model`)', ); - expect(sql('whereNotInComposite')).to.equal( + expect(sql('whereNotInComposite')).toBe( 'select `Model`.* from `Model` where (`A`.`a`, `B`.`b`) not in (select `a`, `b` from `Model`)', ); }); @@ -673,7 +669,7 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql).to.equal(whereInSql); + expect(sql).toBe(whereInSql); }); it(`whereNotInComposite should match everything (${client})`, () => { @@ -682,7 +678,7 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql).to.equal(whereNotInSql); + expect(sql).toBe(whereNotInSql); }); } @@ -692,7 +688,7 @@ describe('QueryBuilder', () => { return TestModel.query() .findByIds([]) .then(() => { - expect(executedQueries).to.eql(['select "Model".* from "Model" where 1 = 0']); + expect(executedQueries).toEqual(['select "Model".* from "Model" where 1 = 0']); }); }); }); @@ -704,7 +700,7 @@ describe('QueryBuilder', () => { const options = { includeTriggerModifications: true }; const expected = knex('Model').insert({ a: 1 }).returning(['id', 'a'], options).toString(); - expect(expected).to.contain('#out'); + expect(expected).toContain('#out'); for (const args of [ ['id', 'a', options], @@ -716,7 +712,7 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql).to.equal(expected); + expect(sql).toBe(expected); } }); @@ -727,7 +723,7 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql).to.equal('update "Model" set "a" = 1 returning "id", "a"'); + expect(sql).toBe('update "Model" set "a" = 1 returning "id", "a"'); }); it('should keep the options when cloned', () => { @@ -738,7 +734,7 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql).to.contain('#out'); + expect(sql).toContain('#out'); }); }); @@ -749,36 +745,34 @@ describe('QueryBuilder', () => { const patchSql = (client) => query(client).patch({ a: 1 }).where('b', 2).orderBy('c').toKnexQuery().toString(); - expect(patchSql('pg')).to.equal('update "Model" set "a" = 1 where "b" = 2'); - expect(patchSql('mysql')).to.equal( - 'update `Model` set `a` = 1 where `b` = 2 order by `c` asc', - ); - expect(patchSql('sqlite3')).to.equal('update `Model` set `a` = 1 where `b` = 2'); + expect(patchSql('pg')).toBe('update "Model" set "a" = 1 where "b" = 2'); + expect(patchSql('mysql')).toBe('update `Model` set `a` = 1 where `b` = 2 order by `c` asc'); + expect(patchSql('sqlite3')).toBe('update `Model` set `a` = 1 where `b` = 2'); }); it('should leave out the order on mssql', () => { expect( query('mssql').patch({ a: 1 }).where('b', 2).orderBy('c').toKnexQuery().toString(), - ).to.equal('update [Model] set [a] = 1 where [b] = 2;select @@rowcount'); + ).toBe('update [Model] set [a] = 1 where [b] = 2;select @@rowcount'); expect( query('mssql').patchAndFetchById(1, { a: 1 }).orderBy('c').toKnexQuery().toString(), - ).to.equal('update [Model] set [a] = 1 where [Model].[id] = 1;select @@rowcount'); + ).toBe('update [Model] set [a] = 1 where [Model].[id] = 1;select @@rowcount'); expect( query('mssql') .update({ a: 1 }) .onBuild((builder) => builder.orderBy('c')) .toKnexQuery() .toString(), - ).to.equal('update [Model] set [a] = 1;select @@rowcount'); - expect(query('mssql').delete().where('b', 2).orderBy('c').toKnexQuery().toString()).to.equal( + ).toBe('update [Model] set [a] = 1;select @@rowcount'); + expect(query('mssql').delete().where('b', 2).orderBy('c').toKnexQuery().toString()).toBe( 'delete from [Model] where [b] = 2;select @@rowcount', ); }); it('should keep the order with a limit on mssql', () => { - expect( - query('mssql').patch({ a: 1 }).orderBy('c').limit(1).toKnexQuery().toString(), - ).to.equal('update top (1) [Model] set [a] = 1 order by [c] asc;select @@rowcount'); + expect(query('mssql').patch({ a: 1 }).orderBy('c').limit(1).toKnexQuery().toString()).toBe( + 'update top (1) [Model] set [a] = 1 order by [c] asc;select @@rowcount', + ); }); }); @@ -811,12 +805,8 @@ describe('QueryBuilder', () => { pg: 'delete from "personTag" using (select "person".* from "person" where "favorite" = ?) as "person" where "person"."category" = ? and "person"."id" = "personTag"."personId"', }; - // The bindings are only kept in order since knex 3.3. - const [major, minor] = require('knex/package.json').version.split('.').map(Number); - const itKnex33 = major > 3 || (major === 3 && minor >= 3) ? it : it.skip; - for (const [client, sql] of Object.entries(expected)) { - itKnex33(`should keep the bindings in the order of the sql (${client})`, () => { + it(`should keep the bindings in the order of the sql (${client})`, () => { const query = PersonTag.query(Knex({ client })) .joinRelated('person(favoriteFilter)') .modifiers({ favoriteFilter: (builder) => builder.where('favorite', true) }) @@ -825,8 +815,8 @@ describe('QueryBuilder', () => { .toKnexQuery() .toSQL(); - expect(query.sql).to.equal(sql); - expect(query.bindings).to.eql([true, 'Follower']); + expect(query.sql).toBe(sql); + expect(query.bindings).toEqual([true, 'Follower']); }); } }); @@ -835,10 +825,10 @@ describe('QueryBuilder', () => { mockKnexQueryResults = [[{ a: 1 }, { a: 2 }]]; return QueryBuilder.forClass(TestModel).then((result) => { - expect(result).to.have.length(2); - expect(result[0]).to.be.a(TestModel); - expect(result[1]).to.be.a(TestModel); - expect(result).to.eql(mockKnexQueryResults[0]); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(TestModel); + expect(result[1]).toBeInstanceOf(TestModel); + expect(result).toEqual(mockKnexQueryResults[0]); }); }); @@ -846,62 +836,58 @@ describe('QueryBuilder', () => { mockKnexQueryResults = [{ a: 1 }]; return QueryBuilder.forClass(TestModel).then((result) => { - expect(result).to.be.a(TestModel); - expect(result.a).to.equal(1); + expect(result).toBeInstanceOf(TestModel); + expect(result.a).toBe(1); }); }); - it('should pass the query builder as `this` and parameter for the hooks', (done) => { + it('should pass the query builder as `this` and parameter for the hooks', () => { let text = ''; - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .runBefore(function (result, builder) { - expect(builder.constructor.name).to.equal('QueryBuilder'); - expect(this).to.equal(builder); + expect(builder.constructor.name).toBe('QueryBuilder'); + expect(this).toBe(builder); text += 'a'; }) .onBuild(function (builder) { - expect(builder.constructor.name).to.equal('QueryBuilder'); - expect(this).to.equal(builder); + expect(builder.constructor.name).toBe('QueryBuilder'); + expect(this).toBe(builder); text += 'b'; }) .onBuildKnex(function (knexBuilder, builder) { - expect(builder.constructor.name).to.equal('QueryBuilder'); - expect(knexUtils.isKnexQueryBuilder(knexBuilder)).to.equal(true); - expect(this).to.equal(knexBuilder); + expect(builder.constructor.name).toBe('QueryBuilder'); + expect(knexUtils.isKnexQueryBuilder(knexBuilder)).toBe(true); + expect(this).toBe(knexBuilder); text += 'c'; }) .runAfter(function (data, builder) { - expect(builder.constructor.name).to.equal('QueryBuilder'); - expect(this).to.equal(builder); + expect(builder.constructor.name).toBe('QueryBuilder'); + expect(this).toBe(builder); text += 'd'; }) .runAfter(function (data, builder) { - expect(builder.constructor.name).to.equal('QueryBuilder'); - expect(this).to.equal(builder); + expect(builder.constructor.name).toBe('QueryBuilder'); + expect(this).toBe(builder); text += 'e'; }) .runAfter(() => { throw new Error('abort'); }) .onError(function (err, builder) { - expect(builder.constructor.name).to.equal('QueryBuilder'); - expect(this).to.equal(builder); - expect(err.message).to.equal('abort'); + expect(builder.constructor.name).toBe('QueryBuilder'); + expect(this).toBe(builder); + expect(err.message).toBe('abort'); text += 'f'; }) .then(() => { - expect(text).to.equal('abcdef'); - done(); - }) - .catch((err) => { - done(err); + expect(text).toBe('abcdef'); }); }); - it('throwing at any phase should call the onError hook', (done) => { + it('throwing at any phase should call the onError hook', () => { let called = false; - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .runBefore(function (result, builder) { throw new Error(); }) @@ -909,16 +895,12 @@ describe('QueryBuilder', () => { called = true; }) .then(() => { - expect(called).to.equal(true); - done(); - }) - .catch((err) => { - done(err); + expect(called).toBe(true); }); }); - it('any return value from onError should be the result of the query', (done) => { - QueryBuilder.forClass(TestModel) + it('any return value from onError should be the result of the query', () => { + return QueryBuilder.forClass(TestModel) .runBefore(function (result, builder) { throw new Error(); }) @@ -926,93 +908,83 @@ describe('QueryBuilder', () => { return 'my custom error'; }) .then((result) => { - expect(result).to.equal('my custom error'); - done(); - }) - .catch((err) => { - done(err); + expect(result).toBe('my custom error'); }); }); - it('should call run* methods in the correct order', (done) => { + it('should call run* methods in the correct order', () => { mockKnexQueryResults = [0]; - // Again call `done` instead of returning a promise just to make sure the final - // `.then` callback is called. (I'm paranoid). - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .runBefore(() => { - expect(mockKnexQueryResults[0]).to.equal(0); + expect(mockKnexQueryResults[0]).toBe(0); return ++mockKnexQueryResults[0]; }) .runBefore(() => { - expect(mockKnexQueryResults[0]).to.equal(1); + expect(mockKnexQueryResults[0]).toBe(1); return delay(1).then(() => ++mockKnexQueryResults[0]); }) .runBefore(() => { - expect(mockKnexQueryResults[0]).to.equal(2); + expect(mockKnexQueryResults[0]).toBe(2); ++mockKnexQueryResults[0]; }) .runAfter((res) => { - expect(res).to.equal(3); + expect(res).toBe(3); return delay(1).then(() => { return ++res; }); }) .runAfter((res) => { - expect(res).to.equal(4); + expect(res).toBe(4); return ++res; }) .then((res) => { - expect(res).to.equal(5); - done(); - }) - .catch(done); + expect(res).toBe(5); + }); }); - it('should not execute query if an error is thrown from runBefore', (done) => { - QueryBuilder.forClass(TestModel) + it('should not execute query if an error is thrown from runBefore', () => { + return QueryBuilder.forClass(TestModel) .runBefore(() => { throw new Error('some error'); }) .onBuild(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .runAfter(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal('some error'); - expect(executedQueries).to.have.length(0); - done(); + expect(err.message).toBe('some error'); + expect(executedQueries).toHaveLength(0); }); }); - it('should reject promise if an error is throw from from runAfter', (done) => { - QueryBuilder.forClass(TestModel) + it('should reject promise if an error is throw from from runAfter', () => { + return QueryBuilder.forClass(TestModel) .runAfter(() => { throw new Error('some error'); }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal('some error'); - done(); + expect(err.message).toBe('some error'); }); }); it('should call custom find implementation defined by findOperationFactory', () => { return QueryBuilder.forClass(TestModel) .findOperationFactory(function (builder) { - expect(builder).to.equal(this); + expect(builder).toBe(this); return createFindOperation(builder, { a: 1 }); }) .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal('select "Model".* from "Model" where "a" = 1'); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe('select "Model".* from "Model" where "a" = 1'); }); }); @@ -1023,8 +995,8 @@ describe('QueryBuilder', () => { }) .insert({ a: 1 }) .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal('insert into "Model" ("a") values (1) returning "id"'); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe('insert into "Model" ("a") values (1) returning "id"'); }); }); @@ -1035,8 +1007,8 @@ describe('QueryBuilder', () => { }) .update({ a: 1 }) .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal('update "Model" set "a" = 1'); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe('update "Model" set "a" = 1'); }); }); @@ -1047,8 +1019,8 @@ describe('QueryBuilder', () => { }) .delete() .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal('delete from "Model"'); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe('delete from "Model"'); }); }); @@ -1059,8 +1031,8 @@ describe('QueryBuilder', () => { }) .insert({ a: 1 }) .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal('insert into "Model" ("a", "b") values (1, 2)'); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe('insert into "Model" ("a", "b") values (1, 2)'); }); }); @@ -1071,8 +1043,8 @@ describe('QueryBuilder', () => { }) .update({ a: 1 }) .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal('update "Model" set "a" = 1, "b" = 2'); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe('update "Model" set "a" = 1, "b" = 2'); }); }); @@ -1083,8 +1055,8 @@ describe('QueryBuilder', () => { }) .patch({ a: 1 }) .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal('update "Model" set "a" = 1, "b" = 2'); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe('update "Model" set "a" = 1, "b" = 2'); }); }); @@ -1095,8 +1067,8 @@ describe('QueryBuilder', () => { }) .delete() .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal('delete from "Model" where "id" = 100'); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe('delete from "Model" where "id" = 100'); }); }); @@ -1107,8 +1079,8 @@ describe('QueryBuilder', () => { }) .relate({ a: 1 }) .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal('insert into "Model" ("a", "b") values (1, 2)'); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe('insert into "Model" ("a", "b") values (1, 2)'); }); }); @@ -1119,8 +1091,8 @@ describe('QueryBuilder', () => { }) .unrelate() .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal('delete from "Model" where "id" = 100'); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe('delete from "Model" where "id" = 100'); }); }); @@ -1130,12 +1102,12 @@ describe('QueryBuilder', () => { const result = await TestModel.query().patchAndFetchById(1, { a: 1 }).select('id', 'a'); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'update "Model" set "a" = 1 where "Model"."id" = 1', 'select "id", "a" from "Model" where "Model"."id" = 1', ]); - expect(result).to.be.a(TestModel); - expect(result.toJSON()).to.eql({ id: 1, a: 1 }); + expect(result).toBeInstanceOf(TestModel); + expect(result.toJSON()).toEqual({ id: 1, a: 1 }); }); it('updateAndFetchById should apply selects to the fetch query', async () => { @@ -1143,7 +1115,7 @@ describe('QueryBuilder', () => { await TestModel.query().updateAndFetchById(1, { a: 1 }).select('a'); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'update "Model" set "a" = 1 where "Model"."id" = 1', 'select "a" from "Model" where "Model"."id" = 1', ]); @@ -1154,7 +1126,7 @@ describe('QueryBuilder', () => { await TestModel.fromJson({ id: 1 }).$query().patchAndFetch({ a: 1 }).select('a'); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'update "Model" set "a" = 1 where "Model"."id" = 1', 'select "a" from "Model" where "Model"."id" = 1', ]); @@ -1165,7 +1137,7 @@ describe('QueryBuilder', () => { await TestModel.fromJson({ id: 1 }).$query().updateAndFetch({ a: 1 }).select('a'); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'update "Model" set "a" = 1 where "Model"."id" = 1', 'select "a" from "Model" where "Model"."id" = 1', ]); @@ -1176,7 +1148,7 @@ describe('QueryBuilder', () => { await TestModel.query().patchAndFetchById(1, { a: 1 }); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'update "Model" set "a" = 1 where "Model"."id" = 1', 'select "Model".* from "Model" where "Model"."id" = 1', ]); @@ -1225,8 +1197,8 @@ describe('QueryBuilder', () => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal(message); - expect(executedQueries).to.have.length(0); + expect(err.message).toBe(message); + expect(executedQueries).toHaveLength(0); }); }); @@ -1237,8 +1209,8 @@ describe('QueryBuilder', () => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal(message); - expect(executedQueries).to.have.length(0); + expect(err.message).toBe(message); + expect(executedQueries).toHaveLength(0); }); }); } @@ -1246,9 +1218,7 @@ describe('QueryBuilder', () => { it("toKnexQuery() should throw for a query that wasn't created using relatedQuery", () => { expect(() => { TestModel.query().for(1).delete().toKnexQuery(); - }).to.throwException((err) => { - expect(err.message).to.equal(message); - }); + }).toThrow(expect.objectContaining({ message })); }); it('should work with queries created using relatedQuery', () => { @@ -1256,7 +1226,7 @@ describe('QueryBuilder', () => { .for(1) .delete() .then(() => { - expect(executedQueries).to.eql(['delete from "Model" where "Model"."ownerId" in (1)']); + expect(executedQueries).toEqual(['delete from "Model" where "Model"."ownerId" in (1)']); }); }); @@ -1265,7 +1235,7 @@ describe('QueryBuilder', () => { .where('a', 1) .for(1) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "Model".* from "Model" where "Model"."ownerId" in (1) and "a" = 1', ]); }); @@ -1285,11 +1255,12 @@ describe('QueryBuilder', () => { it(`should throw when called after ${name}() on a relatedQuery`, () => { expect(() => { create(Person.relatedQuery('pets')).for(1); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'for() must be called before insert, update, patch, delete, relate or unrelate on queries created using the static relatedQuery method', - ); - }); + }).toThrow( + expect.objectContaining({ + message: + 'for() must be called before insert, update, patch, delete, relate or unrelate on queries created using the static relatedQuery method', + }), + ); }); } }); @@ -1305,8 +1276,8 @@ describe('QueryBuilder', () => { .where('a', 1) .none() .then((result) => { - expect(result).to.eql([]); - expect(executedQueries).to.have.length(0); + expect(result).toEqual([]); + expect(executedQueries).toHaveLength(0); }); }); @@ -1316,8 +1287,8 @@ describe('QueryBuilder', () => { TestModel.query().none().findById(1), TestModel.query().findOne({ a: 1 }).none(), ]).then((results) => { - expect(results).to.eql([undefined, undefined, undefined]); - expect(executedQueries).to.have.length(0); + expect(results).toEqual([undefined, undefined, undefined]); + expect(executedQueries).toHaveLength(0); }); }); @@ -1341,8 +1312,8 @@ describe('QueryBuilder', () => { .withGraphFetched('pets') .none() .then((result) => { - expect(result).to.eql([]); - expect(executedQueries).to.have.length(0); + expect(result).toEqual([]); + expect(executedQueries).toHaveLength(0); }); }); @@ -1353,8 +1324,8 @@ describe('QueryBuilder', () => { TestModel.query().none().delete(), TestModel.query().none().deleteById(1), ]).then((results) => { - expect(results).to.eql([0, 0, 0, 0]); - expect(executedQueries).to.have.length(0); + expect(results).toEqual([0, 0, 0, 0]); + expect(executedQueries).toHaveLength(0); }); }); @@ -1363,8 +1334,8 @@ describe('QueryBuilder', () => { .none() .patchAndFetchById(1, { a: 1 }) .then((result) => { - expect(result).to.equal(undefined); - expect(executedQueries).to.have.length(0); + expect(result).toBeUndefined(); + expect(executedQueries).toHaveLength(0); }); }); @@ -1372,9 +1343,9 @@ describe('QueryBuilder', () => { const model = TestModel.fromJson({ id: 1, a: 1 }); const result = await model.$query().patch({ a: 2 }).none(); - expect(result).to.equal(0); - expect(model.a).to.equal(1); - expect(executedQueries).to.have.length(0); + expect(result).toBe(0); + expect(model.a).toBe(1); + expect(executedQueries).toHaveLength(0); }); it('should not modify the owners of a belongs to one relation delete', async () => { @@ -1396,9 +1367,9 @@ describe('QueryBuilder', () => { const pet = Pet.fromJson({ id: 1, ownerId: 2 }); const result = await pet.$relatedQuery('owner').delete().none(); - expect(result).to.equal(0); - expect(pet.ownerId).to.equal(2); - expect(executedQueries).to.have.length(0); + expect(result).toBe(0); + expect(pet.ownerId).toBe(2); + expect(executedQueries).toHaveLength(0); const resolved = await pet .$relatedQuery('owner') @@ -1407,9 +1378,9 @@ describe('QueryBuilder', () => { builder.resolve(0); }); - expect(resolved).to.equal(0); - expect(pet.ownerId).to.equal(2); - expect(executedQueries).to.have.length(0); + expect(resolved).toBe(0); + expect(pet.ownerId).toBe(2); + expect(executedQueries).toHaveLength(0); }); it('should not modify the instance of an instance patch resolved in a hook', async () => { @@ -1421,21 +1392,21 @@ describe('QueryBuilder', () => { builder.resolve(0); }); - expect(result).to.equal(0); - expect(model.a).to.equal(1); - expect(executedQueries).to.have.length(0); + expect(result).toBe(0); + expect(model.a).toBe(1); + expect(executedQueries).toHaveLength(0); }); it('should count falsy values passed to resolve() as resolved', () => { for (const value of [0, false, '', undefined]) { const builder = TestModel.query().resolve(value); - expect(builder.isExplicitlyResolvedOrRejected()).to.equal(true); - expect(builder.isExecutable()).to.equal(false); + expect(builder.isExplicitlyResolvedOrRejected()).toBe(true); + expect(builder.isExecutable()).toBe(false); } - expect(TestModel.query().isExplicitlyResolvedOrRejected()).to.equal(false); - expect(TestModel.query().reject(0).isExplicitlyResolvedOrRejected()).to.equal(true); + expect(TestModel.query().isExplicitlyResolvedOrRejected()).toBe(false); + expect(TestModel.query().reject(0).isExplicitlyResolvedOrRejected()).toBe(true); }); it('should return an empty array for update and delete queries with returning', () => { @@ -1443,8 +1414,8 @@ describe('QueryBuilder', () => { TestModel.query().none().patch({ a: 1 }).returning('*'), TestModel.query().none().delete().returning('*'), ]).then((results) => { - expect(results).to.eql([[], []]); - expect(executedQueries).to.have.length(0); + expect(results).toEqual([[], []]); + expect(executedQueries).toHaveLength(0); }); }); @@ -1456,10 +1427,8 @@ describe('QueryBuilder', () => { throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal( - 'none() can only be used with find, update and delete queries', - ); - expect(executedQueries).to.have.length(0); + expect(err.message).toBe('none() can only be used with find, update and delete queries'); + expect(executedQueries).toHaveLength(0); }); }); @@ -1472,14 +1441,14 @@ describe('QueryBuilder', () => { .orWhere('c', 3) .toKnexQuery() .toString(), - ).to.equal('select "Model".* from "Model" where 1 = 0'); + ).toBe('select "Model".* from "Model" where 1 = 0'); }); it('should replace all where clauses with an always false condition in subqueries', () => { return TestModel.query() .whereIn('id', TestModel.query().select('x').where('a', 1).orWhere('b', 2).none()) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "Model".* from "Model" where "id" in (select "x" from "Model" where 1 = 0)', ]); }); @@ -1493,8 +1462,8 @@ describe('QueryBuilder', () => { TestModel.query().sum('a as total').none(), TestModel.query().select('a').groupBy('a').none(), ]).then((results) => { - expect(results).to.eql([{ count: 0 }, [{ total: null }], []]); - expect(executedQueries).to.eql([ + expect(results).toEqual([{ count: 0 }, [{ total: null }], []]); + expect(executedQueries).toEqual([ 'select count(*) as "count" from "Model" where 1 = 0', 'select sum("a") as "total" from "Model" where 1 = 0', 'select "a" from "Model" where 1 = 0 group by "a"', @@ -1509,8 +1478,8 @@ describe('QueryBuilder', () => { .none() .resultSize() .then((result) => { - expect(result).to.equal(0); - expect(executedQueries).to.eql([ + expect(result).toBe(0); + expect(executedQueries).toEqual([ 'select count(*) as "count" from (select "Model".* from "Model" where 1 = 0) as "temp"', ]); }); @@ -1532,16 +1501,16 @@ describe('QueryBuilder', () => { return HookModel.query() .none() .then((result) => { - expect(result).to.eql([]); - expect(calls).to.eql(['beforeFind', ['afterFind', []]]); - expect(executedQueries).to.have.length(0); + expect(result).toEqual([]); + expect(calls).toEqual(['beforeFind', ['afterFind', []]]); + expect(executedQueries).toHaveLength(0); }); }); it('should be matched by has() and removed by clear()', () => { const query = TestModel.query().none(); - expect(query.has('none')).to.equal(true); - expect(query.clear('none').has('none')).to.equal(false); + expect(query.has('none')).toBe(true); + expect(query.clear('none').has('none')).toBe(false); }); }); @@ -1555,52 +1524,44 @@ describe('QueryBuilder', () => { return query .then(() => { - expect(executedQueries).to.have.length(1); - expect(query.toKnexQuery().toString()).to.equal(executedQueries[0]); - expect(executedQueries[0]).to.equal( - 'update "Model" set "a" = 1, "b" = 2 where "test" < 100', - ); + expect(executedQueries).toHaveLength(1); + expect(query.toKnexQuery().toString()).toBe(executedQueries[0]); + expect(executedQueries[0]).toBe('update "Model" set "a" = 1, "b" = 2 where "test" < 100'); executedQueries = []; return query; }) .then(() => { - expect(executedQueries).to.have.length(1); - expect(query.toKnexQuery().toString()).to.equal(executedQueries[0]); - expect(executedQueries[0]).to.equal( - 'update "Model" set "a" = 1, "b" = 2 where "test" < 100', - ); + expect(executedQueries).toHaveLength(1); + expect(query.toKnexQuery().toString()).toBe(executedQueries[0]); + expect(executedQueries[0]).toBe('update "Model" set "a" = 1, "b" = 2 where "test" < 100'); executedQueries = []; return query; }) .then(() => { - expect(executedQueries).to.have.length(1); - expect(query.toKnexQuery().toString()).to.equal(executedQueries[0]); - expect(executedQueries[0]).to.equal( - 'update "Model" set "a" = 1, "b" = 2 where "test" < 100', - ); + expect(executedQueries).toHaveLength(1); + expect(query.toKnexQuery().toString()).toBe(executedQueries[0]); + expect(executedQueries[0]).toBe('update "Model" set "a" = 1, "b" = 2 where "test" < 100'); }); }); - it('resultSize should create and execute a query that returns the size of the query', (done) => { + it('resultSize should create and execute a query that returns the size of the query', () => { mockKnexQueryResults = [[{ count: '123' }]]; - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .where('test', 100) .orderBy('order') .limit(10) .offset(100) .resultSize() .then((res) => { - expect(executedQueries).to.have.length(1); - expect(res).to.equal(123); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(1); + expect(res).toBe(123); + expect(executedQueries[0]).toBe( 'select count(*) as "count" from (select "Model".* from "Model" where "test" = 100) as "temp"', ); - done(); - }) - .catch(done); + }); }); - it('should consider withSchema when looking for column info', (done) => { + it('should consider withSchema when looking for column info', () => { class TestModelRelated extends Model { static get tableName() { return 'Related'; @@ -1629,56 +1590,50 @@ describe('QueryBuilder', () => { TestModelRelated.knex(mockKnex); mockKnexQueryResults = [[{ count: '123' }]]; - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .withSchema('someSchema') .withGraphJoined('relatedModel') .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ "select * from information_schema.columns where table_name = 'Model' and table_catalog = current_database() and table_schema = 'someSchema'", "select * from information_schema.columns where table_name = 'Related' and table_catalog = current_database() and table_schema = 'someSchema'", 'select "Model"."0" as "0" from "someSchema"."Model" left join "someSchema"."Related" as "relatedModel" on "relatedModel"."id" = "Model"."id"', ]); - done(); - }) - .catch(done); + }); }); - it('range should return a range and the total count', (done) => { + it('range should return a range and the total count', () => { mockKnexQueryResults = [[{ a: '1' }], [{ count: '123' }]]; - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .where('test', 100) .orderBy('order') .range(100, 200) .then((res) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries).to.eql([ + expect(executedQueries).toHaveLength(2); + expect(executedQueries).toEqual([ 'select "Model".* from "Model" where "test" = 100 order by "order" asc limit 101 offset 100', 'select count(*) as "count" from (select "Model".* from "Model" where "test" = 100) as "temp"', ]); - expect(res.total).to.equal(123); - expect(res.results).to.eql([{ a: 1 }]); - done(); - }) - .catch(done); + expect(res.total).toBe(123); + expect(res.results).toEqual([{ a: '1' }]); + }); }); - it('page should return a page and the total count', (done) => { + it('page should return a page and the total count', () => { mockKnexQueryResults = [[{ a: '1' }], [{ count: '123' }]]; - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .where('test', 100) .orderBy('order') .page(10, 100) .then((res) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries).to.eql([ + expect(executedQueries).toHaveLength(2); + expect(executedQueries).toEqual([ 'select "Model".* from "Model" where "test" = 100 order by "order" asc limit 100 offset 1000', 'select count(*) as "count" from (select "Model".* from "Model" where "test" = 100) as "temp"', ]); - expect(res.total).to.equal(123); - expect(res.results).to.eql([{ a: 1 }]); - done(); - }) - .catch(done); + expect(res.total).toBe(123); + expect(res.results).toEqual([{ a: '1' }]); + }); }); describe('resultSize with withGraphJoined', () => { @@ -1724,8 +1679,8 @@ describe('QueryBuilder', () => { .limit(1) .resultSize() .then((res) => { - expect(res).to.equal(2); - expect(executedQueries).to.eql([ + expect(res).toBe(2); + expect(executedQueries).toEqual([ metadataQuery('Person'), metadataQuery('Animal'), 'select count(*) as "count" from (select distinct "Person"."id" from "Person" left join "Animal" as "pets" on "pets"."ownerId" = "Person"."id" where "pets"."name" like \'A%\') as "temp"', @@ -1769,8 +1724,8 @@ describe('QueryBuilder', () => { .withGraphJoined('pets') .resultSize() .then((res) => { - expect(res).to.equal(3); - expect(executedQueries).to.eql([ + expect(res).toBe(3); + expect(executedQueries).toEqual([ 'select count(*) as "count" from (select distinct "Person"."id", "Person"."tenantId" from "Person" left join "Animal" as "pets" on "pets"."ownerId" = "Person"."id" and "pets"."tenantId" = "Person"."tenantId") as "temp"', ]); }); @@ -1788,8 +1743,8 @@ describe('QueryBuilder', () => { .having('personName', '=', 'A') .resultSize() .then((res) => { - expect(res).to.equal(1); - expect(executedQueries).to.eql([ + expect(res).toBe(1); + expect(executedQueries).toEqual([ 'select count(*) as "count" from (select distinct "id" from (select "Person"."id", "Person"."name" as "personName", "pets"."id" as "pets:id", "pets"."ownerId" as "pets:ownerId" from "Person" left join "Animal" as "pets" on "pets"."ownerId" = "Person"."id" group by "Person"."id", "pets"."id" having "personName" = \'A\') as "inner") as "temp"', ]); }); @@ -1812,9 +1767,9 @@ describe('QueryBuilder', () => { .orderBy('Person.id') .page(0, 10) .then((res) => { - expect(res.total).to.equal(2); - expect(res.results).to.have.length(2); - expect(executedQueries).to.eql([ + expect(res.total).toBe(2); + expect(res.results).toHaveLength(2); + expect(executedQueries).toEqual([ 'select "Person"."id" as "id", "Person"."name" as "name", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId" from "Person" left join "Animal" as "pets" on "pets"."ownerId" = "Person"."id" order by "Person"."id" asc limit 10', 'select count(*) as "count" from (select distinct "Person"."id" from "Person" left join "Animal" as "pets" on "pets"."ownerId" = "Person"."id") as "temp"', ]); @@ -1860,8 +1815,8 @@ describe('QueryBuilder', () => { .for(1) .resultSize() .then((res) => { - expect(res).to.equal(123); - expect(executedQueries).to.eql([ + expect(res).toBe(123); + expect(executedQueries).toEqual([ 'select count(*) as "count" from (select "Related".* from "Related" where "Related"."modelId" in (1)) as "temp"', ]); }); @@ -1873,8 +1828,8 @@ describe('QueryBuilder', () => { .for(1) .page(0, 10) .then((res) => { - expect(res.total).to.equal(123); - expect(executedQueries).to.eql([ + expect(res.total).toBe(123); + expect(executedQueries).toEqual([ 'select "Related".* from "Related" where "Related"."modelId" in (1) order by "order" asc limit 10', 'select count(*) as "count" from (select "Related".* from "Related" where "Related"."modelId" in (1)) as "temp"', ]); @@ -1913,13 +1868,9 @@ describe('QueryBuilder', () => { const query = queries[name]; for (const other in queries) { const method = getMethodName(other); - chai - .expect(query[method](), `queries.${name}.${method}()`) - .to.equal(method === getMethodName(name)); - chai - .expect(query.hasWheres(), `queries.${name}.hasWheres()`) - .to.equal(name.includes('relate')); - chai.expect(query.hasSelects(), `queries.${name}.hasSelects()`).to.equal(false); + expect(query[method](), `queries.${name}.${method}()`).toBe(method === getMethodName(name)); + expect(query.hasWheres(), `queries.${name}.hasWheres()`).toBe(name.includes('relate')); + expect(query.hasSelects(), `queries.${name}.hasSelects()`).toBe(false); } } }); @@ -1956,11 +1907,11 @@ describe('QueryBuilder', () => { }, }; - expect(TestModel.query().hasWheres()).to.equal(false); - expect(TestModel.query().insert({}).hasWheres()).to.equal(false); - expect(TestModel.query().update({}).hasWheres()).to.equal(false); - expect(TestModel.query().patch({}).hasWheres()).to.equal(false); - expect(TestModel.query().delete().hasWheres()).to.equal(false); + expect(TestModel.query().hasWheres()).toBe(false); + expect(TestModel.query().insert({}).hasWheres()).toBe(false); + expect(TestModel.query().update({}).hasWheres()).toBe(false); + expect(TestModel.query().patch({}).hasWheres()).toBe(false); + expect(TestModel.query().delete().hasWheres()).toBe(false); const wheres = [ 'findOne', @@ -2003,24 +1954,24 @@ describe('QueryBuilder', () => { for (let i = 0; i < wheres.length; i++) { const name = wheres[i]; const query = TestModel.query()[name](1, '=', 1); - chai.expect(query.hasWheres(), `TestModel.query().${name}().hasWheres()`).to.equal(true); + expect(query.hasWheres(), `TestModel.query().${name}().hasWheres()`).toBe(true); } const model = TestModel.fromJson({ id: 1, someId: 1 }); let query = model.$query(); - chai.expect(query.hasWheres()).to.equal(true); + expect(query.hasWheres()).toBe(true); query = model.$query().withGraphJoined('manyToManyRelation'); - chai.expect(query.hasWheres()).to.equal(true); + expect(query.hasWheres()).toBe(true); query = model.$relatedQuery('belongsToOneRelation'); - chai.expect(query.hasWheres()).to.equal(true); + expect(query.hasWheres()).toBe(true); query = model.$relatedQuery('hasManyRelation'); - chai.expect(query.hasWheres()).to.equal(true); + expect(query.hasWheres()).toBe(true); query = model.$relatedQuery('manyToManyRelation'); - chai.expect(query.hasWheres()).to.equal(true); + expect(query.hasWheres()).toBe(true); }); it('hasSelects() should return true for all variants of select queries', () => { @@ -2042,9 +1993,7 @@ describe('QueryBuilder', () => { for (let i = 0; i < selects.length; i++) { const name = selects[i]; const query = TestModel.query()[name]('arg'); - chai - .expect(query.hasSelects(), `TestModel.query().${name}('arg').hasSelects()`) - .to.equal(true); + expect(query.hasSelects(), `TestModel.query().${name}('arg').hasSelects()`).toBe(true); } }); @@ -2061,11 +2010,11 @@ describe('QueryBuilder', () => { }; const query = TestModel.query(); - expect(query.hasWithGraph()).to.equal(false); + expect(query.hasWithGraph()).toBe(false); query.withGraphFetched('someRel'); - expect(query.hasWithGraph()).to.equal(true); + expect(query.hasWithGraph()).toBe(true); query.clearWithGraph(); - expect(query.hasWithGraph()).to.equal(false); + expect(query.hasWithGraph()).toBe(false); }); it('has() should match defined query operations', () => { @@ -2088,12 +2037,8 @@ describe('QueryBuilder', () => { ]; const test = (query, name, expected) => { const regexp = new RegExp(`^${name}$`); - chai - .expect(query.has(name), `TestModel.query().${name}('arg').has('${name}')`) - .to.equal(expected); - chai - .expect(query.has(regexp), `TestModel.query().${name}('arg').has(${regexp})`) - .to.equal(expected); + expect(query.has(name), `TestModel.query().${name}('arg').has('${name}')`).toBe(expected); + expect(query.has(regexp), `TestModel.query().${name}('arg').has(${regexp})`).toBe(expected); }; operations.forEach((operation) => { @@ -2111,22 +2056,20 @@ describe('QueryBuilder', () => { operations.forEach((operation) => { const query = TestModel.query(); operations.forEach((operation) => query[operation]('arg')); - chai.expect(query.has(operation), `query().has('${operation}')`).to.equal(true); - chai - .expect( - query.clear(operation).has(operation), - `query().clear('${operation}').has('${operation}')`, - ) - .to.equal(false); + expect(query.has(operation), `query().has('${operation}')`).toBe(true); + expect( + query.clear(operation).has(operation), + `query().clear('${operation}').has('${operation}')`, + ).toBe(false); operations.forEach((testOperation) => { - chai - .expect(query.has(testOperation), `query().has('${testOperation}')`) - .to.equal(testOperation !== operation); + expect(query.has(testOperation), `query().has('${testOperation}')`).toBe( + testOperation !== operation, + ); }); }); }); - it('update() should call $beforeUpdate on the model', (done) => { + it('update() should call $beforeUpdate on the model', () => { TestModel.prototype.$beforeUpdate = function () { this.c = 'beforeUpdate'; }; @@ -2136,19 +2079,17 @@ describe('QueryBuilder', () => { }; let model = TestModel.fromJson({ a: 10, b: 'test' }); - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .update(model) .then(() => { - expect(model.c).to.equal('beforeUpdate'); - expect(executedQueries[0]).to.equal( + expect(model.c).toBe('beforeUpdate'); + expect(executedQueries[0]).toBe( 'update "Model" set "a" = 10, "b" = \'test\', "c" = \'beforeUpdate\'', ); - done(); - }) - .catch(done); + }); }); - it('update() should call $beforeUpdate on the model (async)', (done) => { + it('update() should call $beforeUpdate on the model (async)', () => { TestModel.prototype.$beforeUpdate = function () { let self = this; return delay(5).then(() => { @@ -2161,19 +2102,17 @@ describe('QueryBuilder', () => { }; let model = TestModel.fromJson({ a: 10, b: 'test' }); - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .update(model) .then(() => { - expect(model.c).to.equal('beforeUpdate'); - expect(executedQueries[0]).to.equal( + expect(model.c).toBe('beforeUpdate'); + expect(executedQueries[0]).toBe( 'update "Model" set "a" = 10, "b" = \'test\', "c" = \'beforeUpdate\'', ); - done(); - }) - .catch(done); + }); }); - it('patch() should call $beforeUpdate on the model', (done) => { + it('patch() should call $beforeUpdate on the model', () => { TestModel.prototype.$beforeUpdate = function () { this.c = 'beforeUpdate'; }; @@ -2183,19 +2122,17 @@ describe('QueryBuilder', () => { }; let model = TestModel.fromJson({ a: 10, b: 'test' }); - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .patch(model) .then(() => { - expect(model.c).to.equal('beforeUpdate'); - expect(executedQueries[0]).to.equal( + expect(model.c).toBe('beforeUpdate'); + expect(executedQueries[0]).toBe( 'update "Model" set "a" = 10, "b" = \'test\', "c" = \'beforeUpdate\'', ); - done(); - }) - .catch(done); + }); }); - it('patch() should call $beforeUpdate on the model (async)', (done) => { + it('patch() should call $beforeUpdate on the model (async)', () => { TestModel.prototype.$beforeUpdate = function () { let self = this; return delay(5).then(() => { @@ -2208,19 +2145,17 @@ describe('QueryBuilder', () => { }; let model = TestModel.fromJson({ a: 10, b: 'test' }); - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .patch(model) .then(() => { - expect(model.c).to.equal('beforeUpdate'); - expect(executedQueries[0]).to.equal( + expect(model.c).toBe('beforeUpdate'); + expect(executedQueries[0]).toBe( 'update "Model" set "a" = 10, "b" = \'test\', "c" = \'beforeUpdate\'', ); - done(); - }) - .catch(done); + }); }); - it('insert() should call $beforeInsert on the model', (done) => { + it('insert() should call $beforeInsert on the model', () => { TestModel.prototype.$beforeInsert = function () { this.c = 'beforeInsert'; }; @@ -2229,19 +2164,17 @@ describe('QueryBuilder', () => { throw new Error('$afterFind should not be called'); }; - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .insert(TestModel.fromJson({ a: 10, b: 'test' })) .then((model) => { - expect(model.c).to.equal('beforeInsert'); - expect(executedQueries[0]).to.equal( + expect(model.c).toBe('beforeInsert'); + expect(executedQueries[0]).toBe( 'insert into "Model" ("a", "b", "c") values (10, \'test\', \'beforeInsert\') returning "id"', ); - done(); - }) - .catch(done); + }); }); - it('insert() should call $beforeInsert on the model (async)', (done) => { + it('insert() should call $beforeInsert on the model (async)', () => { TestModel.prototype.$beforeInsert = function () { let self = this; return delay(5).then(() => { @@ -2253,19 +2186,17 @@ describe('QueryBuilder', () => { throw new Error('$afterFind should not be called'); }; - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .insert({ a: 10, b: 'test' }) .then((model) => { - expect(model.c).to.equal('beforeInsert'); - expect(executedQueries[0]).to.equal( + expect(model.c).toBe('beforeInsert'); + expect(executedQueries[0]).toBe( 'insert into "Model" ("a", "b", "c") values (10, \'test\', \'beforeInsert\') returning "id"', ); - done(); - }) - .catch(done); + }); }); - it('should call $afterFind on the model if no write operation is specified', (done) => { + it('should call $afterFind on the model if no write operation is specified', () => { mockKnexQueryResults = [ [ { @@ -2281,12 +2212,12 @@ describe('QueryBuilder', () => { this.b = this.a * 2 + context.x; }; - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .context({ x: 10 }) .then((models) => { - expect(models[0]).to.be.a(TestModel); - expect(models[1]).to.be.a(TestModel); - expect(models).to.eql([ + expect(models[0]).toBeInstanceOf(TestModel); + expect(models[1]).toBeInstanceOf(TestModel); + expect(models).toEqual([ { a: 1, b: 12, @@ -2296,12 +2227,10 @@ describe('QueryBuilder', () => { b: 14, }, ]); - done(); - }) - .catch(done); + }); }); - it('should call $afterFind on the model if no write operation is specified (async)', (done) => { + it('should call $afterFind on the model if no write operation is specified (async)', () => { mockKnexQueryResults = [ [ { @@ -2320,12 +2249,12 @@ describe('QueryBuilder', () => { }); }; - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .context({ x: 10 }) .then((models) => { - expect(models[0]).to.be.a(TestModel); - expect(models[1]).to.be.a(TestModel); - expect(models).to.eql([ + expect(models[0]).toBeInstanceOf(TestModel); + expect(models[1]).toBeInstanceOf(TestModel); + expect(models).toEqual([ { a: 1, b: 12, @@ -2335,12 +2264,10 @@ describe('QueryBuilder', () => { b: 14, }, ]); - done(); - }) - .catch(done); + }); }); - it('should call $afterFind before any `runAfter` hooks', (done) => { + it('should call $afterFind before any `runAfter` hooks', () => { mockKnexQueryResults = [ [ { @@ -2359,16 +2286,16 @@ describe('QueryBuilder', () => { }); }; - QueryBuilder.forClass(TestModel) + return QueryBuilder.forClass(TestModel) .context({ x: 10 }) .runAfter((result, builder) => { builder.context().x = 666; return result; }) .then((models) => { - expect(models[0]).to.be.a(TestModel); - expect(models[1]).to.be.a(TestModel); - expect(models).to.eql([ + expect(models[0]).toBeInstanceOf(TestModel); + expect(models[1]).toBeInstanceOf(TestModel); + expect(models).toEqual([ { a: 1, b: 12, @@ -2378,9 +2305,7 @@ describe('QueryBuilder', () => { b: 14, }, ]); - done(); - }) - .catch(done); + }); }); it('should not be able to call setQueryExecutor twice', () => { @@ -2388,7 +2313,7 @@ describe('QueryBuilder', () => { QueryBuilder.forClass(TestModel) .setQueryExecutor(function () {}) .setQueryExecutor(function () {}); - }).to.throwException(); + }).toThrow(); }); it('clearWithGraph() should clear everything related to eager', () => { @@ -2398,20 +2323,20 @@ describe('QueryBuilder', () => { }) .modifyGraph('a', () => {}); - expect(builder.findOperation('eager')).to.not.equal(null); + expect(builder.findOperation('eager')).not.toBeNull(); builder.clearWithGraph(); - expect(builder.findOperation('eager')).to.equal(null); + expect(builder.findOperation('eager')).toBeNull(); }); it('clearReject() should clear remove explicit rejection', () => { let builder = QueryBuilder.forClass(TestModel).reject('error'); - expect(builder._explicitRejectValue).to.equal('error'); + expect(builder._explicitRejectValue).toBe('error'); builder.clearReject(); - expect(builder._explicitRejectValue).to.equal(null); + expect(builder._explicitRejectValue).toBeNull(); }); it('subqueries in join builders should work with mysql', () => { @@ -2424,7 +2349,7 @@ describe('QueryBuilder', () => { .innerJoin('Other', (join) => join.onIn('Other.modelId', subquery())) .toKnexQuery() .toString(), - ).to.equal( + ).toBe( 'select `Model`.* from `Model` inner join `Other` on `Other`.`modelId` in (select `id` from `Model` where `a` > 1)', ); @@ -2434,12 +2359,12 @@ describe('QueryBuilder', () => { .innerJoin('Other', (join) => join.onIn('Other.modelId', subquery().from('Third'))) .toKnexQuery() .toString(), - ).to.equal( + ).toBe( 'update `Model` inner join `Other` on `Other`.`modelId` in (select `id` from `Third` where `a` > 1) set `a` = 1', ); }); - it('joinRelated should add join clause to correct place', (done) => { + it('joinRelated should add join clause to correct place', () => { class M1 extends Model { static get tableName() { return 'M1'; @@ -2468,103 +2393,52 @@ describe('QueryBuilder', () => { M1.knex(mockKnex); M2.knex(mockKnex); - M2.query() + return M2.query() .joinRelated('m1', { alias: 'm' }) .join('M1', 'M1.id', 'M2.m1Id') .then(() => { - expect(executedQueries[0]).to.equal( + expect(executedQueries[0]).toBe( 'select "M2".* from "M2" inner join "M1" as "m" on "m"."m2Id" = "M2"."id" inner join "M1" on "M1"."id" = "M2"."m1Id"', ); - done(); - }) - .catch(done); + }); }); it('undefined values as query builder method arguments should raise an exception', () => { expect(() => { QueryBuilder.forClass(TestModel).where('id', undefined).toKnexQuery(); - }).to.throwException((err) => { - expect(err.message).to.equal( - "undefined passed as argument #1 for 'where' operation. Call skipUndefined() method to ignore the undefined values.", - ); - }); + }).toThrow( + expect.objectContaining({ + message: "undefined passed as argument #1 for 'where' operation.", + }), + ); expect(() => { QueryBuilder.forClass(TestModel).orWhere('id', '<', undefined).toKnexQuery(); - }).to.throwException((err) => { - expect(err.message).to.equal( - "undefined passed as argument #2 for 'orWhere' operation. Call skipUndefined() method to ignore the undefined values.", - ); - }); + }).toThrow( + expect.objectContaining({ + message: "undefined passed as argument #2 for 'orWhere' operation.", + }), + ); expect(() => { QueryBuilder.forClass(TestModel).orWhere('id', undefined, 10).toKnexQuery(); - }).to.throwException(); + }).toThrow(); expect(() => { QueryBuilder.forClass(TestModel).delete().whereIn('id', undefined).toKnexQuery(); - }).to.throwException(); + }).toThrow(); expect(() => { QueryBuilder.forClass(TestModel).delete().whereIn('id', [1, undefined, 3]).toKnexQuery(); - }).to.throwException((err) => { - expect(err.message).to.equal( - "undefined passed as an item in argument #1 for 'whereIn' operation. Call skipUndefined() method to ignore the undefined values.", - ); - }); + }).toThrow( + expect.objectContaining({ + message: "undefined passed as an item in argument #1 for 'whereIn' operation.", + }), + ); }); - it('undefined values as query builder method arguments should be ignored if `skipUndefined` is called', () => { - expect(() => { - QueryBuilder.forClass(TestModel).skipUndefined().where('id', undefined).toKnexQuery(); - }).to.not.throwException(); - - expect(() => { - QueryBuilder.forClass(TestModel).skipUndefined().orWhere('id', '<', undefined).toKnexQuery(); - }).to.not.throwException(); - - expect(() => { - QueryBuilder.forClass(TestModel).skipUndefined().orWhere('id', undefined, 10).toKnexQuery(); - }).to.not.throwException(); - - expect(() => { - QueryBuilder.forClass(TestModel).skipUndefined().deleteById(undefined).toKnexQuery(); - }).to.not.throwException(); - - expect(() => { - QueryBuilder.forClass(TestModel) - .skipUndefined() - .delete() - .whereIn('id', undefined) - .toKnexQuery(); - }).to.not.throwException(); - - expect(() => { - QueryBuilder.forClass(TestModel) - .skipUndefined() - .delete() - .whereIn('id', [1, undefined, 3]) - .toKnexQuery(); - }).to.not.throwException(); - }); - - it('skipUndefined() should log a deprecation warning once', () => { - const warnings = []; - const originalWarn = console.warn; - console.warn = (message) => warnings.push(message); - resetWarnings(); - - try { - QueryBuilder.forClass(TestModel).skipUndefined(); - QueryBuilder.forClass(TestModel).skipUndefined(); - - expect(warnings).to.eql([ - '[objection] skipUndefined() is deprecated and will be removed in objection 4.0', - ]); - } finally { - console.warn = originalWarn; - resetWarnings(); - } + it('skipUndefined() should not exist', () => { + expect(QueryBuilder.forClass(TestModel).skipUndefined).toBeUndefined(); }); it('all query builder methods should work if model is not bound to a knex, when the query is', () => { @@ -2574,10 +2448,10 @@ describe('QueryBuilder', () => { } } - expect(UnboundModel.query(mockKnex).increment('foo', 10).toKnexQuery().toString()).to.equal( + expect(UnboundModel.query(mockKnex).increment('foo', 10).toKnexQuery().toString()).toBe( 'update "Bar" set "foo" = "foo" + 10', ); - expect(UnboundModel.query(mockKnex).decrement('foo', 5).toKnexQuery().toString()).to.equal( + expect(UnboundModel.query(mockKnex).decrement('foo', 5).toKnexQuery().toString()).toBe( 'update "Bar" set "foo" = "foo" - 5', ); }); @@ -2585,31 +2459,29 @@ describe('QueryBuilder', () => { it('json where methods should reference the bare column if no json path is given', () => { const toSql = (builder) => builder.toKnexQuery().toString(); - expect(toSql(TestModel.query().whereJsonSupersetOf('content', { a: 1 }))).to.equal( - `select "Model".* from "Model" where ( "content" )::jsonb @> '{"a":1}'::jsonb`, + expect(toSql(TestModel.query().whereJsonSupersetOf('content', { a: 1 }))).toBe( + `select "Model".* from "Model" where ( "content" )::jsonb @> ( '{"a":1}' )::jsonb`, ); - expect(toSql(TestModel.query().whereJsonSubsetOf('Model.content', { a: 1 }))).to.equal( - `select "Model".* from "Model" where ( "Model"."content" )::jsonb <@ '{"a":1}'::jsonb`, + expect(toSql(TestModel.query().whereJsonSubsetOf('Model.content', { a: 1 }))).toBe( + `select "Model".* from "Model" where ( "Model"."content" )::jsonb <@ ( '{"a":1}' )::jsonb`, ); - expect(toSql(TestModel.query().whereJsonNotSupersetOf('content', 'other'))).to.equal( + expect(toSql(TestModel.query().whereJsonNotSupersetOf('content', 'other'))).toBe( `select "Model".* from "Model" where not ( "content" )::jsonb @> ( "other" )::jsonb`, ); - expect(toSql(TestModel.query().whereJsonIsArray('content'))).to.equal( - `select "Model".* from "Model" where ( "content" )::jsonb @> '[]'::jsonb`, + expect(toSql(TestModel.query().whereJsonIsArray('content'))).toBe( + `select "Model".* from "Model" where jsonb_typeof(( "content" )::jsonb) = 'array'`, ); - expect(toSql(TestModel.query().whereJsonHasAny('content', ['a', 'b']))).to.equal( + expect(toSql(TestModel.query().whereJsonHasAny('content', ['a', 'b']))).toBe( `select "Model".* from "Model" where "content" ?| array['a','b']`, ); - // `#>>'{}'` extracts json scalars as text and maps json null to NULL, - // so it must be kept when extracting as text. - expect(toSql(TestModel.query().whereJsonNotObject('content'))).to.equal( - `select "Model".* from "Model" where (not ( "content" )::jsonb @> '{}'::jsonb or ("content"#>>'{}')::TEXT is NULL)`, + expect(toSql(TestModel.query().whereJsonNotObject('content'))).toBe( + `select "Model".* from "Model" where not coalesce(jsonb_typeof(( "content" )::jsonb) = 'object', false)`, ); // Json paths are still extracted as before. - expect(toSql(TestModel.query().whereJsonSupersetOf('content:a.b', { a: 1 }))).to.equal( - `select "Model".* from "Model" where ( "content"#>'{a,b}' )::jsonb @> '{"a":1}'::jsonb`, + expect(toSql(TestModel.query().whereJsonSupersetOf('content:a.b', { a: 1 }))).toBe( + `select "Model".* from "Model" where ( "content"#>'{a,b}' )::jsonb @> ( '{"a":1}' )::jsonb`, ); - expect(toSql(TestModel.query().whereJsonHasAll('content:a', ['b']))).to.equal( + expect(toSql(TestModel.query().whereJsonHasAll('content:a', ['b']))).toBe( `select "Model".* from "Model" where "content"#>'{a}' ?& array['b']`, ); }); @@ -2617,10 +2489,10 @@ describe('QueryBuilder', () => { it('json where methods should support ref(), val() and raw() on the right side', () => { const toSql = (builder) => builder.toKnexQuery().toString(); - expect(toSql(TestModel.query().whereJsonSupersetOf('content:a', ref('other:b')))).to.equal( + expect(toSql(TestModel.query().whereJsonSupersetOf('content:a', ref('other:b')))).toBe( `select "Model".* from "Model" where ( "content"#>'{a}' )::jsonb @> ( "other"#>'{b}' )::jsonb`, ); - expect(toSql(TestModel.query().whereJsonSubsetOf('content', ref('Model.other')))).to.equal( + expect(toSql(TestModel.query().whereJsonSubsetOf('content', ref('Model.other')))).toBe( `select "Model".* from "Model" where ( "content" )::jsonb <@ ( "Model"."other" )::jsonb`, ); expect( @@ -2629,17 +2501,15 @@ describe('QueryBuilder', () => { .whereJsonSupersetOf('a', 'b') .orWhereJsonNotSubsetOf('content', ref("other:x'?")), ), - ).to.equal( + ).toBe( `select "Model".* from "Model" where ( "a" )::jsonb @> ( "b" )::jsonb or not ( "content" )::jsonb <@ ( "other"#>'{x''?}' )::jsonb`, ); expect( toSql(TestModel.query().whereJsonSupersetOf('content', val({ a: '?' }).castJson())), - ).to.equal( + ).toBe( `select "Model".* from "Model" where ( "content" )::jsonb @> ( CAST('{"a":"?"}' AS jsonb) )::jsonb`, ); - expect( - toSql(TestModel.query().whereJsonSupersetOf('content', raw('?::jsonb', '[1]'))), - ).to.equal( + expect(toSql(TestModel.query().whereJsonSupersetOf('content', raw('?::jsonb', '[1]')))).toBe( `select "Model".* from "Model" where ( "content" )::jsonb @> ( '[1]'::jsonb )::jsonb`, ); expect( @@ -2649,7 +2519,7 @@ describe('QueryBuilder', () => { TestModel.query().select('other').limit(1), ), ), - ).to.equal( + ).toBe( `select "Model".* from "Model" where ( "content" )::jsonb @> ( (select "other" from "Model" limit 1) )::jsonb`, ); }); @@ -2657,19 +2527,19 @@ describe('QueryBuilder', () => { it('json methods should support empty keys in field expressions', () => { const toSql = (builder) => builder.toKnexQuery().toString(); - expect(toSql(TestModel.query().whereJsonSupersetOf('content:[""]', { a: 1 }))).to.equal( - `select "Model".* from "Model" where ( "content"#>'{""}' )::jsonb @> '{"a":1}'::jsonb`, + expect(toSql(TestModel.query().whereJsonSupersetOf('content:[""]', { a: 1 }))).toBe( + `select "Model".* from "Model" where ( "content"#>'{""}' )::jsonb @> ( '{"a":1}' )::jsonb`, ); - expect(toSql(TestModel.query().whereJsonHasAny("content:a['']", ['b']))).to.equal( + expect(toSql(TestModel.query().whereJsonHasAny("content:a['']", ['b']))).toBe( `select "Model".* from "Model" where "content"#>'{a,""}' ?| array['b']`, ); - expect(toSql(TestModel.query().whereJsonIsObject('content:[""]'))).to.equal( - `select "Model".* from "Model" where ( "content"#>'{""}' )::jsonb @> '{}'::jsonb`, + expect(toSql(TestModel.query().whereJsonIsObject('content:[""]'))).toBe( + `select "Model".* from "Model" where jsonb_typeof(( "content"#>'{""}' )::jsonb) = 'object'`, ); - expect(toSql(TestModel.query().where(ref('content:[""]').castText(), 'x'))).to.equal( + expect(toSql(TestModel.query().where(ref('content:[""]').castText(), 'x'))).toBe( `select "Model".* from "Model" where CAST("content"#>>'{""}' AS text) = 'x'`, ); - expect(toSql(TestModel.query().patch({ 'content:[""]': 1 }))).to.equal( + expect(toSql(TestModel.query().patch({ 'content:[""]': 1 }))).toBe( `update "Model" set "content" = jsonb_set("Model"."content", '{""}', '1', true)`, ); }); @@ -2683,14 +2553,17 @@ describe('QueryBuilder', () => { const toSql = (builder) => builder.toKnexQuery().toString(); const expectError = (modelClass, json, key, property) => { - expect(() => toSql(modelClass.query().patch(json))).to.throwException((err) => { - expect(err).to.be.a(objection.ValidationError); - expect(err.type).to.equal('ModelValidation'); - expect(Object.keys(err.data)).to.eql([key]); - expect(err.message).to.equal( - `${key}: can't be combined with a value for the json column '${property}'`, - ); - }); + expectThrows( + () => toSql(modelClass.query().patch(json)), + (err) => { + expect(err).toBeInstanceOf(objection.ValidationError); + expect(err.type).toBe('ModelValidation'); + expect(Object.keys(err.data)).toEqual([key]); + expect(err.message).toBe( + `${key}: can't be combined with a value for the json column '${property}'`, + ); + }, + ); }; expectError(TestModel, { 'content:a': 1, content: { a: 2 } }, 'content:a', 'content'); @@ -2707,17 +2580,51 @@ describe('QueryBuilder', () => { 'jsonCol:innerKey', 'jsonCol', ); - expect(toSql(TestModel.query().patch({ 'content:a': 1, 'content:b': 2 }))).to.equal( + expect(toSql(TestModel.query().patch({ 'content:a': 1, 'content:b': 2 }))).toBe( `update "Model" set "content" = jsonb_set(jsonb_set("Model"."content", '{a}', '1', true), '{b}', '2', true)`, ); }); + it('patch should write field expressions with the json functions of MySQL and SQLite', () => { + const toSql = (client, json) => + TestModel.query(Knex({ client, useNullAsDefault: true })) + .patch(json) + .toKnexQuery() + .toString(); + const json = { 'content:a': 1, 'content:b[0]': { c: 'd' }, 'content:["e?f"]': ref('other:g') }; + + expect(toSql('mysql', json)).toBe( + 'update `Model` set `content` = JSON_SET(JSON_SET(JSON_SET(' + + "`Model`.`content`, '$.\"a\"', CAST('1' AS JSON)), " + + '\'$."b"[0]\', CAST(\'{\\"c\\":\\"d\\"}\' AS JSON)), ' + + `_utf8mb4 X'${Buffer.from('$."e?f"').toString('hex')}', ` + + "JSON_EXTRACT(JSON_ARRAY(JSON_EXTRACT(`other`, '$.\"g\"')), '$[0]'))", + ); + expect(toSql('sqlite3', json)).toBe( + 'update `Model` set `content` = json_set(json_set(json_set(' + + "`Model`.`content`, '$.\"a\"', json('1')), " + + '\'$."b"[0]\', json(\'{"c":"d"}\')), ' + + '\'$."e\\u003ff"\', json_quote(json_extract(`other`, \'$."g"\')))', + ); + expectThrows( + () => toSql('mysql', { 'content:a': 1, content: {} }), + (err) => { + expect(err).toBeInstanceOf(objection.ValidationError); + }, + ); + }); + + it('upsertGraph() should fail for an undefined graph', async () => { + await expect(TestModel.query().upsertGraph(undefined)).rejects.toThrow( + 'expected value "undefined" to be an instance of TestModel', + ); + }); + describe('json field expressions on other databases (ditojs#113)', () => { const message = - '[objection] JSON field expressions and the whereJson*() methods of objection are only ' + - 'supported on PostgreSQL. Use the JSON methods of knex like whereJsonPath() or ' + + 'JSON field expressions and the whereJson*() methods of objection are only supported on ' + + 'PostgreSQL, MySQL and SQLite. Use the JSON methods of knex like whereJsonPath() or ' + 'jsonExtract() with other databases.'; - const knexes = {}; const query = (client) => { knexes[client] = knexes[client] || Knex({ client, useNullAsDefault: true }); @@ -2740,110 +2647,119 @@ describe('QueryBuilder', () => { resetWarnings(); }); - it('should not change the json where methods on postgres', () => { - expect(toSql(query('pg').whereJsonSupersetOf('content', { a: 1 }))).to.equal( - `select "Model".* from "Model" where ( "content" )::jsonb @> '{"a":1}'::jsonb`, + it('should render the json where methods on postgres', () => { + expect(toSql(query('pg').whereJsonSupersetOf('content', { a: 1 }))).toBe( + `select "Model".* from "Model" where ( "content" )::jsonb @> ( '{"a":1}' )::jsonb`, ); - expect(toSql(query('pg').orWhereJsonNotSubsetOf('content', [1]))).to.equal( - `select "Model".* from "Model" where not ( "content" )::jsonb <@ '[1]'::jsonb`, + expect(toSql(query('pg').orWhereJsonNotSubsetOf('content', [1]))).toBe( + `select "Model".* from "Model" where not ( "content" )::jsonb <@ ( '[1]' )::jsonb`, ); }); - it('should pass the simple json superset and subset methods on to knex on mysql', () => { - expect(toSql(query('mysql').whereJsonSupersetOf('content', { a: 1 }))).to.equal( - 'select `Model`.* from `Model` where json_contains(`content`,\'{\\"a\\":1}\')', - ); - expect(toSql(query('mysql').andWhereJsonSupersetOf('Model.content', [1]))).to.equal( - "select `Model`.* from `Model` where json_contains(`Model`.`content`,'[1]')", - ); - expect(toSql(query('mysql').whereJsonSubsetOf('content', [1]))).to.equal( - "select `Model`.* from `Model` where json_contains('[1]',`content`)", - ); + it('should render the json where methods on mysql', () => { expect( toSql( query('mysql') .where('id', 1) - .orWhereJsonSupersetOf('content', [1]) - .whereJsonNotSupersetOf('content', [2]) - .orWhereJsonNotSupersetOf('content', [3]) - .orWhereJsonSubsetOf('content', [4]) - .whereJsonNotSubsetOf('content', [5]) - .orWhereJsonNotSubsetOf('content', [6]), + .orWhereJsonSupersetOf('Model.content', { a: 1 }) + .whereJsonNotSubsetOf('content:a', ref('other:b')) + .orWhereJsonIsArray('content:a[0]') + .whereJsonNotObject('content'), ), - ).to.equal( + ).toBe( 'select `Model`.* from `Model` where `id` = 1' + - " or (json_contains(`content`,'[1]'))" + - " and not json_contains(`content`,'[2]')" + - " or (not json_contains(`content`,'[3]'))" + - " or (json_contains('[4]',`content`))" + - " and not json_contains('[5]',`content`)" + - " or (not json_contains('[6]',`content`))", + ' or JSON_CONTAINS(`Model`.`content`, CAST(\'{\\"a\\":1}\' AS JSON))' + + ' and not JSON_CONTAINS(JSON_EXTRACT(`other`, \'$."b"\'), JSON_EXTRACT(`content`, \'$."a"\'))' + + " or JSON_TYPE(JSON_EXTRACT(`content`, '$.\"a\"[0]')) = 'ARRAY'" + + " and not coalesce(JSON_TYPE(`content`) = 'OBJECT', false)", + ); + expect( + toSql( + query('mysql') + .whereJsonHasAny('content:a', ['b', 'c']) + .orWhereJsonHasAll('content', ['d', 'e']), + ), + ).toBe( + 'select `Model`.* from `Model` where ' + + "JSON_OVERLAPS(COALESCE(JSON_KEYS(JSON_EXTRACT(`content`, '$.\"a\"')), JSON_EXTRACT(`content`, '$.\"a\"')), JSON_ARRAY('b', 'c'))" + + " or (JSON_OVERLAPS(COALESCE(JSON_KEYS(`content`), `content`), JSON_ARRAY('d'))" + + " and JSON_OVERLAPS(COALESCE(JSON_KEYS(`content`), `content`), JSON_ARRAY('e')))", ); - expect(warnings).to.eql([]); }); - it('andWhereJsonNotSupersetOf() should be an alias of whereJsonNotSupersetOf()', () => { - expect(toSql(query('pg').andWhereJsonNotSupersetOf('content', { a: 1 }))).to.equal( - toSql(query('pg').whereJsonNotSupersetOf('content', { a: 1 })), + it('should render the json where methods on sqlite', () => { + const hasKey = (path, keys) => + `exists (select 1 from json_each(\`content\`, '${path}') ` + + "where case when typeof(json_each.key) = 'text' then json_each.key " + + `when json_each.type = 'text' then json_each.value end in (${keys}))`; + expect( + toSql( + query('sqlite3') + .whereJsonIsObject('content:a') + .orWhereJsonNotArray('content') + .whereJsonHasAny('content:a', ['b', 'c']) + .orWhereJsonHasAll('content', ['d', 'e']), + ), + ).toBe( + 'select `Model`.* from `Model` where ' + + "json_type(`content`, '$.\"a\"') = 'object'" + + " or not coalesce(json_type(`content`, '$') = 'array', false)" + + ` and ${hasKey('$."a"', "'b', 'c'")}` + + ` or (${hasKey('$', "'d'")} and ${hasKey('$', "'e'")})`, ); }); - it('should only pass the json superset and subset methods on to knex on mysql', () => { - expect(toSql(query('sqlite3').whereJsonSupersetOf('content', { a: 1 }))).to.equal( - 'select `Model`.* from `Model` where ( `content` )::jsonb @> \'{"a":1}\'::jsonb', + it('should throw for the json superset and subset methods on sqlite', () => { + const message = + 'whereJsonSupersetOf() and whereJsonSubsetOf() are not supported on SQLite, which has no ' + + 'json containment.'; + expect(() => toSql(query('sqlite3').whereJsonSupersetOf('content', { a: 1 }))).toThrow( + message, ); - expect(warnings).to.eql([message]); + expect(() => toSql(query('sqlite3').orWhereJsonNotSubsetOf('content', [1]))).toThrow(message); }); - it('should not pass other forms of the json where methods on to knex', () => { - expect(toSql(query('mysql').whereJsonSupersetOf('content:a', { a: 1 }))).to.equal( - "select `Model`.* from `Model` where ( `content`#>'{a}' )::jsonb @> '{\\\"a\\\":1}'::jsonb", - ); - expect(toSql(query('mysql').whereJsonSupersetOf('content', 'other'))).to.equal( - 'select `Model`.* from `Model` where ( `content` )::jsonb @> ( `other` )::jsonb', - ); - expect(toSql(query('mysql').whereJsonSubsetOf('content', ref('other')))).to.equal( - 'select `Model`.* from `Model` where ( `content` )::jsonb <@ ( `other` )::jsonb', - ); - expect(toSql(query('mysql').whereJsonSupersetOf('content', raw('?', '[1]')))).to.equal( - "select `Model`.* from `Model` where ( `content` )::jsonb @> ( '[1]' )::jsonb", - ); - // knex's version doesn't have the same semantics for empty objects and arrays. - expect(toSql(query('mysql').whereJsonIsObject('content'))).to.equal( - "select `Model`.* from `Model` where ( `content` )::jsonb @> '{}'::jsonb", - ); - expect(toSql(query('mysql').orWhereJsonIsArray('content'))).to.equal( - "select `Model`.* from `Model` where ( `content` )::jsonb @> '[]'::jsonb", + it('andWhereJsonNotSupersetOf() should be an alias of whereJsonNotSupersetOf()', () => { + expect(toSql(query('pg').andWhereJsonNotSupersetOf('content', { a: 1 }))).toBe( + toSql(query('pg').whereJsonNotSupersetOf('content', { a: 1 })), ); - expect(warnings).to.eql([message]); }); - for (const client of ['mysql', 'sqlite3']) { - it(`should warn once about json field expressions on ${client}`, () => { - toSql(query(client).orderBy(ref('content:a.b'), 'desc')); - expect(warnings).to.eql([message]); + const createQueries = { + 'ref() with a field expression': (client) => + query(client).orderBy(ref('content:a.b'), 'desc'), + 'a cast ref() with a field expression': (client) => + query(client).select(ref('content:a').castInt().as('a')), + 'ref().castJson()': (client) => query(client).where(ref('content:a').castJson(), 1), + 'a field expression in a patch': (client) => query(client).patch({ 'content:a': 1 }), + 'whereJsonHasAny()': (client) => query(client).whereJsonHasAny('content', ['a']), + 'whereJsonNotArray()': (client) => query(client).whereJsonNotArray('content'), + 'whereJsonNotObject()': (client) => query(client).whereJsonNotObject('content'), + }; - toSql(query(client).select(ref('content:a').castInt().as('a'))); - toSql(query(client).patch({ 'content:a': 1 })); - toSql(query(client).whereJsonHasAny('content', ['a'])); - toSql(query(client).whereJsonNotObject('content')); - expect(warnings).to.eql([message]); + for (const client of ['mysql', 'mysql2', 'sqlite3', 'better-sqlite3']) { + for (const [title, createQuery] of Object.entries(createQueries)) { + it(`should support ${title} on ${client}`, () => { + toSql(createQuery(client)); + expect(warnings).toEqual([]); + }); + } + } + + for (const client of ['mssql', 'oracledb']) { + it(`should throw for a plain ref().castJson() on ${client}`, () => { + expect(() => toSql(query(client).where(ref('content').castJson(), 1))).toThrow(message); }); - for (const [title, createQuery] of Object.entries({ - 'ref() with a field expression': (client) => - query(client).orderBy(ref('content:a.b'), 'desc'), - 'a field expression in a patch': (client) => query(client).patch({ 'content:a': 1 }), - 'whereJsonHasAny()': (client) => query(client).whereJsonHasAny('content', ['a']), - 'whereJsonNotArray()': (client) => query(client).whereJsonNotArray('content'), - })) { - it(`should warn about ${title} on ${client}`, () => { - toSql(createQuery(client)); - expect(warnings).to.eql([message]); + for (const [title, createQuery] of Object.entries(createQueries)) { + it(`should throw for ${title} on ${client}`, () => { + expect(() => toSql(createQuery(client))).toThrow(message); }); } + } - it(`should not warn about plain refs on ${client}`, () => { + for (const client of ['mysql', 'mysql2', 'sqlite3', 'better-sqlite3', 'mssql', 'oracledb']) { + it(`should not throw for plain refs on ${client}`, () => { toSql( query(client) .select(ref('Model.content').as('c'), ref('id').castText()) @@ -2851,17 +2767,24 @@ describe('QueryBuilder', () => { .orderBy(ref('Model.id')), ); toSql(query(client).patch({ content: ref('other') })); - expect(warnings).to.eql([]); }); } + // Other databases may understand some of the Postgres JSON syntax. + it('should warn once about json field expressions on unknown databases', () => { + for (const createQuery of Object.values(createQueries)) { + toSql(createQuery('redshift')); + } + expect(warnings).toEqual([`[objection] ${message}`]); + }); + it('should not warn on postgres', () => { toSql(query('pg').orderBy(ref('content:a.b'), 'desc')); toSql(query('pg').patch({ 'content:a': 1 })); toSql(query('pg').whereJsonSupersetOf('content:a', { a: 1 })); toSql(query('pg').whereJsonHasAny('content', ['a'])); toSql(query('pg').whereJsonNotObject('content')); - expect(warnings).to.eql([]); + expect(warnings).toEqual([]); }); }); @@ -2916,41 +2839,41 @@ describe('QueryBuilder', () => { const { sql, bindings } = toSQL(query); // The key only appears inside the escaped json path literal, and its // `?` characters aren't turned into binding placeholders. - expect(sql).to.contain(literal); - expect(sql.split(literal).join('')).not.to.contain(key); - expect(bindings.some((it) => typeof it === 'string' && it.includes(key))).to.be(false); + expect(sql).toContain(literal); + expect(sql.split(literal).join('')).not.toContain(key); + expect(bindings.some((it) => typeof it === 'string' && it.includes(key))).toBe(false); } // Interpolating the bindings doesn't throw (e.g. "Expected N bindings"). for (const string of strings) { - expect(string).to.contain(literal); + expect(string).toContain(literal); } }); } it('escapes keys correctly in the json path literal', () => { - expect(toSql(TestModel.query().where(ref("content:x') or 1=1 --"), 1))).to.equal( + expect(toSql(TestModel.query().where(ref("content:x') or 1=1 --"), 1))).toBe( `select "Model".* from "Model" where "content"#>'{"x'') or 1=1 --"}' = 1`, ); - expect(toSql(TestModel.query().whereJsonSupersetOf('content:a?b', { a: 1 }))).to.equal( - `select "Model".* from "Model" where ( "content"#>'{a?b}' )::jsonb @> '{"a":1}'::jsonb`, + expect(toSql(TestModel.query().whereJsonSupersetOf('content:a?b', { a: 1 }))).toBe( + `select "Model".* from "Model" where ( "content"#>'{a?b}' )::jsonb @> ( '{"a":1}' )::jsonb`, ); - expect(toSql(TestModel.query().whereJsonHasAny('content:a?b', ['c?']))).to.equal( + expect(toSql(TestModel.query().whereJsonHasAny('content:a?b', ['c?']))).toBe( `select "Model".* from "Model" where "content"#>'{a?b}' ?| array['c?']`, ); - expect(toSql(TestModel.query().patch({ "content:x') or 1=1 --": 1 }))).to.equal( + expect(toSql(TestModel.query().patch({ "content:x') or 1=1 --": 1 }))).toBe( `update "Model" set "content" = jsonb_set("Model"."content", '{"x'') or 1=1 --"}', '1', true)`, ); }); it('binds string values containing `?` in json methods', () => { - expect(toSql(TestModel.query().whereJsonHasAny('content:a', ['?', 'b?']))).to.equal( + expect(toSql(TestModel.query().whereJsonHasAny('content:a', ['?', 'b?']))).toBe( `select "Model".* from "Model" where "content"#>'{a}' ?| array['?','b?']`, ); const { sql, bindings } = toSQL( TestModel.query().whereJsonHasAll('content', "x') or 1=1 --"), ); - expect(sql).to.equal('select "Model".* from "Model" where "content" ?& array[$1]'); - expect(bindings).to.eql(["x') or 1=1 --"]); + expect(sql).toBe('select "Model".* from "Model" where "content" ?& array[$1]'); + expect(bindings).toEqual(["x') or 1=1 --"]); }); }); @@ -2974,51 +2897,49 @@ describe('QueryBuilder', () => { } function testQueries(Model, knex, { col, patchPath }) { - expect(toSql(Model.query(knex).where(ref('jsonCol:someKey.otherKey'), 1))).to.equal( + expect(toSql(Model.query(knex).where(ref('jsonCol:someKey.otherKey'), 1))).toBe( `select "model".* from "model" where "${col}"#>'{someKey,otherKey}' = 1`, ); - expect(toSql(Model.query(knex).where(ref('jsonCol:[0][innerKey]').castText(), 'x'))).to.equal( + expect(toSql(Model.query(knex).where(ref('jsonCol:[0][innerKey]').castText(), 'x'))).toBe( `select "model".* from "model" where CAST("${col}"#>>'{0,innerKey}' AS text) = 'x'`, ); - expect( - toSql(Model.query(knex).whereJsonSupersetOf('jsonCol:someKey', { innerKey: 1 })), - ).to.equal( - `select "model".* from "model" where ( "${col}"#>'{someKey}' )::jsonb @> '{"innerKey":1}'::jsonb`, + expect(toSql(Model.query(knex).whereJsonSupersetOf('jsonCol:someKey', { innerKey: 1 }))).toBe( + `select "model".* from "model" where ( "${col}"#>'{someKey}' )::jsonb @> ( '{"innerKey":1}' )::jsonb`, ); - expect(toSql(Model.query(knex).whereJsonHasAny('jsonCol:someKey', 'fooBar'))).to.equal( + expect(toSql(Model.query(knex).whereJsonHasAny('jsonCol:someKey', 'fooBar'))).toBe( `select "model".* from "model" where "${col}"#>'{someKey}' ?| array['fooBar']`, ); - expect(toSql(Model.query(knex).whereJsonIsObject('jsonCol:someKey'))).to.equal( - `select "model".* from "model" where ( "${col}"#>'{someKey}' )::jsonb @> '{}'::jsonb`, + expect(toSql(Model.query(knex).whereJsonIsObject('jsonCol:someKey'))).toBe( + `select "model".* from "model" where jsonb_typeof(( "${col}"#>'{someKey}' )::jsonb) = 'object'`, ); - expect(toSql(Model.query(knex).whereJsonNotObject('model.jsonCol:someKey'))).to.equal( - `select "model".* from "model" where (not ( "model"."${col}"#>'{someKey}' )::jsonb @> '{}'::jsonb or ("model"."${col}"#>>'{someKey}')::TEXT is NULL)`, + expect(toSql(Model.query(knex).whereJsonNotObject('model.jsonCol:someKey'))).toBe( + `select "model".* from "model" where not coalesce(jsonb_typeof(( "model"."${col}"#>'{someKey}' )::jsonb) = 'object', false)`, ); - expect(toSql(Model.query(knex).whereJsonSubsetOf('jsonCol', 'model.jsonCol:a'))).to.equal( + expect(toSql(Model.query(knex).whereJsonSubsetOf('jsonCol', 'model.jsonCol:a'))).toBe( `select "model".* from "model" where ( "${col}" )::jsonb <@ ( "model"."${col}"#>'{a}' )::jsonb`, ); - expect(toSql(Model.query(knex).whereJsonHasAll('jsonCol', ['a', 'b']))).to.equal( + expect(toSql(Model.query(knex).whereJsonHasAll('jsonCol', ['a', 'b']))).toBe( `select "model".* from "model" where "${col}" ?& array['a','b']`, ); - expect(toSql(Model.query(knex).patch({ 'jsonCol:[0][innerKey]': 1, otherCol: 2 }))).to.equal( + expect(toSql(Model.query(knex).patch({ 'jsonCol:[0][innerKey]': 1, otherCol: 2 }))).toBe( `update "model" set "json_col" = jsonb_set("model"."json_col", '${patchPath}', '1', true), "other_col" = 2`, ); - expect(toSql(Model.query(knex).patch({ jsonCol: { innerKey: 1 } }))).to.equal( + expect(toSql(Model.query(knex).patch({ jsonCol: { innerKey: 1 } }))).toBe( `update "model" set "json_col" = '{"innerKey":1}'`, ); } - it('snakeCaseMappers() maps json keys of field expressions in patch (default)', () => { + it('snakeCaseMappers() only maps the column part of field expressions in patch', () => { testQueries(createModel(objection.snakeCaseMappers()), mockKnex, { col: 'jsonCol', - patchPath: '{0,inner_key}', + patchPath: '{0,innerKey}', }); }); - it('snakeCaseMappers({ preserveJsonKeys: true }) only maps the column part', () => { - testQueries(createModel(objection.snakeCaseMappers({ preserveJsonKeys: true })), mockKnex, { + it('snakeCaseMappers({ preserveJsonKeys: false }) maps json keys of field expressions in patch', () => { + testQueries(createModel(objection.snakeCaseMappers({ preserveJsonKeys: false })), mockKnex, { col: 'jsonCol', - patchPath: '{0,innerKey}', + patchPath: '{0,inner_key}', }); }); @@ -3036,7 +2957,7 @@ describe('QueryBuilder', () => { return TestModel.query() .first() .then((model) => { - expect(executedQueries[0]).to.equal('select "Model".* from "Model"'); + expect(executedQueries[0]).toBe('select "Model".* from "Model"'); }); }); @@ -3046,28 +2967,28 @@ describe('QueryBuilder', () => { return TestModel.query() .first() .then((model) => { - expect(executedQueries[0]).to.equal('select "Model".* from "Model" limit 1'); + expect(executedQueries[0]).toBe('select "Model".* from "Model" limit 1'); }); }); it('tableNameFor should return the table name', () => { const query = TestModel.query(); - expect(query.tableNameFor(TestModel)).to.equal('Model'); + expect(query.tableNameFor(TestModel)).toBe('Model'); }); it('tableNameFor should return the table name given in from', () => { const query = TestModel.query().from('Lol'); - expect(query.tableNameFor(TestModel)).to.equal('Lol'); + expect(query.tableNameFor(TestModel)).toBe('Lol'); }); it('tableRefFor should return the table name by default', () => { const query = TestModel.query(); - expect(query.tableRefFor(TestModel)).to.equal('Model'); + expect(query.tableRefFor(TestModel)).toBe('Model'); }); it('tableRefFor should return the alias', () => { const query = TestModel.query().alias('Lyl'); - expect(query.tableRefFor(TestModel)).to.equal('Lyl'); + expect(query.tableRefFor(TestModel)).toBe('Lyl'); }); it('should use Model.QueryBuilder in builder methods', () => { @@ -3091,124 +3012,118 @@ describe('QueryBuilder', () => { checks.push(builder instanceof CustomQueryBuilder); }) .then(() => { - expect(checks).to.have.length(4); - expect(checks.every((it) => it)).to.equal(true); + expect(checks).toHaveLength(4); + expect(checks.every((it) => it)).toBe(true); }); }); it('hasSelectionAs', () => { - expect(TestModel.query().hasSelectionAs('foo', 'foo')).to.equal(true); - expect(TestModel.query().hasSelectionAs('foo', 'bar')).to.equal(false); + expect(TestModel.query().hasSelectionAs('foo', 'foo')).toBe(true); + expect(TestModel.query().hasSelectionAs('foo', 'bar')).toBe(false); - expect(TestModel.query().select('foo as bar').hasSelectionAs('foo', 'bar')).to.equal(true); + expect(TestModel.query().select('foo as bar').hasSelectionAs('foo', 'bar')).toBe(true); - expect(TestModel.query().select('foo').hasSelectionAs('foo', 'bar')).to.equal(false); + expect(TestModel.query().select('foo').hasSelectionAs('foo', 'bar')).toBe(false); - expect(TestModel.query().select('*').hasSelectionAs('foo', 'foo')).to.equal(true); + expect(TestModel.query().select('*').hasSelectionAs('foo', 'foo')).toBe(true); - expect(TestModel.query().select('*').hasSelectionAs('foo', 'bar')).to.equal(false); + expect(TestModel.query().select('*').hasSelectionAs('foo', 'bar')).toBe(false); - expect(TestModel.query().select('foo.*').hasSelectionAs('foo.anything', 'anything')).to.equal( - true, - ); + expect(TestModel.query().select('foo.*').hasSelectionAs('foo.anything', 'anything')).toBe(true); - expect( - TestModel.query().select('foo.*').hasSelectionAs('foo.anything', 'somethingElse'), - ).to.equal(false); + expect(TestModel.query().select('foo.*').hasSelectionAs('foo.anything', 'somethingElse')).toBe( + false, + ); - expect(TestModel.query().select('foo.*').hasSelectionAs('bar.anything', 'anything')).to.equal( + expect(TestModel.query().select('foo.*').hasSelectionAs('bar.anything', 'anything')).toBe( false, ); }); it('hasSelection', () => { - expect(TestModel.query().hasSelection('foo')).to.equal(true); - expect(TestModel.query().hasSelection(ref('foo'))).to.equal(true); - expect(TestModel.query().hasSelection('Model.foo')).to.equal(true); - expect(TestModel.query().hasSelection(ref('Model.foo'))).to.equal(true); - expect(TestModel.query().hasSelection('DifferentTable.foo')).to.equal(false); - expect(TestModel.query().hasSelection(ref('DifferentTable.foo'))).to.equal(false); + expect(TestModel.query().hasSelection('foo')).toBe(true); + expect(TestModel.query().hasSelection(ref('foo'))).toBe(true); + expect(TestModel.query().hasSelection('Model.foo')).toBe(true); + expect(TestModel.query().hasSelection(ref('Model.foo'))).toBe(true); + expect(TestModel.query().hasSelection('DifferentTable.foo')).toBe(false); + expect(TestModel.query().hasSelection(ref('DifferentTable.foo'))).toBe(false); - expect(TestModel.query().select('*').hasSelection('DifferentTable.anything')).to.equal(true); + expect(TestModel.query().select('*').hasSelection('DifferentTable.anything')).toBe(true); - expect(TestModel.query().select('foo.*').hasSelection('bar.anything')).to.equal(false); + expect(TestModel.query().select('foo.*').hasSelection('bar.anything')).toBe(false); - expect(TestModel.query().select('foo.*').hasSelection('foo.anything')).to.equal(true); + expect(TestModel.query().select('foo.*').hasSelection('foo.anything')).toBe(true); - expect( - TestModel.query().select(ref('*')).hasSelection(ref('DifferentTable.anything')), - ).to.equal(true); + expect(TestModel.query().select(ref('*')).hasSelection(ref('DifferentTable.anything'))).toBe( + true, + ); - expect(TestModel.query().select('foo').hasSelection('foo')).to.equal(true); + expect(TestModel.query().select('foo').hasSelection('foo')).toBe(true); - expect(TestModel.query().select(ref('foo')).hasSelection(ref('foo'))).to.equal(true); + expect(TestModel.query().select(ref('foo')).hasSelection(ref('foo'))).toBe(true); - expect(TestModel.query().select('foo').hasSelection('Model.foo')).to.equal(true); + expect(TestModel.query().select('foo').hasSelection('Model.foo')).toBe(true); - expect(TestModel.query().select(ref('foo')).hasSelection(ref('Model.foo'))).to.equal(true); + expect(TestModel.query().select(ref('foo')).hasSelection(ref('Model.foo'))).toBe(true); - expect(TestModel.query().select('foo').hasSelection('DifferentTable.foo')).to.equal(false); + expect(TestModel.query().select('foo').hasSelection('DifferentTable.foo')).toBe(false); - expect(TestModel.query().select(ref('foo')).hasSelection(ref('DifferentTable.foo'))).to.equal( + expect(TestModel.query().select(ref('foo')).hasSelection(ref('DifferentTable.foo'))).toBe( false, ); - expect(TestModel.query().select('foo').hasSelection('bar')).to.equal(false); + expect(TestModel.query().select('foo').hasSelection('bar')).toBe(false); - expect(TestModel.query().select(ref('foo')).hasSelection(ref('bar'))).to.equal(false); + expect(TestModel.query().select(ref('foo')).hasSelection(ref('bar'))).toBe(false); - expect(TestModel.query().select('Model.foo').hasSelection('foo')).to.equal(true); + expect(TestModel.query().select('Model.foo').hasSelection('foo')).toBe(true); - expect(TestModel.query().select(ref('Model.foo')).hasSelection(ref('foo'))).to.equal(true); + expect(TestModel.query().select(ref('Model.foo')).hasSelection(ref('foo'))).toBe(true); - expect(TestModel.query().select('Model.foo').hasSelection('Model.foo')).to.equal(true); + expect(TestModel.query().select('Model.foo').hasSelection('Model.foo')).toBe(true); - expect(TestModel.query().select(ref('Model.foo')).hasSelection(ref('Model.foo'))).to.equal( - true, - ); + expect(TestModel.query().select(ref('Model.foo')).hasSelection(ref('Model.foo'))).toBe(true); - expect(TestModel.query().select('Model.foo').hasSelection('NotTestModel.foo')).to.equal(false); + expect(TestModel.query().select('Model.foo').hasSelection('NotTestModel.foo')).toBe(false); - expect( - TestModel.query().select(ref('Model.foo')).hasSelection(ref('NotTestModel.foo')), - ).to.equal(false); + expect(TestModel.query().select(ref('Model.foo')).hasSelection(ref('NotTestModel.foo'))).toBe( + false, + ); - expect(TestModel.query().select('Model.foo').hasSelection('bar')).to.equal(false); + expect(TestModel.query().select('Model.foo').hasSelection('bar')).toBe(false); - expect(TestModel.query().select(ref('Model.foo')).hasSelection(ref('bar'))).to.equal(false); + expect(TestModel.query().select(ref('Model.foo')).hasSelection(ref('bar'))).toBe(false); - expect(TestModel.query().alias('t').select('foo').hasSelection('t.foo')).to.equal(true); + expect(TestModel.query().alias('t').select('foo').hasSelection('t.foo')).toBe(true); - expect(TestModel.query().alias('t').select('t.foo').hasSelection('foo')).to.equal(true); + expect(TestModel.query().alias('t').select('t.foo').hasSelection('foo')).toBe(true); - expect(TestModel.query().alias('t').select('t.foo').hasSelection('t.foo')).to.equal(true); + expect(TestModel.query().alias('t').select('t.foo').hasSelection('t.foo')).toBe(true); - expect(TestModel.query().alias('t').select('foo').hasSelection('Model.foo')).to.equal(false); + expect(TestModel.query().alias('t').select('foo').hasSelection('Model.foo')).toBe(false); }); - it('aggregate selections (#2219)', () => { - const aliases = (query) => - query.findOperation(/count|sum/).aggregateSelections.map((selection) => selection.name); - - expect(aliases(TestModel.query().count())).to.eql([]); - expect(aliases(TestModel.query().count('id'))).to.eql([]); - expect(aliases(TestModel.query().count('* as n'))).to.eql(['n']); - expect(aliases(TestModel.query().count('Model.id AS n'))).to.eql(['n']); - expect(aliases(TestModel.query().count('id', { as: 'n' }))).to.eql(['n']); - expect(aliases(TestModel.query().count({ n: 'id', m: ['a', 'b'] }))).to.eql(['n', 'm']); - expect(aliases(TestModel.query().sum('x as total'))).to.eql(['total']); - expect(TestModel.query().select('id').findOperation('select').aggregateSelections).to.equal( - null, - ); - - // The columns passed to aggregates still count as selected outside of - // `withGraphJoined()`. - expect(TestModel.query().count('id').hasSelection('id')).to.equal(true); - expect(TestModel.query().count('* as n').hasSelection('foo')).to.equal(true); + it('aggregate selections (#2219, #111)', () => { + const aliases = (query) => query.findAllSelections().map((selection) => selection.name); + + expect(aliases(TestModel.query().count())).toEqual([]); + expect(aliases(TestModel.query().count('id'))).toEqual([]); + expect(aliases(TestModel.query().count('* as n'))).toEqual(['n']); + expect(aliases(TestModel.query().count('Model.id AS n'))).toEqual(['n']); + expect(aliases(TestModel.query().count('id', { as: 'n' }))).toEqual(['n']); + expect(aliases(TestModel.query().count({ n: 'id', m: ['a', 'b'] }))).toEqual(['n', 'm']); + expect(aliases(TestModel.query().sum('x as total'))).toEqual(['total']); + + // Aggregates don't select the columns they're passed, but they still count + // as selections, so that the query doesn't select all columns: + expect(TestModel.query().count('id').hasSelection('id')).toBe(false); + expect(TestModel.query().count('* as n').hasSelection('foo')).toBe(false); + expect(TestModel.query().count().hasSelection('foo')).toBe(false); + expect(TestModel.query().select('id').count('* as n').hasSelection('id')).toBe(true); }); it('parseRelationExpression', () => { - expect(QueryBuilder.parseRelationExpression('[foo, bar.baz]')).to.eql({ + expect(QueryBuilder.parseRelationExpression('[foo, bar.baz]')).toEqual({ $name: null, $relation: null, $modify: [], @@ -3262,245 +3177,203 @@ describe('QueryBuilder', () => { }; }); - it("allowGraph('a').withGraphFetched('a(f1)') should be ok", (done) => { - QueryBuilder.forClass(TestModel) + it("allowGraph('a').withGraphFetched('a(f1)') should be ok", () => { + return QueryBuilder.forClass(TestModel) .allowGraph('a') .withGraphFetched('a(f1)', { f1: () => {} }) .then(() => { - expect(executedQueries).to.have.length(1); - done(); - }) - .catch((err) => { - done(new Error('should not get here')); + expect(executedQueries).toHaveLength(1); }); }); - it("withGraphFetched('a(f1)').allowGraph('a') should be ok", (done) => { - QueryBuilder.forClass(TestModel) + it("withGraphFetched('a(f1)').allowGraph('a') should be ok", () => { + return QueryBuilder.forClass(TestModel) .withGraphFetched('a(f1)', { f1: () => {} }) .allowGraph('a') .then(() => { - expect(executedQueries).to.have.length(1); - done(); - }) - .catch((err) => { - done(new Error('should not get here')); + expect(executedQueries).toHaveLength(1); }); }); - it("allowGraph('[a, b.c.[d, e]]').withGraphFetched('a') should be ok", (done) => { - QueryBuilder.forClass(TestModel) - .allowGraph('[a, b.c.[d, e]]') - .withGraphFetched('a') - .then(() => { - done(); - }); + it("allowGraph('[a, b.c.[d, e]]').withGraphFetched('a') should be ok", () => { + return QueryBuilder.forClass(TestModel).allowGraph('[a, b.c.[d, e]]').withGraphFetched('a'); }); - it("allowGraph('[a, b.c.[d, e]]').withGraphFetched('b.c') should be ok", (done) => { - QueryBuilder.forClass(TestModel) + it("allowGraph('[a, b.c.[d, e]]').withGraphFetched('b.c') should be ok", () => { + return QueryBuilder.forClass(TestModel) .allowGraph('[a, b.c.[d, e]]') .withGraphFetched('b.c') .then(() => { - expect(executedQueries).to.have.length(1); - done(); - }) - .catch(() => { - done(new Error('should not get here')); + expect(executedQueries).toHaveLength(1); }); }); - it("allowGraph('[a, b.c.[d, e]]').withGraphFetched('b.c.e') should be ok", (done) => { - QueryBuilder.forClass(TestModel) + it("allowGraph('[a, b.c.[d, e]]').withGraphFetched('b.c.e') should be ok", () => { + return QueryBuilder.forClass(TestModel) .allowGraph('[a, b.c.[d, e]]') .withGraphFetched('b.c.e') .then(() => { - expect(executedQueries).to.have.length(1); - done(); - }) - .catch(() => { - done(new Error('should not get here')); + expect(executedQueries).toHaveLength(1); }); }); - it("allowGraph('a').withGraphFetched('a(f1)') should be ok", (done) => { - QueryBuilder.forClass(TestModel) + it("allowGraph('a').withGraphFetched('a(f1)') should be ok", () => { + return QueryBuilder.forClass(TestModel) .allowGraph('a') .withGraphFetched('a(f1)', { f1: () => {} }) .then(() => { - expect(executedQueries).to.have.length(1); - done(); - }) - .catch((err) => { - done(new Error('should not get here')); + expect(executedQueries).toHaveLength(1); }); }); - it("allowGraph('[a, b.c.[a, e]]').allowGraph('b.c.[b, d]').withGraphFetched('a') should be ok", (done) => { - QueryBuilder.forClass(TestModel) + it("allowGraph('[a, b.c.[a, e]]').allowGraph('b.c.[b, d]').withGraphFetched('a') should be ok", () => { + return QueryBuilder.forClass(TestModel) .allowGraph('[a, b.c.[a, e]]') .allowGraph('b.c.[b, d]') - .withGraphFetched('a') - .then(() => { - done(); - }); + .withGraphFetched('a'); }); - it("allowGraph('[a.[a, b], b.c.[a, e]]').allowGraph('[a.[c, d], b.c.[b, d]]').withGraphFetched('a.b') should be ok", (done) => { - QueryBuilder.forClass(TestModel) + it("allowGraph('[a.[a, b], b.c.[a, e]]').allowGraph('[a.[c, d], b.c.[b, d]]').withGraphFetched('a.b') should be ok", () => { + return QueryBuilder.forClass(TestModel) .allowGraph('[a.[a, b], b.c.[a, e]]') .allowGraph('[a.[c, d], b.c.[b, d]]') .withGraphFetched('a.b') .then(() => { - expect(executedQueries).to.have.length(1); - done(); - }) - .catch(() => { - done(new Error('should not get here')); + expect(executedQueries).toHaveLength(1); }); }); - it("allowGraph('[a.[a, b], b.[a, c]]').allowGraph('[a.[c, d], b.c.[b, d]]').withGraphFetched('a.c') should be ok", (done) => { - QueryBuilder.forClass(TestModel) + it("allowGraph('[a.[a, b], b.[a, c]]').allowGraph('[a.[c, d], b.c.[b, d]]').withGraphFetched('a.c') should be ok", () => { + return QueryBuilder.forClass(TestModel) .allowGraph('[a.[a, b], b.[a, c]]') .allowGraph('[a.[c, d], b.c.[b, d]]') .withGraphFetched('a.c') .then(() => { - expect(executedQueries).to.have.length(1); - done(); - }) - .catch(() => { - done(new Error('should not get here')); + expect(executedQueries).toHaveLength(1); }); }); - it("allowGraph('[a.[a, b], b.[a, c]]').allowGraph('[a.[c, d], b.c.[b, d]]').withGraphFetched('b.a') should be ok", (done) => { - QueryBuilder.forClass(TestModel) + it("allowGraph('[a.[a, b], b.[a, c]]').allowGraph('[a.[c, d], b.c.[b, d]]').withGraphFetched('b.a') should be ok", () => { + return QueryBuilder.forClass(TestModel) .allowGraph('[a.[a, b], b.[a, c]]') .allowGraph('[a.[c, d], b.c.[b, d]]') .withGraphFetched('b.a') .then(() => { - expect(executedQueries).to.have.length(1); - done(); - }) - .catch(() => { - done(new Error('should not get here')); + expect(executedQueries).toHaveLength(1); }); }); - it("allowGraph('[a.[a, b], b.[a, c]]').allowGraph('[a.[c, d], b.c.[b, d]]').withGraphFetched('b.c') should be ok", (done) => { - QueryBuilder.forClass(TestModel) + it("allowGraph('[a.[a, b], b.[a, c]]').allowGraph('[a.[c, d], b.c.[b, d]]').withGraphFetched('b.c') should be ok", () => { + return QueryBuilder.forClass(TestModel) .allowGraph('[a.[a, b], b.[a, c]]') .allowGraph('[a.[c, d], b.c.[b, d]]') .withGraphFetched('b.c') .then(() => { - expect(executedQueries).to.have.length(1); - done(); - }) - .catch(() => { - done(new Error('should not get here')); + expect(executedQueries).toHaveLength(1); }); }); - it("allowGraph('[a.[a, b], b.[a, c]]').allowGraph('[a.[c, d], b.c.[b, d]]').withGraphFetched('b.c.b') should be ok", (done) => { - QueryBuilder.forClass(TestModel) + it("allowGraph('[a.[a, b], b.[a, c]]').allowGraph('[a.[c, d], b.c.[b, d]]').withGraphFetched('b.c.b') should be ok", () => { + return QueryBuilder.forClass(TestModel) .allowGraph('[a.[a, b], b.[a, c]]') .allowGraph('[a.[c, d], b.c.[b, d]]') .withGraphFetched('b.c.b') .then(() => { - expect(executedQueries).to.have.length(1); - done(); - }) - .catch(() => { - done(new Error('should not get here')); + expect(executedQueries).toHaveLength(1); }); }); - it("allowGraph('[a, b.c.[d, e]]').withGraphFetched('a.b') should fail", (done) => { - QueryBuilder.forClass(TestModel) + it("allowGraph('[a, b.c.[d, e]]').withGraphFetched('a.b') should fail", () => { + return QueryBuilder.forClass(TestModel) .allowGraph('[a, b.c.[d, e]]') .withGraphFetched('a.b') - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - expect(executedQueries).to.have.length(0); - done(); - }); + .then( + () => { + throw new Error('should not get here'); + }, + () => { + expect(executedQueries).toHaveLength(0); + }, + ); }); - it("allowGraph('[a, b.c.[d, e]]').allowGraph('a.[c, d]').withGraphFetched('a.b') should fail", (done) => { - QueryBuilder.forClass(TestModel) + it("allowGraph('[a, b.c.[d, e]]').allowGraph('a.[c, d]').withGraphFetched('a.b') should fail", () => { + return QueryBuilder.forClass(TestModel) .allowGraph('[a, b.c.[d, e]]') .allowGraph('a.[c, d]') .withGraphFetched('a.b') - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - expect(executedQueries).to.have.length(0); - done(); - }); + .then( + () => { + throw new Error('should not get here'); + }, + () => { + expect(executedQueries).toHaveLength(0); + }, + ); }); - it("eager('a.b').allowGraph('[a, b.c.[d, e]]') should fail", (done) => { - QueryBuilder.forClass(TestModel) + it("eager('a.b').allowGraph('[a, b.c.[d, e]]') should fail", () => { + return QueryBuilder.forClass(TestModel) .withGraphFetched('a.b') .allowGraph('[a, b.c.[d, e]]') - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - expect(executedQueries).to.have.length(0); - done(); - }); + .then( + () => { + throw new Error('should not get here'); + }, + () => { + expect(executedQueries).toHaveLength(0); + }, + ); }); - it("eager('a.b').allowGraph('[a, b.c.[d, e]]').allowGraph('a.[c, d]') should fail", (done) => { - QueryBuilder.forClass(TestModel) + it("eager('a.b').allowGraph('[a, b.c.[d, e]]').allowGraph('a.[c, d]') should fail", () => { + return QueryBuilder.forClass(TestModel) .withGraphFetched('a.b') .allowGraph('[a, b.c.[d, e]]') .allowGraph('a.[c, d]') - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - expect(executedQueries).to.have.length(0); - done(); - }); + .then( + () => { + throw new Error('should not get here'); + }, + () => { + expect(executedQueries).toHaveLength(0); + }, + ); }); - it("eager('b.c.d.e').allowGraph('[a, b.c.[d, e]]') should fail", (done) => { - QueryBuilder.forClass(TestModel) + it("eager('b.c.d.e').allowGraph('[a, b.c.[d, e]]') should fail", () => { + return QueryBuilder.forClass(TestModel) .withGraphFetched('b.c.d.e') .allowGraph('[a, b.c.[d, e]]') - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - expect(executedQueries).to.have.length(0); - done(); - }); + .then( + () => { + throw new Error('should not get here'); + }, + () => { + expect(executedQueries).toHaveLength(0); + }, + ); }); - it("eager('b.c.d.e').allowGraph('[a, b.c.[d, e]]').allowGraph('b.c.a') should fail", (done) => { - QueryBuilder.forClass(TestModel) + it("eager('b.c.d.e').allowGraph('[a, b.c.[d, e]]').allowGraph('b.c.a') should fail", () => { + return QueryBuilder.forClass(TestModel) .withGraphFetched('b.c.d.e') .allowGraph('[a, b.c.[d, e]]') .allowGraph('b.c.a') - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - expect(executedQueries).to.have.length(0); - done(); - }); + .then( + () => { + throw new Error('should not get here'); + }, + () => { + expect(executedQueries).toHaveLength(0); + }, + ); }); it('graphExpressionObject() should return the eager expression as an object', () => { const builder = QueryBuilder.forClass(TestModel).withGraphFetched('[a, b.c(foo)]'); - expect(builder.graphExpressionObject()).to.eql({ + expect(builder.graphExpressionObject()).toEqual({ $name: null, $relation: null, $modify: [], @@ -3548,10 +3421,10 @@ describe('QueryBuilder', () => { const expr = QueryBuilder.forClass(TestModel).withGraphFetched(graph).graphExpressionObject(); - expect(objection.RelationExpression.create(expr).toString()).to.equal('[a.d, e.f]'); - expect(expr.$childNames).to.eql(['a', 'e']); - expect(expr.a.$childNames).to.eql(['d']); - expect(expr.b).to.equal(undefined); + expect(objection.RelationExpression.create(expr).toString()).toBe('[a.d, e.f]'); + expect(expr.$childNames).toEqual(['a', 'e']); + expect(expr.a.$childNames).toEqual(['d']); + expect(expr.b).toBeUndefined(); }); it("modifiers() should return the eager expression's modifiers as an object", () => { @@ -3560,12 +3433,12 @@ describe('QueryBuilder', () => { foo, }); - expect(builder.modifiers()).to.eql({ + expect(builder.modifiers()).toEqual({ foo, }); }); - it('should use correct query builders', (done) => { + it('should use correct query builders', () => { class M1QueryBuilder extends QueryBuilder {} class M2QueryBuilder extends QueryBuilder {} class M3QueryBuilder extends QueryBuilder {} @@ -3639,7 +3512,7 @@ describe('QueryBuilder', () => { let filter1Check = false; let filter2Check = false; - QueryBuilder.forClass(M1) + return QueryBuilder.forClass(M1) .withGraphFetched('m2.m3') .modifyGraph('m2', (builder) => { filter1Check = builder instanceof M2QueryBuilder; @@ -3648,21 +3521,18 @@ describe('QueryBuilder', () => { filter2Check = builder instanceof M3QueryBuilder; }) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "M1".* from "M1"', 'select "M2".* from "M2" where "M2"."m1Id" in (1)', 'select "M3".* from "M3" where "M3"."id" in (3)', ]); - expect(filter1Check).to.equal(true); - expect(filter2Check).to.equal(true); - - done(); - }) - .catch(done); + expect(filter1Check).toBe(true); + expect(filter2Check).toBe(true); + }); }); - it('$afterFind should be called after relations have been fetched', (done) => { + it('$afterFind should be called after relations have been fetched', () => { class M1 extends Model { static get tableName() { return 'M1'; @@ -3708,16 +3578,16 @@ describe('QueryBuilder', () => { ], ]; - QueryBuilder.forClass(M1) + return QueryBuilder.forClass(M1) .withGraphFetched('someRel.someRel') .then((x) => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "M1".* from "M1"', 'select "M1".* from "M1" where "M1"."m1Id" in (1, 2)', 'select "M1".* from "M1" where "M1"."m1Id" in (3, 4, 5, 6)', ]); - expect(x).to.eql([ + expect(x).toEqual([ { id: 1, ids: [3, 4], @@ -3767,21 +3637,11 @@ describe('QueryBuilder', () => { ], }, ]); - - done(); - }) - .catch(done); + }); }); }); describe('mixing withGraphJoined and withGraphFetched', () => { - const { - JoinEagerOperation, - } = require('../../../lib/queryBuilder/operations/eager/JoinEagerOperation'); - const { - WhereInEagerOperation, - } = require('../../../lib/queryBuilder/operations/eager/WhereInEagerOperation'); - let Person; let Animal; let Movie; @@ -3897,15 +3757,15 @@ describe('QueryBuilder', () => { .withGraphFetched('movies') .where('pets.name', 'like', 'A%') .then((models) => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `${joinQuery} where "pets"."name" like 'A%'`, 'select "Movie".* from "Movie" where "Movie"."personId" in (1, 2)', ]); - expect(models[0]).to.be.a(Person); - expect(models[0].pets[0]).to.be.a(Animal); - expect(models[0].movies[0]).to.be.a(Movie); - expect(toJson(models)).to.eql(expectedGraph); + expect(models[0]).toBeInstanceOf(Person); + expect(models[0].pets[0]).toBeInstanceOf(Animal); + expect(models[0].movies[0]).toBeInstanceOf(Movie); + expect(toJson(models)).toEqual(expectedGraph); }); }); @@ -3916,12 +3776,12 @@ describe('QueryBuilder', () => { .withGraphFetched('movies') .withGraphJoined('pets') .then((models) => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ joinQuery, 'select "Movie".* from "Movie" where "Movie"."personId" in (1, 2)', ]); - expect(toJson(models)).to.eql(expectedGraph); + expect(toJson(models)).toEqual(expectedGraph); }); }); @@ -3931,9 +3791,9 @@ describe('QueryBuilder', () => { (op) => op instanceof JoinEagerOperation || op instanceof WhereInEagerOperation, ); - expect(eagerOps).to.have.length(2); - expect(eagerOps[0]).to.be.a(JoinEagerOperation); - expect(eagerOps[1]).to.be.a(WhereInEagerOperation); + expect(eagerOps).toHaveLength(2); + expect(eagerOps[0]).toBeInstanceOf(JoinEagerOperation); + expect(eagerOps[1]).toBeInstanceOf(WhereInEagerOperation); }); it('should work when withGraphJoined is called in a runBefore hook', () => { @@ -3945,12 +3805,12 @@ describe('QueryBuilder', () => { builder.withGraphJoined('pets'); }) .then((models) => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ joinQuery, 'select "Movie".* from "Movie" where "Movie"."personId" in (1, 2)', ]); - expect(toJson(models)).to.eql(expectedGraph); + expect(toJson(models)).toEqual(expectedGraph); }); }); @@ -3970,7 +3830,7 @@ describe('QueryBuilder', () => { }); return builder.then(() => { - expect(executedQueries[0]).to.equal(joinQuery.replace('select ', 'select 1 as one, ')); + expect(executedQueries[0]).toBe(joinQuery.replace('select ', 'select 1 as one, ')); }); }); @@ -3981,15 +3841,15 @@ describe('QueryBuilder', () => { .withGraphJoined('pets') .withGraphFetched('[parent, movies]') .then((models) => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ joinQuery, 'select "Person".* from "Person" where "Person"."id" in (1)', 'select "Movie".* from "Movie" where "Movie"."personId" in (1, 2)', ]); - expect(models[0].parent).to.equal(null); - expect(models[1].parent.toJSON()).to.eql({ id: 1, name: 'P1', parentId: null }); - expect(models[1].movies).to.have.length(1); + expect(models[0].parent).toBeNull(); + expect(models[1].parent.toJSON()).toEqual({ id: 1, name: 'P1', parentId: null }); + expect(models[1].movies).toHaveLength(1); }); }); @@ -4007,14 +3867,14 @@ describe('QueryBuilder', () => { .withGraphJoined('pets') .withGraphFetched('movies') .then((models) => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "Person"."name", "Person"."id" as "id", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId" from "Person" left join "Animal" as "pets" on "pets"."ownerId" = "Person"."id"', 'select "Movie".* from "Movie" where "Movie"."personId" in (1, 2)', ]); // `id` is selected internally by the join and needed by the fetch, // so it must survive the join but be omitted in the end. - expect(toJson(models)).to.eql([ + expect(toJson(models)).toEqual([ { name: 'P1', pets: [{ id: 10, name: 'A10', ownerId: 1 }], @@ -4040,10 +3900,10 @@ describe('QueryBuilder', () => { .withGraphJoined('pets') .withGraphFetched('movies') .then((models) => { - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'select "Movie".* from "Movie" where "Movie"."personId" in (1)', ); - expect(toJson(models)).to.eql([{ id: 1, name: 'P1', pets: [], movies: [] }]); + expect(toJson(models)).toEqual([{ id: 1, name: 'P1', pets: [], movies: [] }]); }); }); @@ -4053,27 +3913,29 @@ describe('QueryBuilder', () => { it(`should throw if the same relation is passed to ${first} and ${second}`, () => { const builder = Person.query()[first]('[pets, movies]'); - expect(() => builder[second]('pets')).to.throwException((err) => { - expect(err.message).to.equal( - 'relation "pets" cannot be loaded with both withGraphJoined and withGraphFetched', - ); - }); + expect(() => builder[second]('pets')).toThrow( + expect.objectContaining({ + message: + 'relation "pets" cannot be loaded with both withGraphJoined and withGraphFetched', + }), + ); }); it(`should throw if a sub relation of a ${first} relation is passed to ${second}`, () => { const builder = Person.query()[first]('pets'); - expect(() => builder[second]('pets.owner')).to.throwException((err) => { - expect(err.message).to.equal( - 'relation "pets" cannot be loaded with both withGraphJoined and withGraphFetched', - ); - }); + expect(() => builder[second]('pets.owner')).toThrow( + expect.objectContaining({ + message: + 'relation "pets" cannot be loaded with both withGraphJoined and withGraphFetched', + }), + ); }); it(`should throw if \`*\` is used when mixing ${first} and ${second}`, () => { const builder = Person.query()[first]('*'); - expect(() => builder[second]('pets')).to.throwException(/relation expression "\*"/); + expect(() => builder[second]('pets')).toThrow(/relation expression "\*"/); }); }); @@ -4082,7 +3944,7 @@ describe('QueryBuilder', () => { Person.query() .withGraphJoined('pets as joinedPets') .withGraphFetched('pets as fetchedPets'), - ).to.not.throwException(); + ).not.toThrow(); }); it('should merge multiple calls of the same method into one operation', () => { @@ -4095,42 +3957,38 @@ describe('QueryBuilder', () => { .withGraphFetched('movies.foo', { maxBatchSize: 1 }) .withGraphFetched('movies', { maxBatchSize: 5 }); - expect(countOps(builder, JoinEagerOperation)).to.equal(1); - expect(countOps(builder, WhereInEagerOperation)).to.equal(1); - expect(builder.findOperation(JoinEagerOperation).expression.toString()).to.equal( + expect(countOps(builder, JoinEagerOperation)).toBe(1); + expect(countOps(builder, WhereInEagerOperation)).toBe(1); + expect(builder.findOperation(JoinEagerOperation).expression.toString()).toBe( '[pets, parent]', ); - expect(builder.findOperation(WhereInEagerOperation).expression.toString()).to.equal( - 'movies.foo', - ); - expect(builder.findOperation(WhereInEagerOperation).graphOptions.maxBatchSize).to.equal(5); - expect(builder.findOperation(JoinEagerOperation).graphOptions.maxBatchSize).to.equal( - undefined, - ); + expect(builder.findOperation(WhereInEagerOperation).expression.toString()).toBe('movies.foo'); + expect(builder.findOperation(WhereInEagerOperation).graphOptions.maxBatchSize).toBe(5); + expect(builder.findOperation(JoinEagerOperation).graphOptions.maxBatchSize).toBeUndefined(); }); it('graphExpressionObject() should merge both expressions', () => { const builder = Person.query().withGraphJoined('pets').withGraphFetched('movies'); - expect(builder.graphExpressionObject()).to.eql( + expect(builder.graphExpressionObject()).toEqual( objection.RelationExpression.create('[pets, movies]').toPojo(), ); }); it('hasWithGraph() should consider both operations', () => { - expect(Person.query().withGraphJoined('pets').hasWithGraph()).to.equal(true); - expect(Person.query().withGraphFetched('pets').hasWithGraph()).to.equal(true); + expect(Person.query().withGraphJoined('pets').hasWithGraph()).toBe(true); + expect(Person.query().withGraphFetched('pets').hasWithGraph()).toBe(true); expect( Person.query() .modifyGraph('pets', () => {}) .hasWithGraph(), - ).to.equal(false); + ).toBe(false); expect( Person.query() .modifyGraph('pets', () => {}) .withGraphJoined('pets') .hasWithGraph(), - ).to.equal(true); + ).toBe(true); }); ['before', 'after'].forEach((when) => { @@ -4153,14 +4011,14 @@ describe('QueryBuilder', () => { } return builder.then((models) => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "Person"."id" as "id", "Person"."name" as "name", "Person"."parentId" as "parentId", ' + '"pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId" ' + 'from "Person" left join (select "Animal".* from "Animal" where "name" = \'A10\') as "pets" on "pets"."ownerId" = "Person"."id"', 'select "Movie".* from "Movie" where "Movie"."personId" in (1, 2) and "name" = \'M100\'', ]); - expect(models).to.have.length(2); + expect(models).toHaveLength(2); }); }); }); @@ -4174,10 +4032,10 @@ describe('QueryBuilder', () => { .modifyGraph('movies', (qb) => qb.where('name', 'M100')) .withGraphFetched('movies') .then(() => { - expect(executedQueries[0]).to.contain( + expect(executedQueries[0]).toContain( 'left join (select "Animal".* from "Animal" where "name" = \'A10\') as "pets"', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'select "Movie".* from "Movie" where "Movie"."personId" in (1, 2) and "name" = \'M100\'', ); }); @@ -4194,25 +4052,25 @@ describe('QueryBuilder', () => { it('should keep the order on postgres, mysql and sqlite', () => { const modifier = (qb) => qb.where('name', 'A10').orderBy('name'); - expect(joinSql('pg', modifier)).to.contain( + expect(joinSql('pg', modifier)).toContain( 'left join (select "Animal".* from "Animal" where "name" = \'A10\' order by "name" asc) as "pets"', ); - expect(joinSql('mysql', modifier)).to.contain( + expect(joinSql('mysql', modifier)).toContain( "left join (select `Animal`.* from `Animal` where `name` = 'A10' order by `name` asc) as `pets`", ); - expect(joinSql('sqlite3', modifier)).to.contain( + expect(joinSql('sqlite3', modifier)).toContain( "left join (select `Animal`.* from `Animal` where `name` = 'A10' order by `name` asc) as `pets`", ); }); it('should leave out the order on mssql', () => { - expect(joinSql('mssql', (qb) => qb.where('name', 'A10').orderBy('name'))).to.contain( + expect(joinSql('mssql', (qb) => qb.where('name', 'A10').orderBy('name'))).toContain( "left join (select [Animal].* from [Animal] where [name] = 'A10') as [pets]", ); }); it('should keep the order with a limit on mssql', () => { - expect(joinSql('mssql', (qb) => qb.orderBy('name').limit(5))).to.contain( + expect(joinSql('mssql', (qb) => qb.orderBy('name').limit(5))).toContain( 'left join (select top (5) [Animal].* from [Animal] order by [name] asc) as [pets]', ); }); @@ -4220,7 +4078,7 @@ describe('QueryBuilder', () => { it('should keep the order with first() and useLimitInFirst on mssql', () => { Animal.useLimitInFirst = true; - expect(joinSql('mssql', (qb) => qb.orderBy('name').first())).to.contain( + expect(joinSql('mssql', (qb) => qb.orderBy('name').first())).toContain( 'left join (select top (1) [Animal].* from [Animal] order by [name] asc) as [pets]', ); }); @@ -4233,7 +4091,7 @@ describe('QueryBuilder', () => { .withGraphFetched('movies') .modifyGraph('movies', () => {}); - expect(builder.graphModifiersAtPath().map((it) => it.path)).to.eql(['pets', 'movies']); + expect(builder.graphModifiersAtPath().map((it) => it.path)).toEqual(['pets', 'movies']); }); it('clone() should not share the graph modifiers', () => { @@ -4244,7 +4102,7 @@ describe('QueryBuilder', () => { builder.clone().modifyGraph('movies', () => {}); - expect(builder.graphModifiersAtPath().map((it) => it.path)).to.eql(['pets']); + expect(builder.graphModifiersAtPath().map((it) => it.path)).toEqual(['pets']); }); it('clearWithGraph() should drop the graph modifiers', () => { @@ -4254,7 +4112,7 @@ describe('QueryBuilder', () => { .clearWithGraph() .withGraphFetched('movies'); - expect(builder.graphModifiersAtPath()).to.eql([]); + expect(builder.graphModifiersAtPath()).toEqual([]); }); it('clearWithGraph() should clear both operations', () => { @@ -4265,13 +4123,13 @@ describe('QueryBuilder', () => { .withGraphFetched('movies') .clearWithGraph(); - expect(builder.findOperation(JoinEagerOperation)).to.equal(null); - expect(builder.findOperation(WhereInEagerOperation)).to.equal(null); - expect(builder.hasWithGraph()).to.equal(false); - expect(builder.graphExpressionObject()).to.equal(null); + expect(builder.findOperation(JoinEagerOperation)).toBeNull(); + expect(builder.findOperation(WhereInEagerOperation)).toBeNull(); + expect(builder.hasWithGraph()).toBe(false); + expect(builder.graphExpressionObject()).toBeNull(); return builder.then(() => { - expect(executedQueries).to.eql(['select "Person".* from "Person"']); + expect(executedQueries).toEqual(['select "Person".* from "Person"']); }); }); @@ -4283,16 +4141,16 @@ describe('QueryBuilder', () => { .withGraphFetched('movies') .clearWithGraphFetched(); - expect(builder.findOperation(JoinEagerOperation)).to.not.equal(null); - expect(builder.findOperation(WhereInEagerOperation)).to.equal(null); - expect(builder.graphExpressionObject()).to.eql( + expect(builder.findOperation(JoinEagerOperation)).not.toBeNull(); + expect(builder.findOperation(WhereInEagerOperation)).toBeNull(); + expect(builder.graphExpressionObject()).toEqual( objection.RelationExpression.create('pets').toPojo(), ); return builder.then((models) => { - expect(executedQueries).to.eql([joinQuery]); - expect(models[0].pets).to.have.length(2); - expect(models[0].movies).to.equal(undefined); + expect(executedQueries).toEqual([joinQuery]); + expect(models[0].pets).toHaveLength(2); + expect(models[0].movies).toBeUndefined(); }); }); @@ -4302,16 +4160,16 @@ describe('QueryBuilder', () => { const builder = Person.query().withGraphJoined('pets').withGraphFetched('movies'); const clone = builder.clone(); - expect(countOps(clone, JoinEagerOperation)).to.equal(1); - expect(countOps(clone, WhereInEagerOperation)).to.equal(1); - expect(clone.graphExpressionObject()).to.eql(builder.graphExpressionObject()); + expect(countOps(clone, JoinEagerOperation)).toBe(1); + expect(countOps(clone, WhereInEagerOperation)).toBe(1); + expect(clone.graphExpressionObject()).toEqual(builder.graphExpressionObject()); // Modifying the clone should not affect the original. clone.withGraphFetched('parent'); - expect(builder.findOperation(WhereInEagerOperation).expression.toString()).to.equal('movies'); + expect(builder.findOperation(WhereInEagerOperation).expression.toString()).toBe('movies'); return builder.then((models) => { - expect(toJson(models)).to.eql(expectedGraph); + expect(toJson(models)).toEqual(expectedGraph); }); }); @@ -4337,11 +4195,11 @@ describe('QueryBuilder', () => { .then(() => 'ok') .catch((err) => err), ]).then(([ok, err1, err2]) => { - expect(ok).to.equal('ok'); - expect(err1).to.be.a(objection.ValidationError); - expect(err1.type).to.equal('UnallowedRelation'); - expect(err2).to.be.a(objection.ValidationError); - expect(err2.type).to.equal('UnallowedRelation'); + expect(ok).toBe('ok'); + expect(err1).toBeInstanceOf(objection.ValidationError); + expect(err1.type).toBe('UnallowedRelation'); + expect(err2).toBeInstanceOf(objection.ValidationError); + expect(err2.type).toBe('UnallowedRelation'); }); }); @@ -4354,14 +4212,14 @@ describe('QueryBuilder', () => { .where('pets.name', 'A10') .page(0, 10) .then((res) => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `${joinQuery} where "pets"."name" = 'A10' limit 10`, 'select "Movie".* from "Movie" where "Movie"."personId" in (1, 2)', `select count(*) as "count" from (${countJoinQuery} where "pets"."name" = 'A10') as "temp"`, ]); - expect(res.total).to.equal(2); - expect(toJson(res.results)).to.eql(expectedGraph); + expect(res.total).toBe(2); + expect(toJson(res.results)).toEqual(expectedGraph); }); }); @@ -4373,10 +4231,10 @@ describe('QueryBuilder', () => { .withGraphFetched('movies') .resultSize() .then((count) => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `select count(*) as "count" from (${countJoinQuery}) as "temp"`, ]); - expect(count).to.equal(2); + expect(count).toBe(2); }); }); @@ -4393,7 +4251,7 @@ describe('QueryBuilder', () => { }; it('should fetch relations if no algorithm was used before', () => { - expect(relationsOf(Person.query().withGraph('[pets, movies]'))).to.eql({ + expect(relationsOf(Person.query().withGraph('[pets, movies]'))).toEqual({ join: [], fetch: ['pets', 'movies'], }); @@ -4404,21 +4262,21 @@ describe('QueryBuilder', () => { .withGraph('pets', { algorithm: 'join' }) .withGraph('movies', { algorithm: 'fetch' }); - expect(relationsOf(builder)).to.eql({ join: ['pets'], fetch: ['movies'] }); + expect(relationsOf(builder)).toEqual({ join: ['pets'], fetch: ['movies'] }); }); it('should throw for unknown algorithms', () => { expect(() => { Person.query().withGraph('pets', { algorithm: 'naive' }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'unknown graph algorithm "naive", expected "fetch" or "join"', - ); - }); + }).toThrow( + expect.objectContaining({ + message: 'unknown graph algorithm "naive", expected "fetch" or "join"', + }), + ); }); it('should use the most recently used algorithm for new relations', () => { - expect(relationsOf(Person.query().withGraphJoined('pets').withGraph('movies'))).to.eql({ + expect(relationsOf(Person.query().withGraphJoined('pets').withGraph('movies'))).toEqual({ join: ['pets', 'movies'], fetch: [], }); @@ -4428,7 +4286,7 @@ describe('QueryBuilder', () => { .withGraphJoined('pets') .withGraph('parent'); - expect(relationsOf(builder)).to.eql({ join: ['pets', 'parent'], fetch: ['movies'] }); + expect(relationsOf(builder)).toEqual({ join: ['pets', 'parent'], fetch: ['movies'] }); }); it('should merge existing relations into their operations without throwing', () => { @@ -4437,7 +4295,7 @@ describe('QueryBuilder', () => { .withGraphFetched('movies') .withGraph('[pets, movies, parent]'); - expect(relationsOf(builder)).to.eql({ join: ['pets'], fetch: ['movies', 'parent'] }); + expect(relationsOf(builder)).toEqual({ join: ['pets'], fetch: ['movies', 'parent'] }); }); it('should not change the most recently used algorithm when merging', () => { @@ -4447,7 +4305,7 @@ describe('QueryBuilder', () => { .withGraph('pets') .withGraph('parent'); - expect(relationsOf(builder)).to.eql({ join: ['pets'], fetch: ['movies', 'parent'] }); + expect(relationsOf(builder)).toEqual({ join: ['pets'], fetch: ['movies', 'parent'] }); }); it('should add nested relations to the operation of their top-level relation', () => { @@ -4456,10 +4314,8 @@ describe('QueryBuilder', () => { .withGraphFetched('movies') .withGraph('[parent.pets, movies]'); - expect(relationsOf(builder)).to.eql({ join: ['parent'], fetch: ['movies'] }); - expect(builder.findOperation(JoinEagerOperation).expression.toString()).to.equal( - 'parent.pets', - ); + expect(relationsOf(builder)).toEqual({ join: ['parent'], fetch: ['movies'] }); + expect(builder.findOperation(JoinEagerOperation).expression.toString()).toBe('parent.pets'); }); it('should not mistake inherited object properties for loaded relations', () => { @@ -4468,33 +4324,34 @@ describe('QueryBuilder', () => { .withGraphFetched('movies') .withGraph('constructor'); - expect(relationsOf(builder)).to.eql({ join: ['pets'], fetch: ['movies', 'constructor'] }); + expect(relationsOf(builder)).toEqual({ join: ['pets'], fetch: ['movies', 'constructor'] }); }); it('should still throw for contradicting explicit algorithms', () => { expect(() => { Person.query().withGraphJoined('pets').withGraph('pets', { algorithm: 'fetch' }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'relation "pets" cannot be loaded with both withGraphJoined and withGraphFetched', - ); - }); + }).toThrow( + expect.objectContaining({ + message: + 'relation "pets" cannot be loaded with both withGraphJoined and withGraphFetched', + }), + ); }); it('should keep the most recently used algorithm in clones', () => { const builder = Person.query().withGraphJoined('pets').clone().withGraph('movies'); - expect(relationsOf(builder)).to.eql({ join: ['pets', 'movies'], fetch: [] }); + expect(relationsOf(builder)).toEqual({ join: ['pets', 'movies'], fetch: [] }); }); it('should forget the most recently used algorithm in clearWithGraph()', () => { const builder = Person.query().withGraphJoined('pets').clearWithGraph().withGraph('movies'); - expect(relationsOf(builder)).to.eql({ join: [], fetch: ['movies'] }); + expect(relationsOf(builder)).toEqual({ join: [], fetch: ['movies'] }); }); it('should inherit the most recently used algorithm in child queries', () => { const parent = Person.query().withGraphJoined('pets'); const child = Person.query().childQueryOf(parent).withGraph('movies'); - expect(relationsOf(child)).to.eql({ join: ['movies'], fetch: [] }); + expect(relationsOf(child)).toEqual({ join: ['movies'], fetch: [] }); }); it('should pass the other options to the operations', () => { @@ -4504,10 +4361,10 @@ describe('QueryBuilder', () => { .withGraph('pets', { joinOperation: 'innerJoin' }) .withGraph('parent', { maxBatchSize: 1 }); - expect(builder.toKnexQuery().toString()).to.contain( + expect(builder.toKnexQuery().toString()).toContain( 'inner join "Animal" as "pets" on "pets"."ownerId" = "Person"."id"', ); - expect(builder.findOperation(WhereInEagerOperation).graphOptions.maxBatchSize).to.equal(1); + expect(builder.findOperation(WhereInEagerOperation).graphOptions.maxBatchSize).toBe(1); }); it('should warn and ignore joinOperation for fetched relations', () => { @@ -4522,9 +4379,9 @@ describe('QueryBuilder', () => { .withGraphFetched('movies', { joinOperation: 'innerJoin' }) .withGraph('[pets, parent]', { joinOperation: 'rightJoin' }); - expect(builder.findOperation(WhereInEagerOperation).joinOperations).to.eql([]); - expect(builder.findOperation(JoinEagerOperation).joinOperations).to.have.length(1); - expect(warnings).to.eql([ + expect(builder.findOperation(WhereInEagerOperation).joinOperations).toEqual([]); + expect(builder.findOperation(JoinEagerOperation).joinOperations).toHaveLength(1); + expect(warnings).toEqual([ '[objection] The joinOperation option only applies to withGraphJoined() and is ' + 'ignored for the fetched relations "movies".', '[objection] The joinOperation option only applies to withGraphJoined() and is ' + @@ -4548,7 +4405,7 @@ describe('QueryBuilder', () => { .withGraphJoined('pets', { limitPerParent: true }) .withGraphJoined('pets', { limitPerParent: true }); - expect(warnings).to.eql([ + expect(warnings).toEqual([ '[objection] The limitPerParent option only applies to withGraphFetched() and is ' + 'ignored for the joined relations "pets".', ]); @@ -4566,12 +4423,12 @@ describe('QueryBuilder', () => { .withGraphFetched('movies') .withGraph('[pets, movies]') .then((models) => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ joinQuery, 'select "Movie".* from "Movie" where "Movie"."personId" in (1, 2)', ]); - expect(toJson(models)).to.eql(expectedGraph); + expect(toJson(models)).toEqual(expectedGraph); }); }); @@ -4587,11 +4444,11 @@ describe('QueryBuilder', () => { m100: (builder) => builder.where('name', 'M100'), }) .then(() => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.contain( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toContain( `left join (select "Animal".* from "Animal" where "name" = 'A10') as "pets"`, ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( `select "Movie".* from "Movie" where "Movie"."personId" in (1, 2) and "name" = 'M100'`, ); }); @@ -4623,10 +4480,10 @@ describe('QueryBuilder', () => { ); } - expect(builder.isJoinChildQuery()).to.equal(false); + expect(builder.isJoinChildQuery()).toBe(false); return builder.then(() => { - expect(childQueries).to.eql({ Animal: true, Movie: false }); + expect(childQueries).toEqual({ Animal: true, Movie: false }); }); }); } @@ -4635,9 +4492,9 @@ describe('QueryBuilder', () => { const parent = Person.query(); const child = Person.query().childQueryOf(parent, { isJoinChildQuery: true }); - expect(child.isJoinChildQuery()).to.equal(true); - expect(child.clone().isJoinChildQuery()).to.equal(true); - expect(parent.isJoinChildQuery()).to.equal(false); + expect(child.isJoinChildQuery()).toBe(true); + expect(child.clone().isJoinChildQuery()).toBe(true); + expect(parent.isJoinChildQuery()).toBe(false); }); }); }); @@ -4727,7 +4584,7 @@ describe('QueryBuilder', () => { .withGraphJoined('parent', { joinOperation: 'innerJoin' }) .withGraphJoined('pets', { joinOperation: 'leftJoin' }), ), - ).to.eql({ parent: 'inner', pets: 'left' }); + ).toEqual({ parent: 'inner', pets: 'left' }); expect( getJoinTypes( @@ -4735,7 +4592,7 @@ describe('QueryBuilder', () => { .withGraphJoined('pets', { joinOperation: 'leftJoin' }) .withGraphJoined('parent', { joinOperation: 'innerJoin' }), ), - ).to.eql({ parent: 'inner', pets: 'left' }); + ).toEqual({ parent: 'inner', pets: 'left' }); }); it('should use the joinOperation for both joins of many-to-many relations', () => { @@ -4745,7 +4602,7 @@ describe('QueryBuilder', () => { .withGraphJoined('movies', { joinOperation: 'innerJoin' }) .withGraphJoined('pets'), ), - ).to.eql({ movies_join: 'inner', movies: 'inner', pets: 'left' }); + ).toEqual({ movies_join: 'inner', movies: 'inner', pets: 'left' }); }); it('should use the default join operation for calls without a joinOperation', () => { @@ -4755,7 +4612,7 @@ describe('QueryBuilder', () => { .withGraphJoined('parent', { joinOperation: 'innerJoin' }) .withGraphJoined('pets'), ), - ).to.eql({ parent: 'inner', pets: 'left' }); + ).toEqual({ parent: 'inner', pets: 'left' }); }); it('should use the joinOperation of defaultGraphOptions as the default', () => { @@ -4767,7 +4624,7 @@ describe('QueryBuilder', () => { .withGraphJoined('parent') .withGraphJoined('pets', { joinOperation: 'leftJoin' }), ), - ).to.eql({ parent: 'inner', pets: 'left' }); + ).toEqual({ parent: 'inner', pets: 'left' }); }); it('nested relations should inherit the joinOperation of the call that added them', () => { @@ -4777,7 +4634,7 @@ describe('QueryBuilder', () => { .withGraphJoined('parent.[pets, parent]', { joinOperation: 'innerJoin' }) .withGraphJoined('pets'), ), - ).to.eql({ + ).toEqual({ parent: 'inner', 'parent:pets': 'inner', 'parent:parent': 'inner', @@ -4792,7 +4649,7 @@ describe('QueryBuilder', () => { .withGraphJoined('parent.^3', { joinOperation: 'innerJoin' }) .withGraphJoined('pets'), ), - ).to.eql({ + ).toEqual({ parent: 'inner', 'parent:parent': 'inner', 'parent:parent:parent': 'inner', @@ -4807,7 +4664,7 @@ describe('QueryBuilder', () => { .withGraphJoined('parent', { joinOperation: 'innerJoin' }) .withGraphJoined('parent', { joinOperation: 'leftJoin' }), ), - ).to.eql({ parent: 'left' }); + ).toEqual({ parent: 'left' }); expect( getJoinTypes( @@ -4815,7 +4672,7 @@ describe('QueryBuilder', () => { .withGraphJoined('parent.pets', { joinOperation: 'leftJoin' }) .withGraphJoined('parent', { joinOperation: 'innerJoin' }), ), - ).to.eql({ parent: 'inner', 'parent:pets': 'left' }); + ).toEqual({ parent: 'inner', 'parent:pets': 'left' }); // A call without a joinOperation doesn't override the joinOperation of // the relations, but its new nested relations use the default. @@ -4825,7 +4682,7 @@ describe('QueryBuilder', () => { .withGraphJoined('parent', { joinOperation: 'innerJoin' }) .withGraphJoined('parent.pets'), ), - ).to.eql({ parent: 'inner', 'parent:pets': 'left' }); + ).toEqual({ parent: 'inner', 'parent:pets': 'left' }); }); it('should support aliased relations', () => { @@ -4835,7 +4692,7 @@ describe('QueryBuilder', () => { .withGraphJoined('pets as dogs', { joinOperation: 'innerJoin' }) .withGraphJoined('pets as cats'), ), - ).to.eql({ dogs: 'inner', cats: 'left' }); + ).toEqual({ dogs: 'inner', cats: 'left' }); }); it('should keep the joinOperations when cloning', () => { @@ -4843,7 +4700,7 @@ describe('QueryBuilder', () => { .withGraphJoined('parent', { joinOperation: 'innerJoin' }) .withGraphJoined('pets', { joinOperation: 'leftJoin' }); - expect(getJoinTypes(builder.clone().withGraphJoined('movies'))).to.eql({ + expect(getJoinTypes(builder.clone().withGraphJoined('movies'))).toEqual({ parent: 'inner', pets: 'left', movies_join: 'left', @@ -4859,7 +4716,7 @@ describe('QueryBuilder', () => { .withGraphFetched('movies') .withGraphJoined('pets'), ), - ).to.eql({ parent: 'inner', pets: 'left' }); + ).toEqual({ parent: 'inner', pets: 'left' }); }); it('should run the query with the joinOperation of each call', () => { @@ -4869,8 +4726,8 @@ describe('QueryBuilder', () => { .withGraphJoined('parent', { joinOperation: 'innerJoin' }) .withGraphJoined('pets', { joinOperation: 'leftJoin' }) .then(() => { - expect(executedQueries).to.have.length(1); - expect(joinTypes(executedQueries[0])).to.eql({ parent: 'inner', pets: 'left' }); + expect(executedQueries).toHaveLength(1); + expect(joinTypes(executedQueries[0])).toEqual({ parent: 'inner', pets: 'left' }); }); }); }); @@ -4930,9 +4787,9 @@ describe('QueryBuilder', () => { throw new Error('should not get here'); }, (err) => { - expect(err.message).to.contain('withGraphJoined'); - expect(err.message).to.contain(`model ${modelName}`); - expect(err.message).to.contain('withGraphFetched'); + expect(err.message).toContain('withGraphJoined'); + expect(err.message).toContain(`model ${modelName}`); + expect(err.message).toContain('withGraphFetched'); }, ); @@ -4979,7 +4836,7 @@ describe('QueryBuilder', () => { return Person.query() .withGraphJoined('pets') .then((models) => { - expect(models).to.eql([]); + expect(models).toEqual([]); }); }); @@ -4994,7 +4851,7 @@ describe('QueryBuilder', () => { return Person.query() .withGraphJoined('pets') .then((models) => { - expect(models.map((it) => it.toJSON())).to.eql([ + expect(models.map((it) => it.toJSON())).toEqual([ { id: 1, name: 'P1', pets: [] }, { id: 2, name: 'P2', pets: [{ id: 2, name: 'A2', ownerId: 'P2' }] }, ]); @@ -5014,13 +4871,13 @@ describe('QueryBuilder', () => { .withGraphJoined('pets(selectName)') .modifiers({ selectName: (query) => query.select('name') }) .then((models) => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select "name", "Person"."id" as "id", ' + '"pets"."name" as "pets:name", "pets"."id" as "pets:id" ' + 'from "Person" left join (select "name", "Animal"."id", "Animal"."ownerId" from "Animal") as "pets" ' + 'on "pets"."ownerId" = "Person"."name"', ]); - expect(models.map((it) => it.toJSON())).to.eql([ + expect(models.map((it) => it.toJSON())).toEqual([ { name: 'P1', pets: [{ name: 'A1' }, { name: 'A2' }] }, ]); }); @@ -5075,7 +4932,7 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql).to.equal( + expect(sql).toBe( 'select distinct on ("Person"."name") "Person"."id" as "id", "Person"."name" as "name", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId" ' + 'from "Person" left join (select distinct on ("name") "Animal".* from "Animal") as "pets" on "pets"."ownerId" = "Person"."id"', ); @@ -5090,7 +4947,7 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql).to.equal( + expect(sql).toBe( 'select "Person".* from "Person" inner join (select distinct on ("ownerId") "Animal".* from "Animal") as "pets" on "pets"."ownerId" = "Person"."id"', ); }); @@ -5105,7 +4962,7 @@ describe('QueryBuilder', () => { oneOfEach: (query) => query.distinctOn('name'), }) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select distinct on ("name") "Person".* from "Person"', 'select distinct on ("name") "Animal".* from "Animal" where "Animal"."ownerId" in (1)', ]); @@ -5173,8 +5030,8 @@ describe('QueryBuilder', () => { Animal.query().count().where('Animal.ownerId', ref('Person.id')).as('siblingCount'), ); - expect(sql).to.contain(petSelections('siblingCount')); - expect(sql).to.contain( + expect(sql).toContain(petSelections('siblingCount')); + expect(sql).toContain( 'select "name", (select count(*) from "Animal" where "Animal"."ownerId" = "Person"."id") as "siblingCount"', ); }); @@ -5182,7 +5039,7 @@ describe('QueryBuilder', () => { it('should select a knex subquery aliased with as()', () => { const sql = buildSql(mockKnex.count().from('Animal').as('animalCount')); - expect(sql).to.contain(petSelections('animalCount')); + expect(sql).toContain(petSelections('animalCount')); }); it('should select a raw with a quoted alias in the sql', () => { @@ -5192,13 +5049,13 @@ describe('QueryBuilder', () => { raw('1 as [one]'), ); - expect(sql).to.contain(petSelections('upperName', 'lowerName', 'one')); + expect(sql).toContain(petSelections('upperName', 'lowerName', 'one')); }); it('should select a knex raw with a quoted alias in the sql', () => { const sql = buildSql(mockKnex.raw('upper("name") as "upperName"')); - expect(sql).to.contain(petSelections('upperName')); + expect(sql).toContain(petSelections('upperName')); }); it('should select a raw with an identifier binding as the alias', () => { @@ -5207,20 +5064,20 @@ describe('QueryBuilder', () => { raw('lower(:col:) as :alias:', { col: 'name', alias: 'lowerName' }), ); - expect(sql).to.contain(petSelections('upperName', 'lowerName')); + expect(sql).toContain(petSelections('upperName', 'lowerName')); }); it('should select a raw with an unquoted lower case alias in the sql', () => { const sql = buildSql(raw('upper("name") as upper_name')); - expect(sql).to.contain(petSelections('upper_name')); + expect(sql).toContain(petSelections('upper_name')); }); it('should not select raws without a recognizable alias', () => { // Unquoted mixed case aliases are folded to lower case by some databases. const sql = buildSql(raw('upper("name") as upperName'), raw('cast("id" as text)'), raw('1')); - expect(sql).to.contain(petSelections()); + expect(sql).toContain(petSelections()); }); it('should still select all columns of a relation if a modifier only selects raws with an alias in the sql', () => { @@ -5232,7 +5089,7 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql).to.equal( + expect(sql).toBe( 'select "Person"."id" as "id", "Person"."name" as "name", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId", "pets"."upperName" as "pets:upperName" ' + 'from "Person" left join (select upper("name") as "upperName", "Animal".* from "Animal") as "pets" on "pets"."ownerId" = "Person"."id"', ); @@ -5247,11 +5104,11 @@ describe('QueryBuilder', () => { .select(raw('1 as one')) .withGraphJoined('pets') .then((models) => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select 1 as one, "Person"."id" as "id", "Person"."name" as "name", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId" ' + 'from "Person" left join "Animal" as "pets" on "pets"."ownerId" = "Person"."id"', ]); - expect(models.map((it) => it.toJSON())).to.eql([ + expect(models.map((it) => it.toJSON())).toEqual([ { one: 1, id: 1, name: 'P1', pets: [{ id: 10, name: 'A1', ownerId: 1 }] }, ]); }); @@ -5268,10 +5125,10 @@ describe('QueryBuilder', () => { `select ${raws}"Person"."id" as "id", "Person"."name" as "name", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId" ` + 'from "Person" left join "Animal" as "pets" on "pets"."ownerId" = "Person"."id"'; - expect(sql('*')).to.equal(expected('')); - expect(sql('Person.*', raw('1 as one'))).to.equal(expected('1 as one, ')); - expect(sql(raw('1 as one'), '*')).to.equal(expected('1 as one, ')); - expect(sql('Person.*', 'Person.id')).to.equal( + expect(sql('*')).toBe(expected('')); + expect(sql('Person.*', raw('1 as one'))).toBe(expected('1 as one, ')); + expect(sql(raw('1 as one'), '*')).toBe(expected('1 as one, ')); + expect(sql('Person.*', 'Person.id')).toBe( 'select "Person"."id", "Person"."name" as "name", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId" ' + 'from "Person" left join "Animal" as "pets" on "pets"."ownerId" = "Person"."id"', ); @@ -5283,10 +5140,10 @@ describe('QueryBuilder', () => { `${select} "Person"."id" as "id", "Person"."name" as "name", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId" ` + 'from "Person" left join "Animal" as "pets" on "pets"."ownerId" = "Person"."id"'; - expect(sql(Person.query().distinct('*'))).to.equal(expected('select distinct')); - expect(sql(Person.query().distinct('Person.*'))).to.equal(expected('select distinct')); - expect(sql(Person.query().columns('*'))).to.equal(expected('select')); - expect(sql(Person.query().distinctOn('Person.name').select('*'))).to.equal( + expect(sql(Person.query().distinct('*'))).toBe(expected('select distinct')); + expect(sql(Person.query().distinct('Person.*'))).toBe(expected('select distinct')); + expect(sql(Person.query().columns('*'))).toBe(expected('select')); + expect(sql(Person.query().distinctOn('Person.name').select('*'))).toBe( expected('select distinct on ("Person"."name")'), ); }); @@ -5300,7 +5157,7 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql).to.equal( + expect(sql).toBe( 'select "Person"."id" as "id", "Person"."name" as "name", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId", "pets"."animalCount" as "pets:animalCount" ' + 'from "Person" left join (select (select count(*) from "Animal") as "animalCount", "Animal".* from "Animal") as "pets" on "pets"."ownerId" = "Person"."id"', ); @@ -5316,7 +5173,7 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql).to.equal( + expect(sql).toBe( 'select "Person"."id" as "id", "Person"."name" as "name", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId", "pets"."animalCount" as "pets:animalCount" ' + 'from "Person" left join (select *, (select count(*) from "Animal") as "animalCount" from "Animal") as "pets" on "pets"."ownerId" = "Person"."id"', ); @@ -5329,7 +5186,7 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql).to.equal( + expect(sql).toBe( 'select (select count(*) from "Animal") as "animalCount", "Person"."id" as "id", "Person"."name" as "name", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId" ' + 'from "Person" left join "Animal" as "pets" on "pets"."ownerId" = "Person"."id"', ); @@ -5360,8 +5217,8 @@ describe('QueryBuilder', () => { ), }) .then((models) => { - expect(executedQueries[0]).to.contain(petSelections('upperName', 'siblingCount')); - expect(models.map((it) => it.toJSON())).to.eql([ + expect(executedQueries[0]).toContain(petSelections('upperName', 'siblingCount')); + expect(models.map((it) => it.toJSON())).toEqual([ { id: 1, name: 'P1', @@ -5411,13 +5268,14 @@ describe('QueryBuilder', () => { }); const expectError = (query, selection, alias, column) => - expect(() => query.withGraphJoined('pets').toKnexQuery()).to.throwException((err) => { - expect(err.message).to.equal( - `withGraphJoined: the selection '${selection}' selects '${alias}', ` + + expect(() => query.withGraphJoined('pets').toKnexQuery()).toThrow( + expect.objectContaining({ + message: + `withGraphJoined: the selection '${selection}' selects '${alias}', ` + `the name under which the column '${column}' is selected. ` + 'Select it under a different name.', - ); - }); + }), + ); it('should throw if the user selects other columns under the names of its columns', () => { expectError( @@ -5455,13 +5313,14 @@ describe('QueryBuilder', () => { it('should throw if the user selects raws, subqueries or aggregates under the names of the ids', () => { const expectIdError = (query, name, column) => - expect(() => query.withGraphJoined('pets').toKnexQuery()).to.throwException((err) => { - expect(err.message).to.equal( - `withGraphJoined: a raw, subquery or aggregate selection selects '${name}', ` + + expect(() => query.withGraphJoined('pets').toKnexQuery()).toThrow( + expect.objectContaining({ + message: + `withGraphJoined: a raw, subquery or aggregate selection selects '${name}', ` + `the name under which the id column '${column}' is selected. ` + 'Select it under a different name, or select the id column itself.', - ); - }); + }), + ); expectIdError( Person.query().select('*', raw('?? as ??', ['Person.id', 'id'])), @@ -5475,6 +5334,7 @@ describe('QueryBuilder', () => { 'Person.id', ); expectIdError(Person.query().select('*').max('pets.id as pets:id'), 'pets:id', 'pets.id'); + expectIdError(Person.query().max('pets.id as id'), 'id', 'Person.id'); }); it('should let raws, subqueries and aggregates replace its columns', () => { @@ -5483,16 +5343,16 @@ describe('QueryBuilder', () => { `select ${selection}, "Person"."id" as "id", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId" ` + 'from "Person" left join "Animal" as "pets" on "pets"."ownerId" = "Person"."id"'; - expect( - sql(Person.query().select('*', raw('upper(??) as ??', ['Person.name', 'name']))), - ).to.equal(expected('upper("Person"."name") as "name"')); - expect(sql(Person.query().select('*', raw('upper(??)', 'Person.name').as('name')))).to.equal( + expect(sql(Person.query().select('*', raw('upper(??) as ??', ['Person.name', 'name'])))).toBe( expected('upper("Person"."name") as "name"'), ); - expect(sql(Person.query().select('*', Animal.query().max('name').as('name')))).to.equal( + expect(sql(Person.query().select('*', raw('upper(??)', 'Person.name').as('name')))).toBe( + expected('upper("Person"."name") as "name"'), + ); + expect(sql(Person.query().select('*', Animal.query().max('name').as('name')))).toBe( expected('(select max("name") from "Animal") as "name"'), ); - expect(sql(Person.query().select('*').max('pets.name as name'))).to.equal( + expect(sql(Person.query().select('*').max('pets.name as name'))).toBe( expected('max("pets"."name") as "name"'), ); }); @@ -5524,7 +5384,7 @@ describe('QueryBuilder', () => { .withGraphJoined('pets') .toKnexQuery() .toString(), - ).to.equal( + ).toBe( 'select "person"."first_name", "person"."id" as "id", "person"."owner_id" as "owner_id", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."owner_id" as "pets:owner_id" ' + 'from "person" left join "animal" as "pets" on "pets"."owner_id" = "person"."id"', ); @@ -5538,7 +5398,7 @@ describe('QueryBuilder', () => { .toKnexQuery() .toString(); - expect(sql).to.equal( + expect(sql).toBe( 'select "id", "Person"."name", "pets"."id" as "pets:id", "pets"."name" as "pets:name", "pets"."ownerId" as "pets:ownerId" ' + 'from "Person" left join "Animal" as "pets" on "pets"."ownerId" = "Person"."id"', ); @@ -5570,108 +5430,52 @@ describe('QueryBuilder', () => { const graph = () => ({ name: 'Jennifer', pets: [{ name: 'Doggo' }] }); - // Each case: [method, query with onConflict, same query without onConflict]. const cases = { 'insertGraph().onConflict().ignore()': [ 'insertGraph', () => Person.query().insertGraph(graph()).onConflict('name').ignore(), - () => Person.query().insertGraph(graph()), ], 'insertGraph().onConflict().merge()': [ 'insertGraph', () => Person.query().insertGraph(graph()).onConflict('name').merge(), - () => Person.query().insertGraph(graph()), ], 'insertGraph().onConflict()': [ 'insertGraph', () => Person.query().insertGraph(graph()).onConflict('name'), - () => Person.query().insertGraph(graph()), ], 'insertGraphAndFetch().onConflict().ignore()': [ 'insertGraph', () => Person.query().insertGraphAndFetch(graph()).onConflict('name').ignore(), - () => Person.query().insertGraphAndFetch(graph()), ], 'onConflict().ignore().insertGraph()': [ 'insertGraph', () => Person.query().onConflict('name').ignore().insertGraph(graph()), - () => Person.query().insertGraph(graph()), ], 'relatedQuery().insertGraph().onConflict().ignore()': [ 'insertGraph', () => Person.relatedQuery('pets').for(1).insertGraph(graph()).onConflict('name').ignore(), - () => Person.relatedQuery('pets').for(1).insertGraph(graph()), ], 'upsertGraph().onConflict().merge()': [ 'upsertGraph', () => Person.query().upsertGraph(graph()).onConflict('name').merge(), - () => Person.query().upsertGraph(graph()), ], }; - let warnings; - let originalWarn; - - beforeEach(() => { - resetWarnings(); - warnings = []; - originalWarn = console.warn; - console.warn = (message) => warnings.push(message); - }); - - afterEach(() => { - console.warn = originalWarn; - resetWarnings(); - }); - - async function run(createQuery) { - mockKnexQueryResults = [[{ id: 1 }], [{ id: 2 }]]; - mockKnexQueryResultIndex = 0; - executedQueries = []; - await createQuery(); - return executedQueries; - } - - for (const [title, [method, withOnConflict, withoutOnConflict]] of Object.entries(cases)) { - it(`${title} should warn once and ignore the clause`, async () => { - const expectedQueries = await run(withoutOnConflict); - expect(warnings).to.eql([]); - - expect(await run(withOnConflict)).to.eql(expectedQueries); - expect(await run(withOnConflict)).to.eql(expectedQueries); + for (const [title, [method, createQuery]] of Object.entries(cases)) { + it(`${title} should throw without executing any queries`, async () => { + executedQueries = []; - expect(warnings).to.eql([ - `[objection] onConflict(), ignore() and merge() are not supported by ${method}(). ` + - 'Insert the conflicting rows with a separate insert() query instead. ' + - 'This will throw in objection 4.0.', - ]); + await expect(createQuery()).rejects.toThrow( + `onConflict(), ignore() and merge() are not supported by ${method}(). ` + + 'Insert the conflicting rows with a separate insert() query instead.', + ); + expect(executedQueries).toEqual([]); }); } - - it('insertGraph().onConflict().ignore() should insert the graph without on conflict', async () => { - expect( - await run(() => Person.query().insertGraph(graph()).onConflict('name').ignore()), - ).to.eql([ - 'insert into "Person" ("name") values (\'Jennifer\') returning "id"', - 'insert into "Person" ("name", "ownerId") values (\'Doggo\', 1) returning "id"', - ]); - }); - - it('should warn once per method', async () => { - await run(() => Person.query().insertGraph(graph()).onConflict('name').ignore()); - await run(() => Person.query().upsertGraph(graph()).onConflict('name').merge()); - await run(() => Person.query().insertGraph(graph()).onConflict('name').merge()); - - expect(warnings).to.have.length(2); - expect(warnings[0]).to.contain('insertGraph()'); - expect(warnings[1]).to.contain('upsertGraph()'); - }); }); describe('unknown graph options (#84)', () => { let Person; - let warnings; - let originalWarn; beforeEach(() => { Person = class Person extends Model { @@ -5681,42 +5485,77 @@ describe('QueryBuilder', () => { }; Person.knex(mockKnex); - resetWarnings(); - warnings = []; - originalWarn = console.warn; - console.warn = (message) => warnings.push(message); mockKnexQueryResults = [[{ id: 1 }]]; mockKnexQueryResultIndex = 0; + executedQueries = []; }); - afterEach(() => { - console.warn = originalWarn; - resetWarnings(); - }); - - it('insertGraph() should warn once about an unknown option', async () => { - await Person.query().insertGraph({ name: 'Jennifer' }, { relat: true }); - await Person.query().insertGraph({ name: 'Jennifer' }, { relat: true }); + const validInsertGraphOptions = 'Valid options are: relate, noRelate, noInsert, allowRefs.'; + const validUpsertGraphOptions = + 'Valid options are: noRelate, noUnrelate, noInsert, noUpdate, noDelete, update, relate, ' + + 'unrelate, insertMissing, fetchStrategy, allowRefs, allowMarkers.'; + + it('insertGraph() should throw on an unknown option', async () => { + await expect( + Person.query().insertGraph({ name: 'Jennifer' }, { relat: true }), + ).rejects.toThrow(`"relat" is not an option of insertGraph(). ${validInsertGraphOptions}`); + expect(executedQueries).toEqual([]); + }); + + for (const name of [ + 'noUnrelate', + 'noUpdate', + 'noDelete', + 'update', + 'unrelate', + 'insertMissing', + 'fetchStrategy', + 'allowMarkers', + ]) { + it(`insertGraph() should throw on the upsertGraph() option ${name}`, async () => { + await expect( + Person.query().insertGraphAndFetch({ name: 'Jennifer' }, { [name]: true }), + ).rejects.toThrow( + `"${name}" is not an option of insertGraph(). ${validInsertGraphOptions}`, + ); + expect(executedQueries).toEqual([]); + }); + } - expect(warnings).to.have.length(1); - expect(warnings[0]).to.contain('Unknown graph option "relat" is ignored.'); + it('upsertGraph() should throw on an unknown option', async () => { + await expect( + Person.query().upsertGraph({ name: 'Jennifer' }, { noInset: true }), + ).rejects.toThrow(`"noInset" is not an option of upsertGraph(). ${validUpsertGraphOptions}`); + expect(executedQueries).toEqual([]); }); - it('upsertGraph() should warn about an unknown option', async () => { - await Person.query().upsertGraph({ name: 'Jennifer' }, { noInset: true }); - - expect(warnings).to.have.length(1); - expect(warnings[0]).to.contain('Unknown graph option "noInset" is ignored.'); + it('should ignore options that are undefined', async () => { + await Person.query().insertGraph({ name: 'Jennifer' }, { noDelete: undefined }); + await Person.query().upsertGraph({ name: 'Jennifer' }, { noInset: undefined }); }); - it('should not warn about known options', async () => { - await Person.query().insertGraph({ name: 'Jennifer' }, { relate: true, allowRefs: true }); + it('should accept known options', async () => { + await Person.query().insertGraph( + { name: 'Jennifer' }, + { relate: true, noRelate: true, noInsert: ['pets'], allowRefs: true }, + ); await Person.query().upsertGraph( { name: 'Jennifer' }, - { insertMissing: true, noDelete: true, fetchStrategy: 'OnlyNeeded' }, + { + relate: true, + unrelate: true, + insertMissing: true, + update: true, + noInsert: true, + noUpdate: true, + noDelete: true, + noRelate: true, + noUnrelate: true, + fetchStrategy: 'OnlyNeeded', + allowRefs: true, + allowMarkers: true, + }, ); - - expect(warnings).to.eql([]); }); }); @@ -5726,18 +5565,18 @@ describe('QueryBuilder', () => { builder.context({ a: 1 }); - expect(builder.context()).to.eql({ + expect(builder.context()).toEqual({ a: 1, }); builder.context({ b: 2 }); - expect(builder.context()).to.eql({ + expect(builder.context()).toEqual({ a: 1, b: 2, }); - expect(builder.context().transaction === mockKnex).to.equal(true); + expect(builder.context().transaction === mockKnex).toBe(true); }); it('clearContext() should clear the context', () => { @@ -5745,14 +5584,14 @@ describe('QueryBuilder', () => { builder.context({ a: 1 }); - expect(builder.context()).to.eql({ + expect(builder.context()).toEqual({ a: 1, }); const builder2 = builder.clearContext(); - expect(builder === builder2).to.equal(true); - expect(builder.context()).to.eql({}); + expect(builder === builder2).toBe(true); + expect(builder.context()).toEqual({}); }); it('`context` should merge context', () => { @@ -5762,16 +5601,16 @@ describe('QueryBuilder', () => { builder.context(origContext); builder.context({ b: 2 }); - expect(builder.context()).to.eql({ + expect(builder.context()).toEqual({ a: 1, b: 2, }); - expect(origContext).to.eql({ + expect(origContext).toEqual({ a: 1, }); - expect(builder.context().transaction === mockKnex).to.equal(true); + expect(builder.context().transaction === mockKnex).toBe(true); }); it('`context` can be called without `context` having been called', () => { @@ -5781,16 +5620,16 @@ describe('QueryBuilder', () => { builder.context(origContext); builder.context({ b: 2 }); - expect(builder.context()).to.eql({ + expect(builder.context()).toEqual({ a: 1, b: 2, }); - expect(origContext).to.eql({ + expect(origContext).toEqual({ a: 1, }); - expect(builder.context().transaction === mockKnex).to.equal(true); + expect(builder.context().transaction === mockKnex).toBe(true); }); it('cloning a query builder should clone the context also', () => { @@ -5802,21 +5641,21 @@ describe('QueryBuilder', () => { const builder2 = builder.clone(); builder2.context({ b: 2 }); - expect(builder.context()).to.eql({ + expect(builder.context()).toEqual({ a: 1, }); - expect(builder2.context()).to.eql({ + expect(builder2.context()).toEqual({ a: 1, b: 2, }); - expect(origContext).to.eql({ + expect(origContext).toEqual({ a: 1, }); - expect(builder.context().transaction === mockKnex).to.equal(true); - expect(builder2.context().transaction === mockKnex).to.equal(true); + expect(builder.context().transaction === mockKnex).toBe(true); + expect(builder2.context().transaction === mockKnex).toBe(true); }); it('calling `childQueryOf` should copy a reference of the context', () => { @@ -5828,22 +5667,22 @@ describe('QueryBuilder', () => { const builder2 = TestModel.query().childQueryOf(builder); builder2.context({ b: 2 }); - expect(builder.context()).to.eql({ + expect(builder.context()).toEqual({ a: 1, b: 2, }); - expect(builder2.context()).to.eql({ + expect(builder2.context()).toEqual({ a: 1, b: 2, }); - expect(origContext).to.eql({ + expect(origContext).toEqual({ a: 1, }); - expect(builder.context().transaction === mockKnex).to.equal(true); - expect(builder2.context().transaction === mockKnex).to.equal(true); + expect(builder.context().transaction === mockKnex).toBe(true); + expect(builder2.context().transaction === mockKnex).toBe(true); }); it('calling `childQueryOf(builder, { fork: true })` should copy the context', () => { @@ -5855,21 +5694,21 @@ describe('QueryBuilder', () => { const builder2 = TestModel.query().childQueryOf(builder, { fork: true }); builder2.context({ b: 2 }); - expect(builder.context()).to.eql({ + expect(builder.context()).toEqual({ a: 1, }); - expect(builder2.context()).to.eql({ + expect(builder2.context()).toEqual({ a: 1, b: 2, }); - expect(origContext).to.eql({ + expect(origContext).toEqual({ a: 1, }); - expect(builder.context().transaction === mockKnex).to.equal(true); - expect(builder2.context().transaction === mockKnex).to.equal(true); + expect(builder.context().transaction === mockKnex).toBe(true); + expect(builder2.context().transaction === mockKnex).toBe(true); }); it('values saved to context in hooks should be available later', () => { @@ -5888,7 +5727,7 @@ describe('QueryBuilder', () => { return TestModel.query() .patch({ a: 1 }) .then(() => { - expect(foo).to.equal(100); + expect(foo).toBe(100); }); }); }); @@ -5905,11 +5744,11 @@ describe('QueryBuilder', () => { const models = [{ a: 1 }, { a: 2 }, { a: 3 }].map((it) => TestModel.fromJson(it)); const result = await TestModel.query().insert(models).onConflict('a').ignore(); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'insert into "Model" ("a") values (1), (2), (3) on conflict ("a") do nothing returning "id", "a"', ]); - expect(result).to.eql(models); - expect(models.map((it) => it.id)).to.eql([11, undefined, 13]); + expect(result).toEqual(models); + expect(models.map((it) => it.id)).toEqual([11, undefined, 13]); }); it('should match the returned rows by the conflict columns regardless of their order', async () => { @@ -5923,7 +5762,7 @@ describe('QueryBuilder', () => { const models = [{ a: 1 }, { a: 2 }, { a: 3 }].map((it) => TestModel.fromJson(it)); await TestModel.query().insert(models).onConflict(['a']).ignore(); - expect(models.map((it) => it.id)).to.eql([11, undefined, 13]); + expect(models.map((it) => it.id)).toEqual([11, undefined, 13]); }); it('should match the returned rows by the id if no conflict columns are given', async () => { @@ -5932,8 +5771,8 @@ describe('QueryBuilder', () => { const models = [{ id: 1 }, { id: 2 }].map((it) => TestModel.fromJson(it)); await TestModel.query().insert(models).onConflict().ignore().returning('*'); - expect(models[0].b).to.equal(undefined); - expect(models[1].b).to.equal('db2'); + expect(models[0].b).toBeUndefined(); + expect(models[1].b).toBe('db2'); }); it('should match duplicate keys in insertion order', async () => { @@ -5945,8 +5784,8 @@ describe('QueryBuilder', () => { ].map((it) => TestModel.fromJson(it)); await TestModel.query().insert(models).onConflict('a').ignore().returning('*'); - expect(models.map((it) => it.id)).to.eql([11, undefined]); - expect(models.map((it) => it.b)).to.eql(['first', 'second']); + expect(models.map((it) => it.id)).toEqual([11, undefined]); + expect(models.map((it) => it.b)).toEqual(['first', 'second']); }); it('should leave the model untouched if the only row is ignored', async () => { @@ -5955,8 +5794,8 @@ describe('QueryBuilder', () => { const model = TestModel.fromJson({ a: 1 }); const result = await TestModel.query().insert(model).onConflict('a').ignore(); - expect(result).to.be(model); - expect(model.id).to.equal(undefined); + expect(result).toBe(model); + expect(model.id).toBeUndefined(); }); it('should not crash with object properties in the jsonSchema', async () => { @@ -5980,8 +5819,8 @@ describe('QueryBuilder', () => { .ignore() .returning('*'); - expect(result.map((it) => it.id)).to.eql([undefined, 12]); - expect(result.map((it) => it.obj)).to.eql([{ x: 1 }, { x: 2 }]); + expect(result.map((it) => it.id)).toEqual([undefined, 12]); + expect(result.map((it) => it.obj)).toEqual([{ x: 1 }, { x: 2 }]); }); it('should throw if the returned rows cannot be matched to the models', async () => { @@ -5993,8 +5832,8 @@ describe('QueryBuilder', () => { .ignore() .catch((err) => err); - expect(err).to.be.an(Error); - expect(err.message).to.match(/^Could not match the rows returned by an insert/); + expect(err).toBeInstanceOf(Error); + expect(err.message).toMatch(/^Could not match the rows returned by an insert/); }); it('should merge the returned rows by position if all rows are returned', async () => { @@ -6010,7 +5849,7 @@ describe('QueryBuilder', () => { .onConflict('a') .merge(); - expect(result.map((it) => it.id)).to.eql([11, 12]); + expect(result.map((it) => it.id)).toEqual([11, 12]); }); it('should not change the returning clause of inserts without onConflict()', async () => { @@ -6018,8 +5857,8 @@ describe('QueryBuilder', () => { const result = await TestModel.query().insert([{ a: 1 }, { a: 2 }]); - expect(executedQueries).to.eql(['insert into "Model" ("a") values (1), (2) returning "id"']); - expect(result.map((it) => it.id)).to.eql([11, 12]); + expect(executedQueries).toEqual(['insert into "Model" ("a") values (1), (2) returning "id"']); + expect(result.map((it) => it.id)).toEqual([11, 12]); }); it('insertAndFetch() should only fetch the models that have an id', async () => { @@ -6030,12 +5869,12 @@ describe('QueryBuilder', () => { .onConflict('a') .ignore(); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'insert into "Model" ("a") values (1), (3) on conflict ("a") do nothing returning "id", "a"', 'select "Model".* from "Model" where "Model"."id" in (13)', ]); - expect(result.map((it) => it.id)).to.eql([undefined, 13]); - expect(result.map((it) => it.b)).to.eql([undefined, 'fetched']); + expect(result.map((it) => it.id)).toEqual([undefined, 13]); + expect(result.map((it) => it.b)).toEqual([undefined, 'fetched']); }); it('insertAndFetch() should not fetch anything if no model has an id', async () => { @@ -6043,9 +5882,9 @@ describe('QueryBuilder', () => { const result = await TestModel.query().insertAndFetch({ a: 1 }).onConflict('a').ignore(); - expect(executedQueries).to.have.length(1); - expect(result.a).to.equal(1); - expect(result.id).to.equal(undefined); + expect(executedQueries).toHaveLength(1); + expect(result.a).toBe(1); + expect(result.id).toBeUndefined(); }); }); @@ -6134,7 +5973,7 @@ describe('QueryBuilder', () => { }); function testToFindQuery(query, sql) { - expect(query.toFindQuery().toKnexQuery().toSQL().sql).to.equal(sql); + expect(query.toFindQuery().toKnexQuery().toSQL().sql).toBe(sql); } }); @@ -6181,16 +6020,16 @@ describe('QueryBuilder', () => { .toString(); it('should throw for aliases over the postgres limit, suggesting `minimize`', () => { - expect(() => build('pg')).to.throwException((err) => { - expect(err.message).to.equal( - `identifier pets:${longColumn} is over 63 characters long and would be truncated by the database engine. Use the \`minimize\` option of withGraphJoined() to shorten the aliases.`, - ); - }); + expect(() => build('pg')).toThrow( + expect.objectContaining({ + message: `identifier pets:${longColumn} is over 63 characters long and would be truncated by the database engine. Use the \`minimize\` option of withGraphJoined() to shorten the aliases.`, + }), + ); }); it('should use the limit of the database', () => { - expect(build('mysql')).to.contain(`pets:${longColumn}`); - expect(build('mssql')).to.contain(`pets:${longColumn}`); + expect(build('mysql')).toContain(`pets:${longColumn}`); + expect(build('mssql')).toContain(`pets:${longColumn}`); }); it('should still throw over the mssql limit', () => { @@ -6198,7 +6037,7 @@ describe('QueryBuilder', () => { columns: ['id', 'ownerId', 'c'.repeat(130)], }); - expect(() => build('mssql')).to.throwException(/is over 128 characters long/); + expect(() => build('mssql')).toThrow(/is over 128 characters long/); }); }); @@ -6225,7 +6064,7 @@ describe('QueryBuilder', () => { thrown = err; } - expect(thrown).to.be.a(ErrorClass); + expect(thrown).toBeInstanceOf(ErrorClass); return thrown; }; @@ -6238,10 +6077,10 @@ describe('QueryBuilder', () => { objection.UniqueViolationError, ); - expect(err.client).to.equal('mssql'); - expect(err.table).to.equal('user'); - expect(err.schema).to.equal('dbo'); - expect(err.constraint).to.equal('user_pkey'); + expect(err.client).toBe('mssql'); + expect(err.table).toBe('user'); + expect(err.schema).toBe('dbo'); + expect(err.constraint).toBe('user_pkey'); }); it('should still throw a UniqueViolationError for unique key violations', async () => { @@ -6253,7 +6092,7 @@ describe('QueryBuilder', () => { objection.UniqueViolationError, ); - expect(err.constraint).to.equal('user_email_unique'); + expect(err.constraint).toBe('user_email_unique'); }); it('should throw a ForeignKeyViolationError for foreign key violations', async () => { @@ -6266,8 +6105,8 @@ describe('QueryBuilder', () => { objection.ForeignKeyViolationError, ); - expect(err.table).to.equal('pet'); - expect(err.constraint).to.equal('pet_ownerid_foreign'); + expect(err.table).toBe('pet'); + expect(err.constraint).toBe('pet_ownerid_foreign'); }); it('should throw a NotNullViolationError for not null violations', async () => { @@ -6280,7 +6119,7 @@ describe('QueryBuilder', () => { objection.NotNullViolationError, ); - expect(err.column).to.equal('name'); + expect(err.column).toBe('name'); }); it('should keep the error of tedious as the native error', async () => { @@ -6290,8 +6129,8 @@ describe('QueryBuilder', () => { ); const err = await expectError(error, objection.UniqueViolationError); - expect(err.nativeError).to.equal(error); - expect(err.message).to.equal(error.message); + expect(err.nativeError).toBe(error); + expect(err.message).toBe(error.message); }); it('should throw a DBError for other errors', async () => { @@ -6300,7 +6139,7 @@ describe('QueryBuilder', () => { objection.DBError, ); - expect(err).to.not.be.a(objection.UniqueViolationError); + expect(err).not.toBeInstanceOf(objection.UniqueViolationError); }); it('should throw a DBError with the messages of several errors', async () => { @@ -6312,8 +6151,8 @@ describe('QueryBuilder', () => { error.message = 'select * - '; const err = await expectError(error, objection.DBError); - expect(err.nativeError).to.equal(error); - expect(err.message).to.equal( + expect(err.nativeError).toBe(error); + expect(err.message).toBe( "select * - Incorrect syntax near '@p0'. Invalid usage of the option next in the FETCH statement.", ); }); @@ -6328,7 +6167,7 @@ describe('QueryBuilder', () => { thrown = err; } - expect(thrown).to.equal(error); + expect(thrown).toBe(error); }); }); }); diff --git a/tests/unit/queryBuilder/ReferenceBuilder.js b/tests/unit/queryBuilder/ReferenceBuilder.js index d8f164f95..7d68ce5de 100644 --- a/tests/unit/queryBuilder/ReferenceBuilder.js +++ b/tests/unit/queryBuilder/ReferenceBuilder.js @@ -1,84 +1,86 @@ -const Knex = require('knex'); -const expect = require('expect.js'); -const { ref, Model } = require('../../../'); -const { ReferenceBuilder } = require('../../../lib/queryBuilder/ReferenceBuilder'); +import { describe, it, expect } from 'vitest'; +import Knex from 'knex'; +import { ref, Model } from 'objection'; +import { ReferenceBuilder } from '../../../lib/queryBuilder/ReferenceBuilder.js'; + +const knex = Knex({ client: 'pg' }); function toRawArgs(ref) { - return ref._createRawArgs(Model.query()); + return ref._createRawArgs(Model.query(knex)); } describe('ReferenceBuilder', () => { it('fail if reference cannot be parsed', () => { expect(() => { ref(); - }).to.throwException(); + }).toThrow(); expect(() => { ref(''); - }).to.throwException(); + }).toThrow(); }); it('should create ReferenceBuilder', () => { let reference = ref('Awwww.ItWorks'); - expect(reference instanceof ReferenceBuilder).to.be.ok(); - expect(toRawArgs(reference)).to.eql(['??', ['Awwww.ItWorks']]); + expect(reference).toBeInstanceOf(ReferenceBuilder); + expect(toRawArgs(reference)).toEqual(['??', ['Awwww.ItWorks']]); }); it('table method should replace table', () => { let reference = ref('Table.Column').table('Foo'); - expect(toRawArgs(reference)).to.eql(['??', ['Foo.Column']]); + expect(toRawArgs(reference)).toEqual(['??', ['Foo.Column']]); }); it('should allow plain knex reference + casting', () => { let reference = ref('Table.Column').castBigInt(); - expect(toRawArgs(reference)).to.eql(['CAST(?? AS bigint)', ['Table.Column']]); + expect(toRawArgs(reference)).toEqual(['CAST(?? AS bigint)', ['Table.Column']]); }); it('should allow field expression + casting', () => { let reference = ref('Table.Column:jsonAttr').castBool(); - expect(toRawArgs(reference)).to.eql(["CAST(??#>>'{jsonAttr}' AS boolean)", ['Table.Column']]); + expect(toRawArgs(reference)).toEqual(["CAST(??#>>'{jsonAttr}' AS boolean)", ['Table.Column']]); }); it('should allow field expression + no casting', () => { let reference = ref('Table.Column:jsonAttr'); - expect(toRawArgs(reference)).to.eql(["??#>'{jsonAttr}'", ['Table.Column']]); + expect(toRawArgs(reference)).toEqual(["??#>'{jsonAttr}'", ['Table.Column']]); }); it('should quote empty keys in field expressions', () => { - expect(toRawArgs(ref('Table.Column:[""]'))).to.eql([`??#>'{""}'`, ['Table.Column']]); - expect(toRawArgs(ref("Table.Column:a[''].b").castText())).to.eql([ + expect(toRawArgs(ref('Table.Column:[""]'))).toEqual([`??#>'{""}'`, ['Table.Column']]); + expect(toRawArgs(ref("Table.Column:a[''].b").castText())).toEqual([ `CAST(??#>>'{a,"",b}' AS text)`, ['Table.Column'], ]); }); it('should quote keys with special characters in field expressions', () => { - expect(toRawArgs(ref('Table.Column:["a,b"][{c}]'))).to.eql([ + expect(toRawArgs(ref('Table.Column:["a,b"][{c}]'))).toEqual([ `??#>'{"a,b","{c}"}'`, ['Table.Column'], ]); - expect(toRawArgs(ref("Table.Column:['a\\\"b']"))).to.eql([ + expect(toRawArgs(ref("Table.Column:['a\\\"b']"))).toEqual([ `??#>'{"a\\\\\\"b"}'`, ['Table.Column'], ]); }); it('should escape single quotes and question marks in json path keys', () => { - expect(toRawArgs(ref("Table.Column:x') or 1=1 --"))).to.eql([ + expect(toRawArgs(ref("Table.Column:x') or 1=1 --"))).toEqual([ `??#>'{"x'') or 1=1 --"}'`, ['Table.Column'], ]); - expect(toRawArgs(ref('Table.Column:a?b').castText().as('x'))).to.eql([ + expect(toRawArgs(ref('Table.Column:a?b').castText().as('x'))).toEqual([ `CAST(??#>>'{a\\?b}' AS text) as ??`, ['Table.Column', 'x'], ]); // Backslashes are doubled inside the quoted text array element. - expect(toRawArgs(ref(`Table.Column:["a\\'?b"]`))).to.eql([ + expect(toRawArgs(ref(`Table.Column:["a\\'?b"]`))).toEqual([ `??#>'{"a\\\\''\\?b"}'`, ['Table.Column'], ]); // knex drops backslashes in front of `?`, so they are written as octal // escapes in an escape string literal. - expect(toRawArgs(ref(`Table.Column:["a\\?'b"]`))).to.eql([ + expect(toRawArgs(ref(`Table.Column:["a\\?'b"]`))).toEqual([ `??#>E'{"a\\134\\134\\?''b"}'`, ['Table.Column'], ]); @@ -95,7 +97,7 @@ describe('ReferenceBuilder', () => { .toKnexQuery() .toSQL() .toNative(); - expect(sql).to.contain(`"Table"."Column"#>E'{"a\\134\\134?b"}'`); + expect(sql).toContain(`"Table"."Column"#>E'{"a\\134\\134?b"}'`); }); it('should keep bindings in order when used as a column in whereIn', () => { @@ -109,39 +111,148 @@ describe('ReferenceBuilder', () => { .toKnexQuery() .toSQL() .toNative(); - expect(sql).to.contain(`CAST("Table"."Column"#>>'{a?b}' AS integer) in ($1)`); - expect(bindings).to.eql([1]); + expect(sql).toContain(`CAST("Table"."Column"#>>'{a?b}' AS integer) in ($1)`); + expect(bindings).toEqual([1]); }); it('should support few different casts', () => { - expect(toRawArgs(ref('Table.Column:jsonAttr').castText())).to.eql([ + expect(toRawArgs(ref('Table.Column:jsonAttr').castText())).toEqual([ "CAST(??#>>'{jsonAttr}' AS text)", ['Table.Column'], ]); - expect(toRawArgs(ref('Table.Column:jsonAttr').castInt())).to.eql([ + expect(toRawArgs(ref('Table.Column:jsonAttr').castInt())).toEqual([ "CAST(??#>>'{jsonAttr}' AS integer)", ['Table.Column'], ]); - expect(toRawArgs(ref('Table.Column:jsonAttr').castBigInt())).to.eql([ + expect(toRawArgs(ref('Table.Column:jsonAttr').castBigInt())).toEqual([ "CAST(??#>>'{jsonAttr}' AS bigint)", ['Table.Column'], ]); - expect(toRawArgs(ref('Table.Column:jsonAttr').castFloat())).to.eql([ + expect(toRawArgs(ref('Table.Column:jsonAttr').castFloat())).toEqual([ "CAST(??#>>'{jsonAttr}' AS float)", ['Table.Column'], ]); - expect(toRawArgs(ref('Table.Column:jsonAttr').castDecimal())).to.eql([ + expect(toRawArgs(ref('Table.Column:jsonAttr').castDecimal())).toEqual([ "CAST(??#>>'{jsonAttr}' AS decimal)", ['Table.Column'], ]); - expect(toRawArgs(ref('Table.Column:jsonAttr').castReal())).to.eql([ + expect(toRawArgs(ref('Table.Column:jsonAttr').castReal())).toEqual([ "CAST(??#>>'{jsonAttr}' AS real)", ['Table.Column'], ]); - expect(toRawArgs(ref('Table.Column:jsonAttr').castBool())).to.eql([ + expect(toRawArgs(ref('Table.Column:jsonAttr').castBool())).toEqual([ "CAST(??#>>'{jsonAttr}' AS boolean)", ['Table.Column'], ]); - expect(toRawArgs(ref('Table.Column').castJson())).to.eql(['to_jsonb(??)', ['Table.Column']]); + expect(toRawArgs(ref('Table.Column').castJson())).toEqual(['to_jsonb(??)', ['Table.Column']]); + }); + + describe('on MySQL and SQLite', () => { + class TestModel extends Model { + static get tableName() { + return 'Table'; + } + } + + const toSql = (client, builder) => + builder(TestModel.query(Knex({ client, useNullAsDefault: true }))) + .toKnexQuery() + .toString(); + + it('should extract json values', () => { + const builder = (query) => + query.select(ref('Table.Column:a.b[0]').as('x')).where(ref('Column:[""]'), 1); + expect(toSql('mysql', builder)).toBe( + 'select JSON_EXTRACT(`Table`.`Column`, \'$."a"."b"[0]\') as `x` from `Table` ' + + 'where JSON_EXTRACT(`Column`, \'$.""\') = 1', + ); + expect(toSql('sqlite3', builder)).toBe( + 'select json_extract(`Table`.`Column`, \'$."a"."b"[0]\') as `x` from `Table` ' + + 'where json_extract(`Column`, \'$.""\') = 1', + ); + }); + + it('should cast to the types of the database', () => { + const builder = (query) => + query.select( + ref('Column:a').castText(), + ref('Column:a').castInt(), + ref('Column:a').castBigInt(), + ref('Column:a').castFloat(), + ref('Column:a').castReal(), + ref('Column:a').castDecimal(), + ref('Column:a').castBool(), + ref('Column:a').castTo('date'), + ref('Column:a').castTo('char(10)'), + ref('Column').castInt(), + ref('Column').castText().castJson(), + ref('Column:a').castJson(), + ); + // MySQL casts json null to 0 or 'null', so it's turned into NULL first. + const value = "NULLIF(JSON_EXTRACT(`Column`, '$.\"a\"'), CAST('null' AS JSON))"; + expect(toSql('mysql', builder)).toBe( + `select CAST(JSON_UNQUOTE(${value}) AS CHAR), ` + + `CAST(${value} AS SIGNED), ` + + `CAST(${value} AS SIGNED), ` + + `CAST(${value} AS DOUBLE), ` + + `CAST(${value} AS FLOAT), ` + + `CAST(${value} AS DECIMAL(65,30)), ` + + `CAST(${value} AS SIGNED), ` + + `CAST(${value} AS date), ` + + `CAST(JSON_UNQUOTE(${value}) AS char(10)), ` + + 'CAST(`Column` AS SIGNED), ' + + "JSON_EXTRACT(JSON_ARRAY(CAST(`Column` AS CHAR)), '$[0]'), " + + "JSON_EXTRACT(JSON_ARRAY(JSON_EXTRACT(`Column`, '$.\"a\"')), '$[0]') from `Table`", + ); + expect(toSql('sqlite3', builder)).toBe( + 'select CAST(json_extract(`Column`, \'$."a"\') AS text), ' + + 'CAST(json_extract(`Column`, \'$."a"\') AS integer), ' + + 'CAST(json_extract(`Column`, \'$."a"\') AS bigint), ' + + 'CAST(json_extract(`Column`, \'$."a"\') AS float), ' + + 'CAST(json_extract(`Column`, \'$."a"\') AS real), ' + + 'CAST(json_extract(`Column`, \'$."a"\') AS decimal), ' + + 'CAST(json_extract(`Column`, \'$."a"\') AS boolean), ' + + 'CAST(json_extract(`Column`, \'$."a"\') AS date), ' + + 'CAST(json_extract(`Column`, \'$."a"\') AS char(10)), ' + + 'CAST(`Column` AS integer), ' + + 'json_quote(CAST(`Column` AS text)), ' + + 'json_quote(json_extract(`Column`, \'$."a"\')) from `Table`', + ); + }); + + it('should convert whole columns to json except on SQLite', () => { + const builder = (query) => query.select(ref('Column').castJson()); + expect(toSql('mysql', builder)).toBe( + "select JSON_EXTRACT(JSON_ARRAY(`Column`), '$[0]') from `Table`", + ); + expect(() => toSql('sqlite3', builder)).toThrow( + "ref().castJson() needs a json path or a cast on SQLite, which can't tell json columns " + + 'from text columns.', + ); + }); + + it('should escape keys in json paths', () => { + const builder = (query) => + query.select( + ref(`Column:["x') or 1=1 --"]`), + ref('Column:["a?b"]'), + ref("Column:['a\\\"b']"), + ); + // MySQL writes paths with `?` or backslashes as hex literals. + expect(toSql('mysql', builder)).toBe( + "select JSON_EXTRACT(`Column`, '$.\"x'') or 1=1 --\"'), " + + `JSON_EXTRACT(\`Column\`, _utf8mb4 X'${hex('$."a?b"')}'), ` + + `JSON_EXTRACT(\`Column\`, _utf8mb4 X'${hex('$."a\\\\\\"b"')}') from \`Table\``, + ); + expect(toSql('sqlite3', builder)).toBe( + "select json_extract(`Column`, '$.\"x'') or 1=1 --\"'), " + + 'json_extract(`Column`, \'$."a\\u003fb"\'), ' + + 'json_extract(`Column`, \'$."a\\\\\\"b"\') from `Table`', + ); + }); }); }); + +function hex(string) { + return Buffer.from(string).toString('hex'); +} diff --git a/tests/unit/queryBuilder/RelationExpression.js b/tests/unit/queryBuilder/RelationExpression.js index 8570725dd..af0dfb8ba 100644 --- a/tests/unit/queryBuilder/RelationExpression.js +++ b/tests/unit/queryBuilder/RelationExpression.js @@ -1,10 +1,6 @@ -const chai = require('chai'); -const chaiSubset = require('chai-subset'); -const expect = require('expect.js'); -const { RelationExpression } = require('../../../'); -const { resetWarnings } = require('../../../lib/utils/warnings'); - -chai.use(chaiSubset); +import { describe, it, expect } from 'vitest'; +import { RelationExpression } from 'objection'; +import { Model } from 'objection'; describe('RelationExpression', () => { describe('parse', () => { @@ -882,9 +878,9 @@ describe('RelationExpression', () => { testParseFail('a asb'); testParseFail('aas b'); testParseFail('a asd b'); - // Throws in objection 4.0, see ditojs/objection#53. - // testParseFail('[a.b, a.c]'); - // testParseFail('a.[b.c, b.d]'); + // Duplicate relations + testParseFail('[a.b, a.c]'); + testParseFail('a.[b.c, b.d]'); }); it('should accept single function or modifier name in $modifiers', () => { @@ -1155,25 +1151,71 @@ describe('RelationExpression', () => { }); describe('duplicate relations', () => { - it('should warn once that only the last one is used', () => { - const warnings = []; - const originalWarn = console.warn; - console.warn = (message) => warnings.push(message); - resetWarnings(); + for (const [expression, relationName] of [ + ['[movies.reviews, movies.actors]', 'movies'], + ['[movies, movies.reviews]', 'movies'], + ['[movies, movies]', 'movies'], + ['movies.[reviews.author, reviews.movie]', 'reviews'], + ['[m as movies, movies]', 'movies'], + ]) { + it(`should throw for '${expression}'`, () => { + expect(() => RelationExpression.create(expression)).toThrow( + expect.objectContaining({ + message: + `Duplicate relation name "${relationName}". For example, use ` + + `"${relationName}.[foo, bar]" instead of "[${relationName}.foo, ${relationName}.bar]".`, + }), + ); + }); + } - try { - RelationExpression.create('[movies.reviews, movies.actors]'); - RelationExpression.create('[movies.actors, movies.director]'); - } finally { - console.warn = originalWarn; - resetWarnings(); - } + it('should not throw for the same relation under different aliases', () => { + expect(RelationExpression.create('[movies, movies as m]').toString()).toBe( + '[movies, movies as m]', + ); + }); + }); - expect(warnings).to.eql([ - '[objection] Duplicate relation "movies" in a relation expression, only the last one ' + - 'is used. Use "a.[b, c]" instead of "[a.b, a.c]". This will throw an error in ' + - 'objection 4.0.', - ]); + describe('misplaced * and ^', () => { + for (const [expression, token] of [ + ['a.^.b', '^'], + ['a.^3.b', '^3'], + ['a.^.[b, c]', '^'], + ['*.a', '*'], + ['*.[a, b]', '*'], + ['a.*.b', '*'], + ['a.[b.*.c, d]', '*'], + ['a.^(f)', '^'], + ['a.* as b', '*'], + ['* as a', '*'], + ['[a, *]', '*'], + ['a.[b, ^2]', '^2'], + ['^', '^'], + ]) { + it(`should throw for '${expression}'`, () => { + expect(() => RelationExpression.create(expression)).toThrow( + expect.objectContaining({ + message: `"${token}" is only supported at the end of a relation path.`, + }), + ); + }); + } + + for (const expression of ['a.^', 'a.^3', 'a.*', '[a.^3, b.*]', 'a.[b.*, c.^2]']) { + it(`should parse '${expression}'`, () => { + expect(RelationExpression.create(expression).toString()).toBe(expression); + }); + } + + it("should parse '*'", () => { + expect(RelationExpression.create('*').node.$allRecursive).toBe(true); + }); + + it('should parse an alias named like a token as a relation', () => { + expect(RelationExpression.create('a.b as ^').node.a['^']).toContainSubset({ + $relation: 'b', + $recursive: false, + }); }); }); @@ -1184,28 +1226,26 @@ describe('RelationExpression', () => { console.warn = (message) => warnings.push(message); try { - expect(RelationExpression.create('constructor').toString()).to.equal('constructor'); - expect(RelationExpression.create('[constructor, toString]').toString()).to.equal( + expect(RelationExpression.create('constructor').toString()).toBe('constructor'); + expect(RelationExpression.create('[constructor, toString]').toString()).toBe( '[constructor, toString]', ); } finally { console.warn = originalWarn; } - expect(warnings).to.eql([]); + expect(warnings).toEqual([]); }); it("shouldn't be returned as child expressions", () => { const expression = RelationExpression.create('a'); - expect(expression.childExpression('constructor')).to.equal(null); - expect(expression.childExpression('toString')).to.equal(null); - expect(expression.childExpression('a')).to.be.a(RelationExpression); + expect(expression.childExpression('constructor')).toBeNull(); + expect(expression.childExpression('toString')).toBeNull(); + expect(expression.childExpression('a')).toBeInstanceOf(RelationExpression); }); it("shouldn't be mistaken for relations of model graphs", () => { - const { Model } = require('../../../'); - class Item extends Model { static get tableName() { return 'Item'; @@ -1227,7 +1267,7 @@ describe('RelationExpression', () => { const item = Item.fromJson({ id: 1 }); item.toString = [child]; - expect(RelationExpression.fromModelGraph(item).toString()).to.equal('toString.toString'); + expect(RelationExpression.fromModelGraph(item).toString()).toBe('toString.toString'); }); }); @@ -1348,7 +1388,6 @@ describe('RelationExpression', () => { testNotSubExpression('[a.^, b.[c.^, d]]', '[c, b]'); testNotSubExpression('[a.^, b.[c.^, d]]', '[c, b]'); testNotSubExpression('[a.^, b.[c.^, d]]', 'b.c.d'); - testNotSubExpression('a.^', 'a.[b, ^, c]'); testNotSubExpression('a.^3', 'a.^'); testNotSubExpression('a.^3', 'a.^4'); testNotSubExpression('a.^3', 'a.a.a.a'); @@ -1378,7 +1417,7 @@ describe('RelationExpression', () => { items.push({ exprName: expr.node.$name, relation }); }); - expect(items).to.eql([ + expect(items).toEqual([ { exprName: 'a', relation: 'aa' }, { exprName: 'b', relation: 'bb' }, { exprName: 'd', relation: 'dd' }, @@ -1411,7 +1450,7 @@ describe('RelationExpression', () => { }); }); - expect(items).to.eql([ + expect(items).toEqual([ { exprName: 'a', relation: 'aa' }, { exprName: 'a', relation: 'aa' }, { exprName: 'a', relation: 'aa' }, @@ -1444,7 +1483,7 @@ describe('RelationExpression', () => { }); }); - expect(items).to.eql([ + expect(items).toEqual([ { exprName: 'a', relation: 'aa' }, { exprName: 'a', relation: 'aa' }, ]); @@ -1482,7 +1521,7 @@ describe('RelationExpression', () => { }); }); - expect(items).to.eql([ + expect(items).toEqual([ { exprName: 'a', relation: 'aa' }, { exprName: 'b', relation: 'bb' }, { exprName: 'c', relation: 'cc' }, @@ -1492,61 +1531,59 @@ describe('RelationExpression', () => { }); function testParse(str, parsed) { - chai.expect(RelationExpression.create(str).node).to.containSubset(parsed); + expect(RelationExpression.create(str).node).toContainSubset(parsed); } function testClone(expr, cloned) { - chai.expect(RelationExpression.create(expr).clone().node).to.containSubset(cloned); + expect(RelationExpression.create(expr).clone().node).toContainSubset(cloned); } function testMerge(str1, str2, parsed) { it(str1 + ' + ' + str2 + ' --> ' + parsed, () => { - expect(RelationExpression.create(str1).merge(str2).toString()).to.equal(parsed); + expect(RelationExpression.create(str1).merge(str2).toString()).toBe(parsed); expect( RelationExpression.create(str1).merge(RelationExpression.create(str2)).toString(), - ).to.equal(parsed); + ).toBe(parsed); }); } function testPath(str, path, expected) { - chai - .expect( - RelationExpression.create(str) - .expressionsAtPath(path) - .map((it) => it.node), - ) - .to.containSubset(expected); + expect( + RelationExpression.create(str) + .expressionsAtPath(path) + .map((it) => it.node), + ).toContainSubset(expected); } function testToString(str) { it(str, () => { - expect(RelationExpression.create(str).toString()).to.equal(str); + expect(RelationExpression.create(str).toString()).toBe(str); }); } function testToJSON(str, expectedJson) { it(str, () => { const json = RelationExpression.create(str).toJSON(); - expect(json).to.eql(expectedJson); - expect(RelationExpression.create(json).toString()).to.eql(str); + expect(json).toEqual(expectedJson); + expect(RelationExpression.create(json).toString()).toEqual(str); }); } function testParseFail(str) { expect(() => { RelationExpression.create(str); - }).to.throwException(); + }).toThrow(); } function testSubExpression(str, subStr) { it('"' + subStr + '" is a sub expression of "' + str + '"', () => { - expect(RelationExpression.create(str).isSubExpression(subStr)).to.equal(true); + expect(RelationExpression.create(str).isSubExpression(subStr)).toBe(true); }); } function testNotSubExpression(str, subStr) { it('"' + subStr + '" is not a sub expression of "' + str + '"', () => { - expect(RelationExpression.create(str).isSubExpression(subStr)).to.equal(false); + expect(RelationExpression.create(str).isSubExpression(subStr)).toBe(false); }); } }); diff --git a/tests/unit/queryBuilder/ValueBuilder.js b/tests/unit/queryBuilder/ValueBuilder.js index 2e98a6cbf..d696b4222 100644 --- a/tests/unit/queryBuilder/ValueBuilder.js +++ b/tests/unit/queryBuilder/ValueBuilder.js @@ -1,40 +1,72 @@ -const expect = require('expect.js'); -const { val, Model } = require('../../../'); -const { ValueBuilder } = require('../../../lib/queryBuilder/ValueBuilder'); +import { describe, it, expect } from 'vitest'; +import Knex from 'knex'; +import { val, Model } from 'objection'; +import { ValueBuilder } from '../../../lib/queryBuilder/ValueBuilder.js'; + +const knex = Knex({ client: 'pg' }); function toRawArgs(ref) { - return ref._createRawArgs(Model.query()); + return ref._createRawArgs(Model.query(knex)); } describe('ValueBuilder', () => { it('should create ValueBuilder', () => { let builder = val('Awwww.ItWorks'); - expect(builder instanceof ValueBuilder).to.be.ok(); - expect(toRawArgs(builder)).to.eql(['?', ['Awwww.ItWorks']]); + expect(builder).toBeInstanceOf(ValueBuilder); + expect(toRawArgs(builder)).toEqual(['?', ['Awwww.ItWorks']]); }); it('should allow casting', () => { let builder = val('100').castBigInt(); - expect(toRawArgs(builder)).to.eql(['CAST(? AS bigint)', ['100']]); + expect(toRawArgs(builder)).toEqual(['CAST(? AS bigint)', ['100']]); }); it('should stringify when casting to json', () => { let builder = val({ value: 100 }).castJson(); - expect(toRawArgs(builder)).to.eql(['CAST(? AS jsonb)', ['{"value":100}']]); + expect(toRawArgs(builder)).toEqual(['CAST(? AS jsonb)', ['{"value":100}']]); }); it('should expand arrays', () => { let builder = val([1, 2, 3]).asArray(); - expect(toRawArgs(builder)).to.eql(['ARRAY[?, ?, ?]', [1, 2, 3]]); + expect(toRawArgs(builder)).toEqual(['ARRAY[?, ?, ?]', [1, 2, 3]]); + }); + + it('should expand undefined to an array with one value', () => { + let builder = val(undefined).asArray(); + expect(toRawArgs(builder)).toEqual(['ARRAY[?]', [undefined]]); }); it('should support aliasing', () => { let builder = val('Hello').as('greeting'); - expect(toRawArgs(builder)).to.eql(['? as ??', ['Hello', 'greeting']]); + expect(toRawArgs(builder)).toEqual(['? as ??', ['Hello', 'greeting']]); }); it('should support simultaneous casting and aliasing', () => { let builder = val('1').castBigInt().as('total'); - expect(toRawArgs(builder)).to.eql(['CAST(? AS bigint) as ??', ['1', 'total']]); + expect(toRawArgs(builder)).toEqual(['CAST(? AS bigint) as ??', ['1', 'total']]); + }); + + it('should convert json values on MySQL and SQLite', () => { + class TestModel extends Model { + static get tableName() { + return 'Table'; + } + } + + const toSql = (client) => + TestModel.query(Knex({ client, useNullAsDefault: true })) + .where('a', val({ a: 1 })) + .where('b', val('x').castJson()) + .where('c', val(1).castFloat()) + .toKnexQuery() + .toString(); + expect(toSql('mysql')).toBe( + 'select `Table`.* from `Table` where `a` = CAST(\'{\\"a\\":1}\' AS JSON) ' + + 'and `b` = CAST(\'\\"x\\"\' AS JSON) and `c` = CAST(1 AS DOUBLE)', + ); + expect(toSql('sqlite3')).toBe( + 'select `Table`.* from `Table` where `a` = json(\'{"a":1}\') ' + + 'and `b` = json(\'"x"\') and `c` = CAST(1 AS float)', + ); }); }); diff --git a/tests/unit/queryBuilder/jsonFieldExpressionParser.js b/tests/unit/queryBuilder/jsonFieldExpressionParser.js index 67d30292b..1eead424c 100644 --- a/tests/unit/queryBuilder/jsonFieldExpressionParser.js +++ b/tests/unit/queryBuilder/jsonFieldExpressionParser.js @@ -1,5 +1,5 @@ -const expect = require('expect.js'), - parser = require('../../../lib/queryBuilder/parsers/jsonFieldExpressionParser.js'); +import { describe, it, expect } from 'vitest'; +import * as parser from '../../../lib/queryBuilder/parsers/jsonFieldExpressionParser.js'; describe('jsonFieldExpressionParser', () => { // basic index and field references @@ -112,12 +112,12 @@ describe('jsonFieldExpressionParser', () => { it('should fail if wrong start rule in parser options', () => { expect(() => { parser.parse('col', { startRule: 'undefined is not a function' }); - }).to.throwException(); + }).toThrow(); }); it('should be able to give start rule as parameter', () => { let result = parser.parse('col', { startRule: 'start' }); - expect(result.columnName).to.be('col'); + expect(result.columnName).toBe('col'); }); }); }); @@ -126,7 +126,7 @@ function testParsing(expr, expected) { it(expr, () => { let result = parser.parse(expr); let resultArray = [result.columnName].concat(result.access.map((it) => it.ref)); - expect(JSON.stringify(resultArray)).to.eql(JSON.stringify(expected)); + expect(JSON.stringify(resultArray)).toEqual(JSON.stringify(expected)); }); } @@ -134,6 +134,6 @@ function testFail(expr) { it(expr + ' should fail', () => { expect(() => { parser.parse(expr); - }).to.throwException(); + }).toThrow(); }); } diff --git a/tests/unit/relations/BelongsToOneRelation.js b/tests/unit/relations/BelongsToOneRelation.js index 7e0e1da28..0dd3f3af9 100644 --- a/tests/unit/relations/BelongsToOneRelation.js +++ b/tests/unit/relations/BelongsToOneRelation.js @@ -1,9 +1,10 @@ -const Knex = require('knex'), - expect = require('expect.js'), - objection = require('../../../'), - knexMocker = require('../../../testUtils/mockKnex'), - RelationOwner = require('../../../lib/relations/RelationOwner').RelationOwner, - Model = objection.Model, +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import Knex from 'knex'; +import * as objection from 'objection'; +import knexMocker from '../../../testUtils/mockKnex.js'; +import { RelationOwner } from '../../../lib/relations/RelationOwner.js'; + +const Model = objection.Model, QueryBuilder = objection.QueryBuilder, BelongsToOneRelation = objection.BelongsToOneRelation; @@ -18,7 +19,7 @@ describe('BelongsToOneRelation', () => { let relation; let compositeKeyRelation; - before(() => { + beforeAll(() => { let knex = Knex({ client: 'pg' }); mockKnex = knexMocker(knex, function (mock, oldImpl, args) { @@ -80,6 +81,9 @@ describe('BelongsToOneRelation', () => { }); describe('find', () => { + // The callers of `find()` assign its result to the owners. + let findOperation; + it('should generate a find query', () => { let owner = OwnerModel.fromJson({ id: 666, relatedId: 1 }); let expectedResult = [{ id: 1, a: 10, rid: 1 }]; @@ -87,18 +91,19 @@ describe('BelongsToOneRelation', () => { mockKnexQueryResults = [expectedResult]; let builder = QueryBuilder.forClass(RelatedModel).findOperationFactory((builder) => { - return relation.find(builder, RelationOwner.create(owner)); + return (findOperation = relation.find(builder, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.eql(expectedResult[0]); - expect(owner.nameOfOurRelation).to.eql(expectedResult[0]); - expect(result).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toEqual(expectedResult[0]); + expect(owner.nameOfOurRelation).toEqual(expectedResult[0]); + expect(result).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( 'select "RelatedModel".* from "RelatedModel" where "RelatedModel"."rid" in (1)', ); }); @@ -118,21 +123,22 @@ describe('BelongsToOneRelation', () => { ]; let builder = QueryBuilder.forClass(RelatedModel).findOperationFactory((builder) => { - return compositeKeyRelation.find(builder, RelationOwner.create(owners)); + return (findOperation = compositeKeyRelation.find(builder, RelationOwner.create(owners))); }); return builder.then((result) => { - expect(result).to.have.length(2); - expect(result).to.eql(expectedResult); - expect(owners[0].nameOfOurRelation).to.equal(result[0]); - expect(owners[1].nameOfOurRelation).to.equal(result[1]); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(2); + expect(result).toEqual(expectedResult); + expect(owners[0].nameOfOurRelation).toBe(result[0]); + expect(owners[1].nameOfOurRelation).toBe(result[1]); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( 'select "RelatedModel".* from "RelatedModel" where ("RelatedModel"."aid", "RelatedModel"."bid") in ((11, 22), (11, 33))', ); }); @@ -152,21 +158,22 @@ describe('BelongsToOneRelation', () => { ]; let builder = QueryBuilder.forClass(RelatedModel).findOperationFactory((builder) => { - return relation.find(builder, RelationOwner.create(owners)); + return (findOperation = relation.find(builder, RelationOwner.create(owners))); }); return builder.then((result) => { - expect(result).to.have.length(2); - expect(result).to.eql(expectedResult); - expect(owners[0].nameOfOurRelation).to.equal(result[0]); - expect(owners[1].nameOfOurRelation).to.equal(result[1]); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(2); + expect(result).toEqual(expectedResult); + expect(owners[0].nameOfOurRelation).toBe(result[0]); + expect(owners[1].nameOfOurRelation).toBe(result[1]); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( 'select "RelatedModel".* from "RelatedModel" where "RelatedModel"."rid" in (2, 3)', ); }); @@ -179,19 +186,20 @@ describe('BelongsToOneRelation', () => { let builder = QueryBuilder.forClass(RelatedModel) .findOperationFactory((builder) => { - return relation.find(builder, RelationOwner.create(owner)); + return (findOperation = relation.find(builder, RelationOwner.create(owner))); }) .select('name'); return builder.then((result) => { - expect(result).to.eql(expectedResult[0]); - expect(owner.nameOfOurRelation).to.eql(expectedResult[0]); - expect(result).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toEqual(expectedResult[0]); + expect(owner.nameOfOurRelation).toEqual(expectedResult[0]); + expect(result).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( 'select "RelatedModel"."rid", "name" from "RelatedModel" where "RelatedModel"."rid" in (2)', ); }); @@ -205,18 +213,19 @@ describe('BelongsToOneRelation', () => { let owner = OwnerModel.fromJson({ id: 666, relatedId: 1 }); let builder = QueryBuilder.forClass(RelatedModel).findOperationFactory((builder) => { - return relation.find(builder, RelationOwner.create(owner)); + return (findOperation = relation.find(builder, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.eql(expectedResult[0]); - expect(owner.nameOfOurRelation).to.eql(expectedResult[0]); - expect(result).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toEqual(expectedResult[0]); + expect(owner.nameOfOurRelation).toEqual(expectedResult[0]); + expect(result).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( 'select "RelatedModel".* from "RelatedModel" where "RelatedModel"."rid" in (1) and "filterCol" = 100', ); }); @@ -232,18 +241,19 @@ describe('BelongsToOneRelation', () => { mockKnexQueryResults = [expectedResult]; let builder = QueryBuilder.forClass(RelatedModel).findOperationFactory((builder) => { - return relation.find(builder, RelationOwner.create(owner)); + return (findOperation = relation.find(builder, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.eql(expectedResult[0]); - expect(owner.nameOfOurRelation).to.eql(expectedResult[0]); - expect(result).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toEqual(expectedResult[0]); + expect(owner.nameOfOurRelation).toEqual(expectedResult[0]); + expect(result).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( 'select "RelatedModel".* from "RelatedModel" where "RelatedModel"."rid" in (1) and "name" = \'Jennifer\'', ); }); @@ -257,18 +267,19 @@ describe('BelongsToOneRelation', () => { mockKnexQueryResults = [expectedResult]; let builder = QueryBuilder.forClass(RelatedModel).findOperationFactory((builder) => { - return relation.find(builder, RelationOwner.create(owner)); + return (findOperation = relation.find(builder, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.eql(expectedResult[0]); - expect(owner.nameOfOurRelation).to.eql(expectedResult[0]); - expect(result).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + findOperation.assignResult(builder, result); + expect(result).toEqual(expectedResult[0]); + expect(owner.nameOfOurRelation).toEqual(expectedResult[0]); + expect(result).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'select "RelatedModel".* from "RelatedModel" where "RelatedModel"."rid" in (1) and "filteredProperty" = true', ); }); @@ -292,20 +303,20 @@ describe('BelongsToOneRelation', () => { let toSql = builder.toKnexQuery().toString(); return builder.then((result) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.equal(toString); - expect(executedQueries[0]).to.equal(toSql); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toBe(toString); + expect(executedQueries[0]).toBe(toSql); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "rid") values (\'str1\', 2) returning "id"', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'update "OwnerModel" set "relatedId" = 2 where "OwnerModel"."id" in (666)', ); - expect(owner.nameOfOurRelation).to.equal(result[0]); - expect(owner.relatedId).to.equal(2); - expect(result).to.eql([{ a: 'str1', id: 1, rid: 2 }]); - expect(result[0]).to.be.a(RelatedModel); + expect(owner.nameOfOurRelation).toBe(result[0]); + expect(owner.relatedId).toBe(2); + expect(result).toEqual([{ a: 'str1', id: 1, rid: 2 }]); + expect(result[0]).toBeInstanceOf(RelatedModel); }); }); @@ -325,21 +336,21 @@ describe('BelongsToOneRelation', () => { let toSql = builder.toKnexQuery().toString(); return builder.then((result) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.equal(toString); - expect(executedQueries[0]).to.equal(toSql); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toBe(toString); + expect(executedQueries[0]).toBe(toSql); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "aid", "bid") values (\'str1\', 11, 22) returning "id"', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'update "OwnerModel" set "relatedAId" = 11, "relatedBId" = 22 where "OwnerModel"."id" in (666)', ); - expect(owner.relatedAId).to.equal(11); - expect(owner.relatedBId).to.equal(22); - expect(owner.nameOfOurRelation).to.equal(result[0]); - expect(result).to.eql([{ a: 'str1', aid: 11, bid: 22 }]); - expect(result[0]).to.be.a(RelatedModel); + expect(owner.relatedAId).toBe(11); + expect(owner.relatedBId).toBe(22); + expect(owner.nameOfOurRelation).toBe(result[0]); + expect(result).toEqual([{ a: 'str1', aid: 11, bid: 22 }]); + expect(result[0]).toBeInstanceOf(RelatedModel); }); }); @@ -355,17 +366,17 @@ describe('BelongsToOneRelation', () => { }) .insert(related) .then((result) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "rid") values (\'str1\', 2) returning "id"', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'update "OwnerModel" set "relatedId" = 2 where "OwnerModel"."id" in (666)', ); - expect(owner.nameOfOurRelation).to.equal(result[0]); - expect(owner.relatedId).to.equal(2); - expect(result).to.eql([{ a: 'str1', id: 5, rid: 2 }]); - expect(result[0]).to.be.a(RelatedModel); + expect(owner.nameOfOurRelation).toBe(result[0]); + expect(owner.relatedId).toBe(2); + expect(result).toEqual([{ a: 'str1', id: 5, rid: 2 }]); + expect(result[0]).toBeInstanceOf(RelatedModel); }); }); @@ -381,17 +392,17 @@ describe('BelongsToOneRelation', () => { }) .insert(related) .then((result) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "rid") values (\'str1\', 2) returning "id"', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'update "OwnerModel" set "relatedId" = 2 where "OwnerModel"."id" in (666)', ); - expect(owner.nameOfOurRelation).to.equal(result); - expect(owner.relatedId).to.equal(2); - expect(result).to.eql({ a: 'str1', id: 1, rid: 2 }); - expect(result).to.be.a(RelatedModel); + expect(owner.nameOfOurRelation).toBe(result); + expect(owner.relatedId).toBe(2); + expect(result).toEqual({ a: 'str1', id: 1, rid: 2 }); + expect(result).toBeInstanceOf(RelatedModel); }); }); @@ -407,21 +418,21 @@ describe('BelongsToOneRelation', () => { }) .insert(related) .then((result) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "rid") values (\'str1\', 2) returning "id"', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'update "OwnerModel" set "relatedId" = 2 where "OwnerModel"."id" in (666)', ); - expect(owner.nameOfOurRelation).to.equal(result); - expect(owner.relatedId).to.equal(2); - expect(result).to.eql({ a: 'str1', id: 1, rid: 2 }); - expect(result).to.be.a(RelatedModel); + expect(owner.nameOfOurRelation).toBe(result); + expect(owner.relatedId).toBe(2); + expect(result).toEqual({ a: 'str1', id: 1, rid: 2 }); + expect(result).toBeInstanceOf(RelatedModel); }); }); - it('should fail if trying to insert multiple', (done) => { + it('should fail if trying to insert multiple', () => { mockKnexQueryResults = [[1]]; let owner = OwnerModel.fromJson({ id: 666 }); @@ -430,17 +441,13 @@ describe('BelongsToOneRelation', () => { { a: 'str1', rid: 2 }, ]; - QueryBuilder.forClass(RelatedModel) - .insertOperationFactory((builder) => { - return relation.insert(builder, RelationOwner.create(owner)); - }) - .insert(related) - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - done(); - }); + return expect( + QueryBuilder.forClass(RelatedModel) + .insertOperationFactory((builder) => { + return relation.insert(builder, RelationOwner.create(owner)); + }) + .insert(related), + ).rejects.toThrow(); }); it('should not apply returning() to the relate query', () => { @@ -457,14 +464,14 @@ describe('BelongsToOneRelation', () => { .returning('*'); return builder.then((result) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "rid") values (\'str1\', 2) returning *', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'update "OwnerModel" set "relatedId" = 2 where "OwnerModel"."id" in (666)', ); - expect(result).to.eql([{ a: 'str1', id: 1, rid: 2 }]); + expect(result).toEqual([{ a: 'str1', id: 1, rid: 2 }]); }); }); }); @@ -483,11 +490,11 @@ describe('BelongsToOneRelation', () => { .update(update); return builder.then((numUpdates) => { - expect(numUpdates).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(numUpdates).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\' where "RelatedModel"."rid" in (2)', ); }); @@ -506,11 +513,11 @@ describe('BelongsToOneRelation', () => { .update(update); return builder.then((numUpdates) => { - expect(numUpdates).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(numUpdates).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\', "aid" = 11, "bid" = 22 where ("RelatedModel"."aid", "RelatedModel"."bid") in ((11, 22))', ); }); @@ -528,9 +535,9 @@ describe('BelongsToOneRelation', () => { }) .update(update) .then((numUpdates) => { - expect(numUpdates).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(numUpdates).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\' where "RelatedModel"."rid" in (2)', ); }); @@ -548,8 +555,8 @@ describe('BelongsToOneRelation', () => { }) .update(update) .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\' where "RelatedModel"."rid" in (2) and "someColumn" = \'foo\'', ); }); @@ -570,11 +577,11 @@ describe('BelongsToOneRelation', () => { .patch(patch); return builder.then((numUpdates) => { - expect(numUpdates).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(numUpdates).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\' where "RelatedModel"."rid" in (2)', ); }); @@ -601,9 +608,9 @@ describe('BelongsToOneRelation', () => { }) .patch(patch) .then((numUpdates) => { - expect(numUpdates).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(numUpdates).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\' where "RelatedModel"."rid" in (2)', ); }); @@ -619,9 +626,9 @@ describe('BelongsToOneRelation', () => { }) .increment('test', 1) .then((numUpdates) => { - expect(numUpdates).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(numUpdates).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "test" = "test" + 1 where "RelatedModel"."rid" in (1)', ); }); @@ -637,9 +644,9 @@ describe('BelongsToOneRelation', () => { }) .decrement('test', 10) .then((numUpdates) => { - expect(numUpdates).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(numUpdates).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "test" = "test" - 10 where "RelatedModel"."rid" in (2)', ); }); @@ -658,9 +665,9 @@ describe('BelongsToOneRelation', () => { }) .patch(update) .then((numUpdates) => { - expect(numUpdates).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(numUpdates).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\' where "RelatedModel"."rid" in (2) and "someColumn" = \'foo\'', ); }); @@ -678,9 +685,9 @@ describe('BelongsToOneRelation', () => { .delete(); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql({}); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(result).toEqual([]); + expect(executedQueries[0]).toEqual( 'delete from "RelatedModel" where "RelatedModel"."rid" in (2)', ); }); @@ -696,9 +703,9 @@ describe('BelongsToOneRelation', () => { .delete(); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql({}); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(result).toEqual([]); + expect(executedQueries[0]).toEqual( 'delete from "RelatedModel" where ("RelatedModel"."aid", "RelatedModel"."bid") in ((11, 22))', ); }); @@ -714,9 +721,9 @@ describe('BelongsToOneRelation', () => { }) .delete() .then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql({}); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(result).toEqual([]); + expect(executedQueries[0]).toEqual( 'delete from "RelatedModel" where "RelatedModel"."rid" in (2) and "someColumn" = 100', ); }); @@ -735,12 +742,12 @@ describe('BelongsToOneRelation', () => { .relate(10); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "OwnerModel" set "relatedId" = 10 where "OwnerModel"."id" in (666)', ); }); @@ -757,12 +764,12 @@ describe('BelongsToOneRelation', () => { .relate([10]); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "OwnerModel" set "relatedId" = 10 where "OwnerModel"."id" in (666)', ); }); @@ -779,12 +786,12 @@ describe('BelongsToOneRelation', () => { .relate({ rid: 10 }); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "OwnerModel" set "relatedId" = 10 where "OwnerModel"."id" in (666)', ); }); @@ -801,12 +808,12 @@ describe('BelongsToOneRelation', () => { .relate([{ rid: 10 }]); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "OwnerModel" set "relatedId" = 10 where "OwnerModel"."id" in (666)', ); }); @@ -823,12 +830,12 @@ describe('BelongsToOneRelation', () => { .relate([10, 20]); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "OwnerModel" set "relatedAId" = 10, "relatedBId" = 20 where "OwnerModel"."id" in (666)', ); }); @@ -845,12 +852,12 @@ describe('BelongsToOneRelation', () => { .relate({ aid: 10, bid: 20 }); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "OwnerModel" set "relatedAId" = 10, "relatedBId" = 20 where "OwnerModel"."id" in (666)', ); }); @@ -866,63 +873,51 @@ describe('BelongsToOneRelation', () => { }) .relate(11) .then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql({ a: 1, b: 2 }); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(result).toEqual({ a: 1, b: 2 }); + expect(executedQueries[0]).toEqual( 'update "OwnerModel" set "relatedId" = 11 where "OwnerModel"."id" in (666)', ); }); }); - it('should fail if trying to relate multiple', (done) => { + it('should fail if trying to relate multiple', () => { mockKnexQueryResults = [123]; let owner = OwnerModel.fromJson({ id: 666 }); - QueryBuilder.forClass(RelatedModel) - .relateOperationFactory((builder) => { - return relation.relate(builder, RelationOwner.create(owner)); - }) - .relate([11, 12]) - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - done(); - }); + return expect( + QueryBuilder.forClass(RelatedModel) + .relateOperationFactory((builder) => { + return relation.relate(builder, RelationOwner.create(owner)); + }) + .relate([11, 12]), + ).rejects.toThrow(); }); - it("should fail if object value doesn't contain the needed id", (done) => { + it("should fail if object value doesn't contain the needed id", () => { mockKnexQueryResults = [123]; let owner = OwnerModel.fromJson({ id: 666 }); - QueryBuilder.forClass(RelatedModel) - .relateOperationFactory((builder) => { - return relation.relate(builder, RelationOwner.create(owner)); - }) - .relate({ wrongId: 10 }) - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - done(); - }); + return expect( + QueryBuilder.forClass(RelatedModel) + .relateOperationFactory((builder) => { + return relation.relate(builder, RelationOwner.create(owner)); + }) + .relate({ wrongId: 10 }), + ).rejects.toThrow(); }); - it("should fail if object value doesn't contain the needed id (composite key)", (done) => { + it("should fail if object value doesn't contain the needed id (composite key)", () => { mockKnexQueryResults = [123]; let owner = OwnerModel.fromJson({ id: 666 }); - QueryBuilder.forClass(RelatedModel) - .relateOperationFactory((builder) => { - return compositeKeyRelation.relate(builder, RelationOwner.create(owner)); - }) - .relate({ aid: 10, wrongId: 20 }) - .then(() => { - done(new Error('should not get here')); - }) - .catch(() => { - done(); - }); + return expect( + QueryBuilder.forClass(RelatedModel) + .relateOperationFactory((builder) => { + return compositeKeyRelation.relate(builder, RelationOwner.create(owner)); + }) + .relate({ aid: 10, wrongId: 20 }), + ).rejects.toThrow(); }); it('should return the patched owner rows when returning() is used', () => { @@ -937,15 +932,15 @@ describe('BelongsToOneRelation', () => { .returning('*'); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "OwnerModel" set "relatedId" = 10 where "OwnerModel"."id" in (666) returning *', ); - expect(result).to.have.length(1); - expect(result[0]).to.be.a(OwnerModel); - expect(result[0].toJSON()).to.eql({ id: 666, relatedId: 10 }); + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(OwnerModel); + expect(result[0].toJSON()).toEqual({ id: 666, relatedId: 10 }); }); }); }); @@ -964,11 +959,12 @@ describe('BelongsToOneRelation', () => { to: 'RelatedModel.rid', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'OwnerModel.relationMappings.nameOfOurRelation: Property join.through is not supported for this relation type.', - ); - }); + }).toThrow( + expect.objectContaining({ + message: + 'OwnerModel.relationMappings.nameOfOurRelation: Property join.through is not supported for this relation type.', + }), + ); }); }); diff --git a/tests/unit/relations/HasManyRelation.js b/tests/unit/relations/HasManyRelation.js index ab2ebf0e6..c0d33c4de 100644 --- a/tests/unit/relations/HasManyRelation.js +++ b/tests/unit/relations/HasManyRelation.js @@ -1,9 +1,10 @@ -const Knex = require('knex'), - expect = require('expect.js'), - objection = require('../../../'), - knexMocker = require('../../../testUtils/mockKnex'), - RelationOwner = require('../../../lib/relations/RelationOwner').RelationOwner, - Model = objection.Model, +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import Knex from 'knex'; +import * as objection from 'objection'; +import knexMocker from '../../../testUtils/mockKnex.js'; +import { RelationOwner } from '../../../lib/relations/RelationOwner.js'; + +const Model = objection.Model, QueryBuilder = objection.QueryBuilder, HasManyRelation = objection.HasManyRelation; @@ -18,7 +19,7 @@ describe('HasManyRelation', () => { let relation; let compositeKeyRelation; - before(() => { + beforeAll(() => { let knex = Knex({ client: 'pg' }); mockKnex = knexMocker(knex, function (mock, oldImpl, args) { @@ -80,6 +81,9 @@ describe('HasManyRelation', () => { }); describe('find', () => { + // The callers of `find()` assign its result to the owners. + let findOperation; + it('should generate a find query', () => { let owner = OwnerModel.fromJson({ oid: 666 }); let expectedResult = [ @@ -93,20 +97,21 @@ describe('HasManyRelation', () => { .where('name', 'Teppo') .orWhere('age', '>', 60) .findOperationFactory((builder) => { - return relation.find(builder, RelationOwner.create(owner)); + return (findOperation = relation.find(builder, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.have.length(2); - expect(result).to.eql(expectedResult); - expect(owner.nameOfOurRelation).to.eql(expectedResult); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(2); + expect(result).toEqual(expectedResult); + expect(owner.nameOfOurRelation).toEqual(expectedResult); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( 'select "RelatedModel".* from "RelatedModel" where "RelatedModel"."ownerId" in (666) and ("name" = \'Teppo\' or "age" > 60)', ); }); @@ -131,29 +136,30 @@ describe('HasManyRelation', () => { .where('name', 'Teppo') .orWhere('age', '>', 60) .findOperationFactory((builder) => { - return compositeKeyRelation.find(builder, RelationOwner.create(owners)); + return (findOperation = compositeKeyRelation.find(builder, RelationOwner.create(owners))); }); return builder.then((result) => { - expect(result).to.have.length(4); - expect(result).to.eql(expectedResult); - expect(owners[0].nameOfOurRelation).to.eql([ + findOperation.assignResult(builder, result); + expect(result).toHaveLength(4); + expect(result).toEqual(expectedResult); + expect(owners[0].nameOfOurRelation).toEqual([ { a: 1, ownerAId: 11, ownerBId: 22 }, { a: 2, ownerAId: 11, ownerBId: 22 }, ]); - expect(owners[1].nameOfOurRelation).to.eql([ + expect(owners[1].nameOfOurRelation).toEqual([ { a: 3, ownerAId: 11, ownerBId: 33 }, { a: 4, ownerAId: 11, ownerBId: 33 }, ]); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - expect(result[2]).to.be.a(RelatedModel); - expect(result[3]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + expect(result[2]).toBeInstanceOf(RelatedModel); + expect(result[3]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( 'select "RelatedModel".* from "RelatedModel" where ("RelatedModel"."ownerAId", "RelatedModel"."ownerBId") in ((11, 22), (11, 33)) and ("name" = \'Teppo\' or "age" > 60)', ); }); @@ -175,29 +181,30 @@ describe('HasManyRelation', () => { .where('name', 'Teppo') .orWhere('age', '>', 60) .findOperationFactory((builder) => { - return relation.find(builder, RelationOwner.create(owners)); + return (findOperation = relation.find(builder, RelationOwner.create(owners))); }); return builder.then((result) => { - expect(result).to.have.length(4); - expect(result).to.eql(expectedResult); - expect(owners[0].nameOfOurRelation).to.eql([ + findOperation.assignResult(builder, result); + expect(result).toHaveLength(4); + expect(result).toEqual(expectedResult); + expect(owners[0].nameOfOurRelation).toEqual([ { a: 1, ownerId: 666 }, { a: 2, ownerId: 666 }, ]); - expect(owners[1].nameOfOurRelation).to.eql([ + expect(owners[1].nameOfOurRelation).toEqual([ { a: 3, ownerId: 667 }, { a: 4, ownerId: 667 }, ]); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - expect(result[2]).to.be.a(RelatedModel); - expect(result[3]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + expect(result[2]).toBeInstanceOf(RelatedModel); + expect(result[3]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( 'select "RelatedModel".* from "RelatedModel" where "RelatedModel"."ownerId" in (666, 667) and ("name" = \'Teppo\' or "age" > 60)', ); }); @@ -217,20 +224,21 @@ describe('HasManyRelation', () => { .orWhere('age', '>', 60) .select('name') .findOperationFactory((builder) => { - return relation.find(builder, RelationOwner.create(owner)); + return (findOperation = relation.find(builder, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.have.length(2); - expect(result).to.eql(expectedResult); - expect(owner.nameOfOurRelation).to.eql(expectedResult); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(2); + expect(result).toEqual(expectedResult); + expect(owner.nameOfOurRelation).toEqual(expectedResult); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( 'select "RelatedModel"."ownerId", "name" from "RelatedModel" where "RelatedModel"."ownerId" in (666) and ("name" = \'Teppo\' or "age" > 60)', ); }); @@ -251,20 +259,21 @@ describe('HasManyRelation', () => { .where('name', 'Teppo') .orWhere('age', '>', 60) .findOperationFactory((builder) => { - return relation.find(builder, RelationOwner.create(owner)); + return (findOperation = relation.find(builder, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.have.length(2); - expect(result).to.eql(expectedResult); - expect(owner.nameOfOurRelation).to.eql(expectedResult); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(2); + expect(result).toEqual(expectedResult); + expect(owner.nameOfOurRelation).toEqual(expectedResult); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( 'select "RelatedModel".* from "RelatedModel" where "RelatedModel"."ownerId" in (666) and "someColumn" = \'foo\' and ("name" = \'Teppo\' or "age" > 60)', ); }); @@ -285,20 +294,21 @@ describe('HasManyRelation', () => { .where('name', 'Teppo') .orWhere('age', '>', 60) .findOperationFactory((builder) => { - return relation.find(builder, RelationOwner.create(owner)); + return (findOperation = relation.find(builder, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.have.length(2); - expect(result).to.eql(expectedResult); - expect(owner.nameOfOurRelation).to.eql(expectedResult); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(2); + expect(result).toEqual(expectedResult); + expect(owner.nameOfOurRelation).toEqual(expectedResult); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( 'select "RelatedModel".* from "RelatedModel" where "RelatedModel"."ownerId" in (666) and "someColumn" = \'foo\' and ("name" = \'Teppo\' or "age" > 60)', ); }); @@ -319,20 +329,21 @@ describe('HasManyRelation', () => { .where('name', 'Teppo') .orWhere('age', '>', 60) .findOperationFactory((builder) => { - return relation.find(builder, RelationOwner.create(owner)); + return (findOperation = relation.find(builder, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.have.length(2); - expect(result).to.eql(expectedResult); - expect(owner.nameOfOurRelation).to.eql(expectedResult); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(2); + expect(result).toEqual(expectedResult); + expect(owner.nameOfOurRelation).toEqual(expectedResult); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( 'select "RelatedModel".* from "RelatedModel" where "RelatedModel"."ownerId" in (666) and "filteredProperty" = true and ("name" = \'Teppo\' or "age" > 60)', ); }); @@ -356,20 +367,20 @@ describe('HasManyRelation', () => { let toSql = builder.toKnexQuery().toString(); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(toString); - expect(executedQueries[0]).to.equal(toSql); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(toString); + expect(executedQueries[0]).toBe(toSql); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "ownerId") values (\'str1\', 666), (\'str2\', 666) returning "id"', ); - expect(owner.nameOfOurRelation).to.eql(result); - expect(result).to.eql([ + expect(owner.nameOfOurRelation).toEqual(result); + expect(result).toEqual([ { a: 'str1', id: 1, ownerId: 666 }, { a: 'str2', id: 2, ownerId: 666 }, ]); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); }); }); @@ -389,20 +400,20 @@ describe('HasManyRelation', () => { let toSql = builder.toKnexQuery().toString(); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(toString); - expect(executedQueries[0]).to.equal(toSql); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(toString); + expect(executedQueries[0]).toBe(toSql); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "ownerAId", "ownerBId") values (\'str1\', 11, 22), (\'str2\', 11, 22) returning "id"', ); - expect(owner.nameOfOurRelation).to.eql(result); - expect(result).to.eql([ + expect(owner.nameOfOurRelation).toEqual(result); + expect(result).toEqual([ { a: 'str1', id: 1, ownerAId: 11, ownerBId: 22 }, { a: 'str2', id: 2, ownerAId: 11, ownerBId: 22 }, ]); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); }); }); @@ -422,10 +433,10 @@ describe('HasManyRelation', () => { .context({ foo: 'bar' }) .insert(related) .then(() => { - expect(calls).to.have.length(1); - expect(calls[0].model).to.equal(related); - expect(calls[0].ctx.foo).to.equal('bar'); - expect(calls[0].owner).to.equal(owner); + expect(calls).toHaveLength(1); + expect(calls[0].model).toBe(related); + expect(calls[0].ctx.foo).toBe('bar'); + expect(calls[0].owner).toBe(owner); }); }); @@ -441,8 +452,8 @@ describe('HasManyRelation', () => { }) .insert({ a: 'str1' }) .then(() => { - expect(calls).to.have.length(1); - expect(calls[0].owner).to.equal(undefined); + expect(calls).toHaveLength(1); + expect(calls[0].owner).toBeUndefined(); }); }); @@ -458,16 +469,16 @@ describe('HasManyRelation', () => { }) .insert(related) .then((result) => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "ownerId") values (\'str1\', 666), (\'str2\', 666) returning "id"', ); - expect(result).to.eql([ + expect(result).toEqual([ { a: 'str1', id: 1, ownerId: 666 }, { a: 'str2', id: 2, ownerId: 666 }, ]); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); }); }); @@ -483,12 +494,12 @@ describe('HasManyRelation', () => { }) .insert(related) .then((result) => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "ownerId") values (\'str1\', 666) returning "id"', ); - expect(result).to.eql({ a: 'str1', id: 1, ownerId: 666 }); - expect(result).to.be.a(RelatedModel); + expect(result).toEqual({ a: 'str1', id: 1, ownerId: 666 }); + expect(result).toBeInstanceOf(RelatedModel); }); }); @@ -504,12 +515,12 @@ describe('HasManyRelation', () => { }) .insert(related) .then((result) => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "ownerId") values (\'str1\', 666) returning "id"', ); - expect(result).to.eql({ a: 'str1', id: 1, ownerId: 666 }); - expect(result).to.be.a(RelatedModel); + expect(result).toEqual({ a: 'str1', id: 1, ownerId: 666 }); + expect(result).toBeInstanceOf(RelatedModel); }); }); }); @@ -531,11 +542,11 @@ describe('HasManyRelation', () => { .select('shouldBeIgnored'); return builder.then((numUpdated) => { - expect(numUpdated).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(numUpdated).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\' where "RelatedModel"."ownerId" in (666) and "gender" = \'male\' and "thingy" is not null', ); }); @@ -557,11 +568,11 @@ describe('HasManyRelation', () => { .select('shouldBeIgnored'); return builder.then((numUpdated) => { - expect(numUpdated).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(numUpdated).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\' where ("RelatedModel"."ownerAId", "RelatedModel"."ownerBId") in ((11, 22)) and "gender" = \'male\' and "thingy" is not null', ); }); @@ -582,9 +593,9 @@ describe('HasManyRelation', () => { .whereNotNull('thingy') .select('shouldBeIgnored') .then((numUpdated) => { - expect(numUpdated).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(numUpdated).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\' where "RelatedModel"."ownerId" in (666) and "gender" = \'male\' and "thingy" is not null', ); }); @@ -607,9 +618,9 @@ describe('HasManyRelation', () => { .whereNotNull('thingy') .select('shouldBeIgnored') .then((numUpdated) => { - expect(numUpdated).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(numUpdated).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\' where "RelatedModel"."ownerId" in (666) and "someColumn" = 100 and "gender" = \'male\' and "thingy" is not null', ); }); @@ -633,11 +644,11 @@ describe('HasManyRelation', () => { .select('shouldBeIgnored'); return builder.then((numUpdated) => { - expect(numUpdated).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(numUpdated).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\' where "RelatedModel"."ownerId" in (666) and "gender" = \'male\' and "thingy" is not null', ); }); @@ -667,9 +678,9 @@ describe('HasManyRelation', () => { .whereNotNull('thingy') .select('shouldBeIgnored') .then((numUpdated) => { - expect(numUpdated).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(numUpdated).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\' where "RelatedModel"."ownerId" in (666) and "gender" = \'male\' and "thingy" is not null', ); }); @@ -685,9 +696,9 @@ describe('HasManyRelation', () => { }) .increment('test', 1) .then((numUpdated) => { - expect(numUpdated).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(numUpdated).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "test" = "test" + 1 where "RelatedModel"."ownerId" in (666)', ); }); @@ -703,9 +714,9 @@ describe('HasManyRelation', () => { }) .decrement('test', 10) .then((numUpdated) => { - expect(numUpdated).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(numUpdated).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "test" = "test" - 10 where "RelatedModel"."ownerId" in (666)', ); }); @@ -726,8 +737,8 @@ describe('HasManyRelation', () => { .whereNotNull('thingy') .select('shouldBeIgnored') .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "a" = \'str1\' where "RelatedModel"."ownerId" in (666) and "someColumn" = 100 and "gender" = \'male\' and "thingy" is not null', ); }); @@ -748,12 +759,12 @@ describe('HasManyRelation', () => { .select('shouldBeIgnored'); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql({}); + expect(executedQueries).toHaveLength(1); + expect(result).toEqual([]); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'delete from "RelatedModel" where "RelatedModel"."ownerId" in (666) and "gender" = \'male\' and "thingy" is not null', ); }); @@ -772,12 +783,12 @@ describe('HasManyRelation', () => { .select('shouldBeIgnored'); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql({}); + expect(executedQueries).toHaveLength(1); + expect(result).toEqual([]); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'delete from "RelatedModel" where ("RelatedModel"."ownerAId", "RelatedModel"."ownerBId") in ((11, 22)) and "gender" = \'male\' and "thingy" is not null', ); }); @@ -796,9 +807,9 @@ describe('HasManyRelation', () => { .whereNotNull('thingy') .select('shouldBeIgnored') .then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql({}); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(result).toEqual([]); + expect(executedQueries[0]).toEqual( 'delete from "RelatedModel" where "RelatedModel"."ownerId" in (666) and "someColumn" = 100 and "gender" = \'male\' and "thingy" is not null', ); }); @@ -817,12 +828,12 @@ describe('HasManyRelation', () => { .relate(10); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "ownerId" = 666 where "RelatedModel"."id" in (10)', ); }); @@ -839,12 +850,12 @@ describe('HasManyRelation', () => { .relate([10, 20, 30]); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql([5, 6, 7]); + expect(executedQueries).toHaveLength(1); + expect(result).toEqual([5, 6, 7]); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "ownerId" = 666 where "RelatedModel"."id" in (10, 20, 30)', ); }); @@ -861,12 +872,12 @@ describe('HasManyRelation', () => { .relate({ id: 10 }); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "ownerId" = 666 where "RelatedModel"."id" in (10)', ); }); @@ -883,12 +894,12 @@ describe('HasManyRelation', () => { .relate([{ id: 10 }, { id: 20 }]); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "ownerId" = 666 where "RelatedModel"."id" in (10, 20)', ); }); @@ -905,12 +916,12 @@ describe('HasManyRelation', () => { .relate([1, 2, 3]); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "ownerAId" = 11, "ownerBId" = 22 where "RelatedModel"."id" in (1, 2, 3)', ); }); @@ -926,9 +937,9 @@ describe('HasManyRelation', () => { }) .relate(11) .then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "ownerId" = 666 where "RelatedModel"."id" in (11)', ); }); @@ -951,16 +962,16 @@ describe('HasManyRelation', () => { .returning('*'); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "ownerId" = 666 where "RelatedModel"."id" in (10, 20) returning *', ); - expect(result).to.have.length(2); - expect(result[0]).to.be.a(RelatedModel); - expect(result[0].toJSON()).to.eql({ id: 10, ownerId: 666 }); - expect(result[1].toJSON()).to.eql({ id: 20, ownerId: 666 }); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[0].toJSON()).toEqual({ id: 10, ownerId: 666 }); + expect(result[1].toJSON()).toEqual({ id: 20, ownerId: 666 }); }); }); }); @@ -978,12 +989,12 @@ describe('HasManyRelation', () => { .whereIn('code', [55, 66, 77]); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "ownerId" = NULL where "code" in (55, 66, 77) and "RelatedModel"."ownerId" in (666)', ); }); @@ -1001,12 +1012,12 @@ describe('HasManyRelation', () => { .whereIn('code', [55, 66, 77]); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "ownerAId" = NULL, "ownerBId" = NULL where "code" in (55, 66, 77) and ("RelatedModel"."ownerAId", "RelatedModel"."ownerBId") in ((11, 22))', ); }); @@ -1023,9 +1034,9 @@ describe('HasManyRelation', () => { .unrelate() .whereIn('code', [55, 66, 77]) .then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql({}); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(result).toEqual([]); + expect(executedQueries[0]).toEqual( 'update "RelatedModel" set "ownerId" = NULL where "code" in (55, 66, 77) and "RelatedModel"."ownerId" in (666) and "someColumn" = 100', ); }); @@ -1044,11 +1055,12 @@ describe('HasManyRelation', () => { to: 'RelatedModel.ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'OwnerModel.relationMappings.nameOfOurRelation: Property join.through is not supported for this relation type.', - ); - }); + }).toThrow( + expect.objectContaining({ + message: + 'OwnerModel.relationMappings.nameOfOurRelation: Property join.through is not supported for this relation type.', + }), + ); }); }); diff --git a/tests/unit/relations/ManyToManyRelation.js b/tests/unit/relations/ManyToManyRelation.js index 5843f09a9..8a149107c 100644 --- a/tests/unit/relations/ManyToManyRelation.js +++ b/tests/unit/relations/ManyToManyRelation.js @@ -1,10 +1,14 @@ -const Knex = require('knex'); -const expect = require('expect.js'); -const objection = require('../../../'); -const { isFunction } = require('../../../lib/utils/objectUtils'); -const knexMocker = require('../../../testUtils/mockKnex'); -const RelationOwner = require('../../../lib/relations/RelationOwner').RelationOwner; -const { sortBy } = require('../../../testUtils/testUtils'); +import { describe, it, expect, beforeEach } from 'vitest'; +import Knex from 'knex'; +import * as objection from 'objection'; +import { isFunction } from '../../../lib/utils/objectUtils.js'; +import knexMocker from '../../../testUtils/mockKnex.js'; +import { RelationOwner } from '../../../lib/relations/RelationOwner.js'; +import { sortBy } from '../../../testUtils/testUtils.js'; +import { createRequire } from 'node:module'; + +// The model files in ./files are CommonJS modules. +const require = createRequire(import.meta.url); const Model = objection.Model; const QueryBuilder = objection.QueryBuilder; @@ -115,9 +119,9 @@ describe('ManyToManyRelation', () => { }, }); - expect(relation.joinTable).to.equal('JoinModel'); - expect(relation.joinTableOwnerProp.cols).to.eql(['ownerId']); - expect(relation.joinTableRelatedProp.cols).to.eql(['relatedId']); + expect(relation.joinTable).toBe('JoinModel'); + expect(relation.joinTableOwnerProp.cols).toEqual(['ownerId']); + expect(relation.joinTableRelatedProp.cols).toEqual(['relatedId']); }); it('should accept a join model in join.through object', () => { @@ -137,10 +141,10 @@ describe('ManyToManyRelation', () => { }, }); - expect(relation.joinTable).to.equal('JoinModel'); - expect(relation.joinTableOwnerProp.cols).to.eql(['ownerId']); - expect(relation.joinTableRelatedProp.props).to.eql(['relatedId']); - expect(isSubclassOf(relation.joinModelClass, JoinModel)).to.equal(true); + expect(relation.joinTable).toBe('JoinModel'); + expect(relation.joinTableOwnerProp.cols).toEqual(['ownerId']); + expect(relation.joinTableRelatedProp.props).toEqual(['relatedId']); + expect(isSubclassOf(relation.joinModelClass, JoinModel)).toBe(true); }); it('should accept references created with Model.ref()', () => { @@ -160,11 +164,11 @@ describe('ManyToManyRelation', () => { }, }); - expect(relation.joinTable).to.equal('JoinModel'); - expect(relation.ownerProp.cols).to.eql(['id']); - expect(relation.joinTableOwnerProp.cols).to.eql(['ownerId']); - expect(relation.joinTableRelatedProp.cols).to.eql(['relatedId']); - expect(relation.relatedProp.cols).to.eql(['ownerId']); + expect(relation.joinTable).toBe('JoinModel'); + expect(relation.ownerProp.cols).toEqual(['id']); + expect(relation.joinTableOwnerProp.cols).toEqual(['ownerId']); + expect(relation.joinTableRelatedProp.cols).toEqual(['relatedId']); + expect(relation.relatedProp.cols).toEqual(['ownerId']); }); it('should accept an absolute file path to a join model in join.through object', () => { @@ -176,7 +180,7 @@ describe('ManyToManyRelation', () => { join: { from: 'OwnerModel.id', through: { - modelClass: __dirname + '/files/JoinModel', + modelClass: import.meta.dirname + '/files/JoinModel', from: 'JoinModel.ownerId', to: 'JoinModel.relatedId', }, @@ -184,10 +188,10 @@ describe('ManyToManyRelation', () => { }, }); - expect(relation.joinTable).to.equal('JoinModel'); - expect(relation.joinTableOwnerProp.cols).to.eql(['ownerId']); - expect(relation.joinTableRelatedProp.cols).to.eql(['relatedId']); - expect(isSubclassOf(relation.joinModelClass, require('./files/JoinModel'))).to.equal(true); + expect(relation.joinTable).toBe('JoinModel'); + expect(relation.joinTableOwnerProp.cols).toEqual(['ownerId']); + expect(relation.joinTableRelatedProp.cols).toEqual(['relatedId']); + expect(isSubclassOf(relation.joinModelClass, require('./files/JoinModel'))).toBe(true); }); it('should accept a composite keys in join.through object (1)', () => { @@ -206,9 +210,9 @@ describe('ManyToManyRelation', () => { }, }); - expect(relation.joinTable).to.equal('JoinModel'); - expect(relation.joinTableOwnerProp.cols).to.eql(['ownerName', 'ownerDateOfBirth']); - expect(relation.joinTableRelatedProp.cols).to.eql(['relatedId']); + expect(relation.joinTable).toBe('JoinModel'); + expect(relation.joinTableOwnerProp.cols).toEqual(['ownerName', 'ownerDateOfBirth']); + expect(relation.joinTableRelatedProp.cols).toEqual(['relatedId']); }); it('should accept a composite keys in join.through object (2)', () => { @@ -227,9 +231,9 @@ describe('ManyToManyRelation', () => { }, }); - expect(relation.joinTable).to.equal('JoinModel'); - expect(relation.joinTableOwnerProp.cols).to.eql(['ownerName', 'ownerDateOfBirth']); - expect(relation.joinTableRelatedProp.cols).to.eql(['relatedA', 'relatedB']); + expect(relation.joinTable).toBe('JoinModel'); + expect(relation.joinTableOwnerProp.cols).toEqual(['ownerName', 'ownerDateOfBirth']); + expect(relation.joinTableRelatedProp.cols).toEqual(['relatedA', 'relatedB']); }); it('should accept an array in through.extra', () => { @@ -249,8 +253,8 @@ describe('ManyToManyRelation', () => { }, }); - expect(relation.joinTableExtras[0].joinTableCol).to.equal('extra1'); - expect(relation.joinTableExtras[1].joinTableCol).to.equal('extra2'); + expect(relation.joinTableExtras[0].joinTableCol).toBe('extra1'); + expect(relation.joinTableExtras[1].joinTableCol).toBe('extra2'); }); it('should accept a string in through.extra', () => { @@ -270,7 +274,7 @@ describe('ManyToManyRelation', () => { }, }); - expect(relation.joinTableExtras[0].joinTableCol).to.equal('extra1'); + expect(relation.joinTableExtras[0].joinTableCol).toBe('extra1'); }); it('should accept an object in through.extra', () => { @@ -292,9 +296,9 @@ describe('ManyToManyRelation', () => { }, }); - expect(relation.joinTableExtras[0].joinTableCol).to.equal('extraColumn'); - expect(relation.joinTableExtras[0].joinTableProp).to.equal('extraColumn'); - expect(relation.joinTableExtras[0].aliasCol).to.equal('extra1'); + expect(relation.joinTableExtras[0].joinTableCol).toBe('extraColumn'); + expect(relation.joinTableExtras[0].joinTableProp).toBe('extraColumn'); + expect(relation.joinTableExtras[0].aliasCol).toBe('extra1'); }); it('should fail if join.through.modelClass is not a subclass of Model', () => { @@ -314,11 +318,12 @@ describe('ManyToManyRelation', () => { to: 'OwnerModel.id', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'OwnerModel.relationMappings.testRelation: join.through.modelClass: is not a subclass of Model or a file path to a module that exports one. You may be dealing with a require loop. See the documentation section about require loops.', - ); - }); + }).toThrow( + expect.objectContaining({ + message: + 'OwnerModel.relationMappings.testRelation: join.through.modelClass: is not a subclass of Model or a file path to a module that exports one. You may be dealing with circular imports (a require loop). See the documentation section about circular imports.', + }), + ); }); it('should fail if join.through.modelClass is an invalid path', () => { @@ -338,11 +343,9 @@ describe('ManyToManyRelation', () => { to: 'OwnerModel.id', }, }); - }).to.throwException((err) => { - expect(err.message).to.contain( - "OwnerModel.relationMappings.testRelation: Cannot find module '/not/a/path/to/a/model'", - ); - }); + }).toThrow( + "OwnerModel.relationMappings.testRelation: Cannot find module '/not/a/path/to/a/model'", + ); }); it('should fail if join.through.to is missing', () => { @@ -360,11 +363,9 @@ describe('ManyToManyRelation', () => { to: 'OwnerModel.id', }, }); - }).to.throwException((err) => { - expect(err.message).to.contain( - 'OwnerModel.relationMappings.testRelation: join.through must be an object that describes the join table. For example: {from: "JoinTable.someId", to: "JoinTable.someOtherId"}', - ); - }); + }).toThrow( + 'OwnerModel.relationMappings.testRelation: join.through must be an object that describes the join table. For example: {from: "JoinTable.someId", to: "JoinTable.someOtherId"}', + ); }); it('should fail if join.through.from is missing', () => { @@ -382,11 +383,9 @@ describe('ManyToManyRelation', () => { to: 'OwnerModel.id', }, }); - }).to.throwException((err) => { - expect(err.message).to.contain( - 'OwnerModel.relationMappings.testRelation: join.through must be an object that describes the join table. For example: {from: "JoinTable.someId", to: "JoinTable.someOtherId"}', - ); - }); + }).toThrow( + 'OwnerModel.relationMappings.testRelation: join.through must be an object that describes the join table. For example: {from: "JoinTable.someId", to: "JoinTable.someOtherId"}', + ); }); it('join.through.from should have format joinTable.columnName', () => { @@ -405,11 +404,9 @@ describe('ManyToManyRelation', () => { to: 'OwnerModel.id', }, }); - }).to.throwException((err) => { - expect(err.message).to.contain( - 'OwnerModel.relationMappings.testRelation: join.through.from must have format JoinTable.columnName. For example "JoinTable.someId" or in case of composite key ["JoinTable.a", "JoinTable.b"].', - ); - }); + }).toThrow( + 'OwnerModel.relationMappings.testRelation: join.through.from must have format JoinTable.columnName. For example "JoinTable.someId" or in case of composite key ["JoinTable.a", "JoinTable.b"].', + ); }); it('join.through.to should have format JoinModel.columnName', () => { @@ -428,11 +425,9 @@ describe('ManyToManyRelation', () => { to: 'OwnerModel.id', }, }); - }).to.throwException((err) => { - expect(err.message).to.contain( - 'OwnerModel.relationMappings.testRelation: join.through.to must have format JoinTable.columnName. For example "JoinTable.someId" or in case of composite key ["JoinTable.a", "JoinTable.b"].', - ); - }); + }).toThrow( + 'OwnerModel.relationMappings.testRelation: join.through.to must have format JoinTable.columnName. For example "JoinTable.someId" or in case of composite key ["JoinTable.a", "JoinTable.b"].', + ); }); it('join.through `to` and `from` should point to the same table', () => { @@ -451,11 +446,9 @@ describe('ManyToManyRelation', () => { to: 'OwnerModel.id', }, }); - }).to.throwException((err) => { - expect(err.message).to.contain( - 'OwnerModel.relationMappings.testRelation: join.through `from` and `to` must point to the same join table.', - ); - }); + }).toThrow( + 'OwnerModel.relationMappings.testRelation: join.through `from` and `to` must point to the same join table.', + ); }); it('should accept a modifier in join.through', () => { @@ -463,7 +456,7 @@ describe('ManyToManyRelation', () => { let modifier = (builder) => builder.where('someColumn', 'foo'); createJoinThroughModifiedRelation(modifier); - expect(relation.joinTableModify).to.be.a(Function); + expect(relation.joinTableModify).toBeInstanceOf(Function); // test also join.through.filter let relationFilter = new ManyToManyRelation('testRelation', OwnerModel); @@ -481,10 +474,13 @@ describe('ManyToManyRelation', () => { }, }); - expect(relationFilter.joinTableModify).to.be.a(Function); + expect(relationFilter.joinTableModify).toBeInstanceOf(Function); }); describe('find', () => { + // The callers of `find()` assign its result to the owners. + let findOperation; + it('should generate a find query', () => { let owner = OwnerModel.fromJson({ oid: 666 }); let expectedResult = [ @@ -498,20 +494,21 @@ describe('ManyToManyRelation', () => { .where('name', 'Teppo') .orWhere('age', '>', 60) .findOperationFactory((builder) => { - return relation.find(builder, RelationOwner.create(owner)); + return (findOperation = relation.find(builder, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.have.length(2); - expect(result).to.eql(expectedResult); - expect(owner.nameOfOurRelation).to.eql(expectedResult); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(2); + expect(result).toEqual(expectedResult); + expect(owner.nameOfOurRelation).toEqual(expectedResult); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( [ 'select "RelatedModel".*, "JoinModel"."extra1" as "extra1", "JoinModel"."extra2" as "extra2", "JoinModel"."ownerId" as "objectiontmpjoin0"', 'from "RelatedModel"', @@ -537,20 +534,21 @@ describe('ManyToManyRelation', () => { .where('name', 'Teppo') .orWhere('age', '>', 60) .findOperationFactory((builder) => { - return compositeKeyRelation.find(builder, RelationOwner.create(owner)); + return (findOperation = compositeKeyRelation.find(builder, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.have.length(2); - expect(result).to.eql(expectedResult); - expect(owner.nameOfOurRelation).to.eql(expectedResult); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(2); + expect(result).toEqual(expectedResult); + expect(owner.nameOfOurRelation).toEqual(expectedResult); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( [ 'select "RelatedModel".*, "JoinModel"."ownerAId" as "objectiontmpjoin0", "JoinModel"."ownerBId" as "objectiontmpjoin1"', 'from "RelatedModel"', @@ -579,23 +577,24 @@ describe('ManyToManyRelation', () => { .where('name', 'Teppo') .orWhere('age', '>', 60) .findOperationFactory(function () { - return relation.find(this, RelationOwner.create(owners)); + return (findOperation = relation.find(this, RelationOwner.create(owners))); }); return builder.then((result) => { - expect(result).to.have.length(4); - expect(result).to.eql(expectedResult); - expect(owners[0].nameOfOurRelation).to.eql([{ a: 1 }, { a: 2 }]); - expect(owners[1].nameOfOurRelation).to.eql([{ a: 3 }, { a: 4 }]); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - expect(result[2]).to.be.a(RelatedModel); - expect(result[3]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(4); + expect(result).toEqual(expectedResult); + expect(owners[0].nameOfOurRelation).toEqual([{ a: 1 }, { a: 2 }]); + expect(owners[1].nameOfOurRelation).toEqual([{ a: 3 }, { a: 4 }]); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + expect(result[2]).toBeInstanceOf(RelatedModel); + expect(result[3]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( [ 'select "RelatedModel".*, "JoinModel"."extra1" as "extra1", "JoinModel"."extra2" as "extra2", "JoinModel"."ownerId" as "objectiontmpjoin0"', 'from "RelatedModel"', @@ -627,23 +626,24 @@ describe('ManyToManyRelation', () => { .where('name', 'Teppo') .orWhere('age', '>', 60) .findOperationFactory(function () { - return compositeKeyRelation.find(this, RelationOwner.create(owners)); + return (findOperation = compositeKeyRelation.find(this, RelationOwner.create(owners))); }); return builder.then((result) => { - expect(result).to.have.length(4); - expect(result).to.eql(expectedResult); - expect(owners[0].nameOfOurRelation).to.eql([{ a: 1 }, { a: 2 }]); - expect(owners[1].nameOfOurRelation).to.eql([{ a: 3 }, { a: 4 }]); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - expect(result[2]).to.be.a(RelatedModel); - expect(result[3]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(4); + expect(result).toEqual(expectedResult); + expect(owners[0].nameOfOurRelation).toEqual([{ a: 1 }, { a: 2 }]); + expect(owners[1].nameOfOurRelation).toEqual([{ a: 3 }, { a: 4 }]); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + expect(result[2]).toBeInstanceOf(RelatedModel); + expect(result[3]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( [ 'select "RelatedModel".*, "JoinModel"."ownerAId" as "objectiontmpjoin0", "JoinModel"."ownerBId" as "objectiontmpjoin1"', 'from "RelatedModel"', @@ -670,20 +670,21 @@ describe('ManyToManyRelation', () => { .orWhere('age', '>', 60) .select('name') .findOperationFactory(function () { - return relation.find(this, RelationOwner.create(owner)); + return (findOperation = relation.find(this, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.have.length(2); - expect(result).to.eql(expectedResult); - expect(owner.nameOfOurRelation).to.eql(expectedResult); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(2); + expect(result).toEqual(expectedResult); + expect(owner.nameOfOurRelation).toEqual(expectedResult); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( [ 'select "JoinModel"."ownerId" as "objectiontmpjoin0", "RelatedModel"."rid", "name"', 'from "RelatedModel"', @@ -712,20 +713,21 @@ describe('ManyToManyRelation', () => { .where('name', 'Teppo') .orWhere('age', '>', 60) .findOperationFactory(function () { - return relation.find(this, RelationOwner.create(owner)); + return (findOperation = relation.find(this, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.have.length(2); - expect(result).to.eql(expectedResult); - expect(owner.nameOfOurRelation).to.eql(expectedResult); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(2); + expect(result).toEqual(expectedResult); + expect(owner.nameOfOurRelation).toEqual(expectedResult); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( [ 'select "RelatedModel".*, "JoinModel"."ownerId" as "objectiontmpjoin0"', 'from "RelatedModel"', @@ -755,20 +757,21 @@ describe('ManyToManyRelation', () => { .where('name', 'Teppo') .orWhere('age', '>', 60) .findOperationFactory(function () { - return relation.find(this, RelationOwner.create(owner)); + return (findOperation = relation.find(this, RelationOwner.create(owner))); }); return builder.then((result) => { - expect(result).to.have.length(2); - expect(result).to.eql(expectedResult); - expect(owner.nameOfOurRelation).to.eql(expectedResult); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); - - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + findOperation.assignResult(builder, result); + expect(result).toHaveLength(2); + expect(result).toEqual(expectedResult); + expect(owner.nameOfOurRelation).toEqual(expectedResult); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); + + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( [ 'select "RelatedModel".*, "JoinModel"."ownerId" as "objectiontmpjoin0"', 'from "RelatedModel"', @@ -788,13 +791,13 @@ describe('ManyToManyRelation', () => { let owner = OwnerModel.fromJson({ oid: 666 }); let builder = QueryBuilder.forClass(RelatedModel).findOperationFactory(function () { - return relation.find(this, RelationOwner.create(owner)); + return (findOperation = relation.find(this, RelationOwner.create(owner))); }); return builder.then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe( [ 'select "RelatedModel".*, "JoinModel"."ownerId" as "objectiontmpjoin0"', 'from "RelatedModel"', @@ -829,29 +832,29 @@ describe('ManyToManyRelation', () => { let toSql = builder.toKnexQuery().toString(); return builder.then((result) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.equal(toString); - expect(executedQueries[0]).to.equal(toSql); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toBe(toString); + expect(executedQueries[0]).toBe(toSql); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "rid") values (\'str1\', 3), (\'str2\', 4) returning "id"', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'insert into "JoinModel" ("ownerId", "relatedId") values (666, 3), (666, 4) returning "relatedId"', ); - expect(sortBy(owner.nameOfOurRelation, 'id')).to.eql([ + expect(sortBy(owner.nameOfOurRelation, 'id')).toEqual([ { a: 'str1', id: 1, rid: 3 }, { a: 'str2', id: 2, rid: 4 }, { a: 'str0', id: 3 }, ]); - expect(result).to.eql([ + expect(result).toEqual([ { a: 'str1', id: 1, rid: 3 }, { a: 'str2', id: 2, rid: 4 }, ]); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); }); }); @@ -881,29 +884,29 @@ describe('ManyToManyRelation', () => { let toSql = builder.toKnexQuery().toString(); return builder.then((result) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.equal(toString); - expect(executedQueries[0]).to.equal(toSql); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toBe(toString); + expect(executedQueries[0]).toBe(toSql); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "cid", "did") values (\'str1\', 33, 44), (\'str2\', 33, 55) returning "id"', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'insert into "JoinModel" ("ownerAId", "ownerBId", "relatedCId", "relatedDId") values (11, 22, 33, 44), (11, 22, 33, 55) returning "relatedCId", "relatedDId"', ); - expect(sortBy(owner.nameOfOurRelation, 'id')).to.eql([ + expect(sortBy(owner.nameOfOurRelation, 'id')).toEqual([ { a: 'str1', id: 1, cid: 33, did: 44 }, { a: 'str2', id: 2, cid: 33, did: 55 }, { a: 'str0', id: 3 }, ]); - expect(result).to.eql([ + expect(result).toEqual([ { a: 'str1', id: 1, cid: 33, did: 44 }, { a: 'str2', id: 2, cid: 33, did: 55 }, ]); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); }); }); @@ -926,28 +929,28 @@ describe('ManyToManyRelation', () => { let toSql = builder.toKnexQuery().toString(); return builder.then((result) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.equal(toString); - expect(executedQueries[0]).to.equal(toSql); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toBe(toString); + expect(executedQueries[0]).toBe(toSql); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "rid") values (\'str1\', 3), (\'str2\', 4) returning "id"', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'insert into "JoinModel" ("ownerId", "relatedId") values (666, 3), (666, 4) returning "relatedId"', ); - expect(owner.nameOfOurRelation).to.eql([ + expect(owner.nameOfOurRelation).toEqual([ { a: 'str1', id: 1, rid: 3 }, { a: 'str2', id: 2, rid: 4 }, ]); - expect(result).to.eql([ + expect(result).toEqual([ { a: 'str1', id: 1, rid: 3 }, { a: 'str2', id: 2, rid: 4 }, ]); - expect(result[0]).to.be.a(RelatedModel); - expect(result[1]).to.be.a(RelatedModel); + expect(result[0]).toBeInstanceOf(RelatedModel); + expect(result[1]).toBeInstanceOf(RelatedModel); }); }); @@ -967,18 +970,18 @@ describe('ManyToManyRelation', () => { let toSql = builder.toKnexQuery().toString(); return builder.then((result) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.equal(toString); - expect(executedQueries[0]).to.equal(toSql); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toBe(toString); + expect(executedQueries[0]).toBe(toSql); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "rid") values (\'str1\', 2) returning "id"', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'insert into "JoinModel" ("ownerId", "relatedId") values (666, 2) returning "relatedId"', ); - expect(result).to.eql({ a: 'str1', id: 1, rid: 2 }); - expect(result).to.be.a(RelatedModel); + expect(result).toEqual({ a: 'str1', id: 1, rid: 2 }); + expect(result).toBeInstanceOf(RelatedModel); }); }); @@ -998,18 +1001,18 @@ describe('ManyToManyRelation', () => { let toSql = builder.toKnexQuery().toString(); return builder.then((result) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.equal(toString); - expect(executedQueries[0]).to.equal(toSql); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toBe(toString); + expect(executedQueries[0]).toBe(toSql); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "rid") values (\'str1\', 2) returning "id"', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'insert into "JoinModel" ("ownerId", "relatedId") values (666, 2) returning "relatedId"', ); - expect(result).to.eql({ a: 'str1', id: 1, rid: 2 }); - expect(result).to.be.a(RelatedModel); + expect(result).toEqual({ a: 'str1', id: 1, rid: 2 }); + expect(result).toBeInstanceOf(RelatedModel); }); }); @@ -1036,13 +1039,13 @@ describe('ManyToManyRelation', () => { let toSql = builder.toKnexQuery().toString(); return builder.then((result) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.equal(toString); - expect(executedQueries[0]).to.equal(toSql); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toBe(toString); + expect(executedQueries[0]).toBe(toSql); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "rid") values (\'str2\', 4) returning "id"', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'insert into "JoinModel" ("extra1", "extra2", "ownerId", "relatedId") values (\'extraVal1\', \'extraVal2\', 666, 4) returning "relatedId"', ); @@ -1051,13 +1054,13 @@ describe('ManyToManyRelation', () => { owner.nameOfOurRelation.map((it) => it.toJSON()), 'id', ), - ).to.eql([ + ).toEqual([ { a: 'str2', id: 1, rid: 4, extra1: 'extraVal1', extra2: 'extraVal2' }, { a: 'str0', id: 3 }, ]); - expect(result).to.be.a(RelatedModel); - expect(result.toJSON()).to.eql({ + expect(result).toBeInstanceOf(RelatedModel); + expect(result.toJSON()).toEqual({ a: 'str2', id: 1, rid: 4, @@ -1085,13 +1088,13 @@ describe('ManyToManyRelation', () => { let toSql = builder.toKnexQuery().toString(); return builder.then((result) => { - expect(executedQueries).to.have.length(2); - expect(executedQueries[0]).to.equal(toString); - expect(executedQueries[0]).to.equal(toSql); - expect(executedQueries[0]).to.equal( + expect(executedQueries).toHaveLength(2); + expect(executedQueries[0]).toBe(toString); + expect(executedQueries[0]).toBe(toSql); + expect(executedQueries[0]).toBe( 'insert into "RelatedModel" ("a", "rid") values (\'str2\', 4) returning "id"', ); - expect(executedQueries[1]).to.equal( + expect(executedQueries[1]).toBe( 'insert into "JoinModel" ("extra2", "ownerId", "relatedId") values (\'extraVal2\', 666, 4) returning "relatedId"', ); @@ -1100,13 +1103,13 @@ describe('ManyToManyRelation', () => { owner.nameOfOurRelation.map((it) => it.toJSON()), 'id', ), - ).to.eql([ + ).toEqual([ { a: 'str2', id: 1, rid: 4, extra2: 'extraVal2' }, { a: 'str0', id: 3 }, ]); - expect(result).to.be.a(RelatedModel); - expect(result.toJSON()).to.eql({ a: 'str2', id: 1, rid: 4, extra2: 'extraVal2' }); + expect(result).toBeInstanceOf(RelatedModel); + expect(result.toJSON()).toEqual({ a: 'str2', id: 1, rid: 4, extra2: 'extraVal2' }); }); }); }); @@ -1128,11 +1131,11 @@ describe('ManyToManyRelation', () => { .select('shouldBeIgnored'); return builder.then((numUpdated) => { - expect(numUpdated).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(numUpdated).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( `update "RelatedModel" set "a" = 'str1' where "RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "gender" = 'male' and "thingy" is not null)`, ); }); @@ -1154,11 +1157,11 @@ describe('ManyToManyRelation', () => { .select('shouldBeIgnored'); return builder.then((numUpdated) => { - expect(numUpdated).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(numUpdated).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( `update "RelatedModel" set "a" = 'str1' where "RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."cid" = "JoinModel"."relatedCId" and "RelatedModel"."did" = "JoinModel"."relatedDId" where ("JoinModel"."ownerAId", "JoinModel"."ownerBId") in ((11, 22)) and "gender" = 'male' and "thingy" is not null)`, ); }); @@ -1179,9 +1182,9 @@ describe('ManyToManyRelation', () => { .whereNotNull('thingy') .select('shouldBeIgnored') .then((numUpdated) => { - expect(numUpdated).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(numUpdated).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( `update "RelatedModel" set "a" = 'str1' where "RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "gender" = 'male' and "thingy" is not null)`, ); }); @@ -1201,8 +1204,8 @@ describe('ManyToManyRelation', () => { .whereNotNull('thingy') .select('shouldBeIgnored') .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( `update "RelatedModel" set "a" = 'str1' where "RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "someColumn" = 100 and "gender" = 'male' and "thingy" is not null)`, ); }); @@ -1226,11 +1229,11 @@ describe('ManyToManyRelation', () => { .select('shouldBeIgnored'); return builder.then((numUpdated) => { - expect(numUpdated).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(numUpdated).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( `update "RelatedModel" set "a" = 'str1' where "RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "gender" = 'male' and "thingy" is not null)`, ); }); @@ -1261,9 +1264,9 @@ describe('ManyToManyRelation', () => { .whereNotNull('thingy') .select('shouldBeIgnored') .then((numUpdated) => { - expect(numUpdated).to.equal(42); - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(numUpdated).toBe(42); + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( `update "RelatedModel" set "a" = 'str1' where "RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "gender" = 'male' and "thingy" is not null)`, ); }); @@ -1278,8 +1281,8 @@ describe('ManyToManyRelation', () => { }) .increment('test', 1) .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( `update "RelatedModel" set "test" = "test" + 1 where "RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666))`, ); }); @@ -1294,8 +1297,8 @@ describe('ManyToManyRelation', () => { }) .decrement('test', 10) .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( `update "RelatedModel" set "test" = "test" - 10 where "RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666))`, ); }); @@ -1316,8 +1319,8 @@ describe('ManyToManyRelation', () => { .whereNotNull('thingy') .select('shouldBeIgnored') .then(() => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toEqual( `update "RelatedModel" set "a" = 'str1' where "RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "someColumn" = 100 and "gender" = 'male' and "thingy" is not null)`, ); }); @@ -1338,12 +1341,12 @@ describe('ManyToManyRelation', () => { .select('shouldBeIgnored'); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql({}); + expect(executedQueries).toHaveLength(1); + expect(result).toEqual([]); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( `delete from "RelatedModel" where "RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "gender" = 'male' and "thingy" is not null)`, ); }); @@ -1362,12 +1365,12 @@ describe('ManyToManyRelation', () => { .select('shouldBeIgnored'); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql({}); + expect(executedQueries).toHaveLength(1); + expect(result).toEqual([]); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( `delete from "RelatedModel" where "RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."cid" = "JoinModel"."relatedCId" and "RelatedModel"."did" = "JoinModel"."relatedDId" where ("JoinModel"."ownerAId", "JoinModel"."ownerBId") in ((11, 22)) and "gender" = 'male' and "thingy" is not null)`, ); }); @@ -1386,9 +1389,9 @@ describe('ManyToManyRelation', () => { .whereNotNull('thingy') .select('shouldBeIgnored') .then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql({}); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(result).toEqual([]); + expect(executedQueries[0]).toEqual( `delete from "RelatedModel" where "RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "someColumn" = 100 and "gender" = 'male' and "thingy" is not null)`, ); }); @@ -1405,7 +1408,7 @@ describe('ManyToManyRelation', () => { .delete() .where('gender', 'male') .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `delete from "RelatedModel" where "RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "gender" = 'male' order by "someColumn" asc)`, ]); }); @@ -1424,12 +1427,12 @@ describe('ManyToManyRelation', () => { .relate(10); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(1); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( [ 'insert into "JoinModel" ("ownerId", "relatedId") values (666, 10) returning "relatedId"', ].join(' '), @@ -1448,12 +1451,12 @@ describe('ManyToManyRelation', () => { .relate([10, 20, 30]); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(3); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(3); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( [ 'insert into "JoinModel" ("ownerId", "relatedId") values (666, 10), (666, 20), (666, 30) returning "relatedId"', ].join(' '), @@ -1472,12 +1475,12 @@ describe('ManyToManyRelation', () => { .relate([{ rid: 10 }, { rid: 20 }, { rid: 30 }]); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(3); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(3); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( [ 'insert into "JoinModel" ("ownerId", "relatedId") values (666, 10), (666, 20), (666, 30) returning "relatedId"', ].join(' '), @@ -1506,12 +1509,12 @@ describe('ManyToManyRelation', () => { ]); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(3); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(3); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( [ 'insert into "JoinModel" ("ownerAId", "ownerBId", "relatedCId", "relatedDId") values (11, 22, 33, 44), (11, 22, 33, 55), (11, 22, 66, 77) returning "relatedCId", "relatedDId"', ].join(' '), @@ -1540,12 +1543,12 @@ describe('ManyToManyRelation', () => { ]); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.equal(3); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(3); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( [ 'insert into "JoinModel" ("ownerAId", "ownerBId", "relatedCId", "relatedDId") values (11, 22, 33, 44), (11, 22, 33, 55), (11, 22, 66, 77) returning "relatedCId", "relatedDId"', ].join(' '), @@ -1563,9 +1566,9 @@ describe('ManyToManyRelation', () => { }) .relate(11) .then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(1); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(result).toBe(1); + expect(executedQueries[0]).toEqual( 'insert into "JoinModel" ("ownerId", "relatedId") values (666, 11) returning "relatedId"', ); }); @@ -1582,12 +1585,12 @@ describe('ManyToManyRelation', () => { .relate({ rid: 10, extra2: 'foo', shouldNotBeInQuery: 'bar' }); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(1); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( [ 'insert into "JoinModel" ("extra2", "ownerId", "relatedId") values (\'foo\', 666, 10) returning "relatedId"', ].join(' '), @@ -1612,16 +1615,16 @@ describe('ManyToManyRelation', () => { .returning('*'); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries).toHaveLength(1); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( 'insert into "JoinModel" ("ownerId", "relatedId") values (666, 10), (666, 20) returning *', ); - expect(result).to.have.length(2); - expect(result[0]).to.be.a(relation.joinModelClass); - expect(result[0].toJSON()).to.eql({ id: 1, ownerId: 666, relatedId: 10 }); - expect(result[1].toJSON()).to.eql({ id: 2, ownerId: 666, relatedId: 20 }); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(relation.joinModelClass); + expect(result[0].toJSON()).toEqual({ id: 1, ownerId: 666, relatedId: 10 }); + expect(result[1].toJSON()).toEqual({ id: 2, ownerId: 666, relatedId: 20 }); }); }); }); @@ -1640,12 +1643,12 @@ describe('ManyToManyRelation', () => { .whereIn('code', [55, 66, 77]); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql(123); + expect(executedQueries).toHaveLength(1); + expect(result).toBe(123); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( `delete from "JoinModel" where "JoinModel"."ownerId" in (666) and exists (select 1 from "RelatedModel" where "RelatedModel"."rid" = "JoinModel"."relatedId" and "someColumn" = 100 and "code" in (55, 66, 77))`, ); }); @@ -1663,12 +1666,12 @@ describe('ManyToManyRelation', () => { .where('someColumn', 100); return builder.then((result) => { - expect(executedQueries).to.have.length(1); - expect(result).to.eql({}); + expect(executedQueries).toHaveLength(1); + expect(result).toEqual([]); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.equal(builder.toKnexQuery().toString()); - expect(executedQueries[0]).to.eql( + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toBe(builder.toKnexQuery().toString()); + expect(executedQueries[0]).toEqual( `delete from "JoinModel" where ("JoinModel"."ownerAId", "JoinModel"."ownerBId") in ((11, 22)) and exists (select 1 from "RelatedModel" where "RelatedModel"."cid" = "JoinModel"."relatedCId" and "RelatedModel"."did" = "JoinModel"."relatedDId" and "code" in (55, 66, 77) and "someColumn" = 100)`, ); }); @@ -1686,9 +1689,9 @@ describe('ManyToManyRelation', () => { .where('RelatedModel.code', 55) .where('JoinModel.extra1', 'foo') .then((result) => { - expect(result).to.equal(1); + expect(result).toBe(1); // Only the join rows that match the filters themselves are deleted. - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `delete from "JoinModel" where "JoinModel"."ownerId" in (666) and exists (select 1 from "RelatedModel" where "RelatedModel"."rid" = "JoinModel"."relatedId" and "RelatedModel"."code" = 55 and "JoinModel"."extra1" = 'foo')`, ]); }); @@ -1706,7 +1709,7 @@ describe('ManyToManyRelation', () => { .unrelate() .where('jm.extra1', 'foo') .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `delete from "JoinModel" as "jm" where "jm"."ownerId" in (666) and exists (select 1 from "RelatedModel" where "RelatedModel"."rid" = "jm"."relatedId" and "jm"."extra1" = 'foo')`, ]); }); @@ -1730,7 +1733,7 @@ describe('ManyToManyRelation', () => { .unrelate() .where('RelatedModel.code', 55) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `delete from "JoinModel" where "JoinModel"."ownerId" in (666) and exists (select 1 from "RelatedModel" where "RelatedModel"."rid" = "JoinModel"."relatedId" and "RelatedModel"."code" = 55 order by "RelatedModel"."rid" asc)`, ]); }); @@ -1750,11 +1753,11 @@ describe('ManyToManyRelation', () => { .where('RelatedModel.code', 55) .where('JoinModel.extra1', 'foo') .then((result) => { - expect(result).to.equal(1); - expect(executedQueries).to.eql([ + expect(result).toBe(1); + expect(executedQueries).toEqual([ `select distinct "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "RelatedModel"."code" = 55 and "JoinModel"."extra1" = 'foo'`, `update "JoinModel" set "extra2" = 'extraVal' where "JoinModel"."ownerId" in (666) and exists (select 1 from "RelatedModel" where "RelatedModel"."rid" = "JoinModel"."relatedId" and "RelatedModel"."code" = 55 and "JoinModel"."extra1" = 'foo')`, - `update "RelatedModel" set "a" = 'str1' where ("RelatedModel"."id" in (5))`, + `update "RelatedModel" set "a" = 'str1' where ("RelatedModel"."id" = any('{5}'))`, ]); }); }); @@ -1770,21 +1773,19 @@ describe('ManyToManyRelation', () => { .patch({ a: 'str1', extra2: 'extraVal' }) .where('RelatedModel.code', 55) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `select distinct "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "RelatedModel"."code" = 55`, `update "JoinModel" set "extra2" = 'extraVal' where "JoinModel"."ownerId" in (666) and exists (select 1 from "RelatedModel" where "RelatedModel"."rid" = "JoinModel"."relatedId" and "RelatedModel"."code" = 55)`, - `update "RelatedModel" set "a" = 'str1' where (1 = 0)`, + `update "RelatedModel" set "a" = 'str1' where ("RelatedModel"."id" = any('{}'))`, ]); }); }); - describe('in batches of ids (#161)', () => { + describe('by ids (#161, #287)', () => { const owner = () => RelationOwner.create(OwnerModel.fromJson({ oid: 666 })); const range = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i); const selectIds = `select distinct "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "RelatedModel"."code" = 55`; const patchJoinRows = `update "JoinModel" set "extra2" = 'extraVal' where "JoinModel"."ownerId" in (666) and exists (select 1 from "RelatedModel" where "RelatedModel"."rid" = "JoinModel"."relatedId" and "RelatedModel"."code" = 55)`; - const patchRelated = (ids) => - `update "RelatedModel" set "a" = 'str1' where ("RelatedModel"."id" in (${ids.join(', ')}))`; function patch() { return QueryBuilder.forClass(RelatedModel) @@ -1793,42 +1794,21 @@ describe('ManyToManyRelation', () => { .where('RelatedModel.code', 55); } - it('should patch the related rows in batches of 500 ids and sum the counts', async () => { - const ids = range(1, 1001); - mockKnexQueryResults = [ids.map((id) => ({ id })), 1001, 500, 500, 1]; + it('should patch the related rows by all ids bound as one array', async () => { + const ids = range(1, 2101); + mockKnexQueryResults = [ids.map((id) => ({ id })), 2101, 2101]; const result = await patch(); - expect(result).to.equal(1001); - expect(executedQueries).to.eql([ + expect(result).toBe(2101); + expect(executedQueries).toEqual([ selectIds, patchJoinRows, - patchRelated(range(1, 500)), - patchRelated(range(501, 1000)), - patchRelated([1001]), - ]); - }); - - it('should concatenate the returned rows of all batches', async () => { - const ids = range(1, 501); - mockKnexQueryResults = [ - ids.map((id) => ({ id })), - 501, - range(1, 500).map((id) => ({ id, a: 'str1' })), - [{ id: 501, a: 'str1' }], - ]; - - const result = await patch().returning('*'); - - expect(result.map((model) => model.id)).to.eql(ids); - expect(result.every((model) => model instanceof RelatedModel)).to.equal(true); - expect(executedQueries.slice(2)).to.eql([ - `${patchRelated(range(1, 500))} returning *`, - `${patchRelated([501])} returning *`, + `update "RelatedModel" set "a" = 'str1' where ("RelatedModel"."id" = any('{${ids.join(',')}}'))`, ]); }); - it('should count each column of composite ids', async () => { + it('should bind one value per column of composite ids', async () => { class CompositeRelatedModel extends RelatedModel { static get idColumn() { return ['cid', 'did']; @@ -1850,41 +1830,44 @@ describe('ManyToManyRelation', () => { }, }); - const ids = range(1, 251).map((id) => [id, id + 1000]); - mockKnexQueryResults = [ids.map(([cid, did]) => ({ cid, did })), 251, 250, 1]; - const values = (ids) => ids.map(([cid, did]) => `(${cid}, ${did})`).join(', '); + mockKnexQueryResults = [ + [ + { cid: 1, did: 'a' }, + { cid: 2, did: 'b' }, + ], + 2, + 2, + ]; const result = await QueryBuilder.forClass(CompositeRelatedModel) .patchOperationFactory((builder) => compositeRelation.patch(builder, owner())) .patch({ a: 'str1', extra2: 'extraVal' }); - expect(result).to.equal(251); - expect(executedQueries.slice(2)).to.eql([ - `update "RelatedModel" set "a" = 'str1' where (("RelatedModel"."cid", "RelatedModel"."did") in (${values(ids.slice(0, 250))}))`, - `update "RelatedModel" set "a" = 'str1' where (("RelatedModel"."cid", "RelatedModel"."did") in (${values(ids.slice(250))}))`, - ]); + expect(result).toBe(2); + expect(executedQueries[2]).toBe( + `update "RelatedModel" set "a" = 'str1' where (("RelatedModel"."cid", "RelatedModel"."did") in ((1, 'a'), (2, 'b')))`, + ); }); - it('should filter the find query of the static hooks by all ids in one statement', async () => { - const ids = range(1, 501); + it('should filter the find query of the static hooks by all ids bound as one array', async () => { + const ids = range(1, 2101); let findQuery = null; // The model classes are created anew for each test. RelatedModel.afterUpdate = ({ asFindQuery }) => { findQuery = asFindQuery().select('RelatedModel.id').toKnexQuery().toString(); }; - mockKnexQueryResults = [ids.map((id) => ({ id })), 501, 500, 1]; + mockKnexQueryResults = [ids.map((id) => ({ id })), 2101, 2101]; await patch(); - expect(executedQueries.length).to.equal(4); - expect(findQuery).to.equal( - `select "JoinModel"."ownerId" as "objectiontmpjoin0", "RelatedModel"."rid", "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "RelatedModel"."id" in (${ids.join(', ')})`, + expect(findQuery).toBe( + `select "JoinModel"."ownerId" as "objectiontmpjoin0", "RelatedModel"."rid", "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and ("RelatedModel"."id" = any('{${ids.join(',')}}'))`, ); }); it('should build a single statement with the filter subquery', () => { - expect(patch().toKnexQuery().toString()).to.equal( + expect(patch().toKnexQuery().toString()).toBe( `update "RelatedModel" set "a" = 'str1' where ("RelatedModel"."id" in (select "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "RelatedModel"."code" = 55))`, ); }); @@ -1901,7 +1884,7 @@ describe('ManyToManyRelation', () => { .patch({ extra2: 'extraVal' }) .where('RelatedModel.code', 55) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `select distinct "RelatedModel"."id" from "RelatedModel" inner join "JoinModel" on "RelatedModel"."rid" = "JoinModel"."relatedId" where "JoinModel"."ownerId" in (666) and "RelatedModel"."code" = 55`, `update "JoinModel" set "extra2" = 'extraVal' where "JoinModel"."ownerId" in (666) and exists (select 1 from "RelatedModel" where "RelatedModel"."rid" = "JoinModel"."relatedId" and "RelatedModel"."code" = 55)`, ]); @@ -1936,8 +1919,8 @@ describe('ManyToManyRelation', () => { return unrelate(QueryBuilder.forClass(RelatedModel)) .whereIn('code', [55, 66, 77]) .then((result) => { - expect(result).to.equal(2); - expect(executedQueries).to.eql([ + expect(result).toBe(2); + expect(executedQueries).toEqual([ 'select `JoinModel`.`ownerId`, `JoinModel`.`relatedId` from `JoinModel` where `JoinModel`.`ownerId` in (666) and exists (select 1 from `RelatedModel` where `RelatedModel`.`rid` = `JoinModel`.`relatedId` and `code` in (55, 66, 77))', 'delete from `JoinModel` where `JoinModel`.`ownerId` in (666) and (`JoinModel`.`ownerId`, `JoinModel`.`relatedId`) in ((666, 5), (666, 6))', ]); @@ -1961,8 +1944,8 @@ describe('ManyToManyRelation', () => { .unrelate() .where('someColumn', 100) .then((result) => { - expect(result).to.equal(2); - expect(executedQueries).to.eql([ + expect(result).toBe(2); + expect(executedQueries).toEqual([ 'select `JoinModel`.`ownerAId`, `JoinModel`.`ownerBId`, `JoinModel`.`relatedCId`, `JoinModel`.`relatedDId` from `JoinModel` where (`JoinModel`.`ownerAId`, `JoinModel`.`ownerBId`) in ((11, 22)) and exists (select 1 from `RelatedModel` where `RelatedModel`.`cid` = `JoinModel`.`relatedCId` and `RelatedModel`.`did` = `JoinModel`.`relatedDId` and `someColumn` = 100)', 'delete from `JoinModel` where (`JoinModel`.`ownerAId`, `JoinModel`.`ownerBId`) in ((11, 22)) and (`JoinModel`.`ownerAId`, `JoinModel`.`ownerBId`, `JoinModel`.`relatedCId`, `JoinModel`.`relatedDId`) in ((11, 22, 1, 2), (11, 22, 3, 4))', ]); @@ -1975,8 +1958,8 @@ describe('ManyToManyRelation', () => { return unrelate(QueryBuilder.forClass(RelatedModel)) .where('code', 55) .then((result) => { - expect(result).to.equal(0); - expect(executedQueries.slice(1)).to.eql([ + expect(result).toBe(0); + expect(executedQueries.slice(1)).toEqual([ 'delete from `JoinModel` where `JoinModel`.`ownerId` in (666) and 1 = 0', ]); }); @@ -1993,8 +1976,8 @@ describe('ManyToManyRelation', () => { .patch({ a: 'str1', extra2: 'extraVal' }) .where('code', 55) .then((result) => { - expect(result).to.equal(1); - expect(executedQueries).to.eql([ + expect(result).toBe(1); + expect(executedQueries).toEqual([ 'select distinct `RelatedModel`.`id` from `RelatedModel` inner join `JoinModel` on `RelatedModel`.`rid` = `JoinModel`.`relatedId` where `JoinModel`.`ownerId` in (666) and `code` = 55', 'select `JoinModel`.`ownerId`, `JoinModel`.`relatedId` from `JoinModel` where `JoinModel`.`ownerId` in (666) and exists (select 1 from `RelatedModel` where `RelatedModel`.`rid` = `JoinModel`.`relatedId` and `code` = 55)', "update `JoinModel` set `extra2` = 'extraVal' where `JoinModel`.`ownerId` in (666) and (`JoinModel`.`ownerId`, `JoinModel`.`relatedId`) in ((666, 5))", @@ -2005,10 +1988,13 @@ describe('ManyToManyRelation', () => { it('patch with extras should pick the related rows with the limit and only patch their join rows', () => { mockKnexQueryResults = [ - [{ id: 5 }, { id: 6 }], [ - { ownerId: 666, relatedId: 5 }, - { ownerId: 666, relatedId: 6 }, + { id: 5, objectiontmprelated0: 15 }, + { id: 6, objectiontmprelated0: 16 }, + ], + [ + { ownerId: 666, relatedId: 15 }, + { ownerId: 666, relatedId: 16 }, ], 2, 2, @@ -2025,12 +2011,13 @@ describe('ManyToManyRelation', () => { .limit(2) .offset(1) .then((result) => { - expect(result).to.equal(2); - // The limit picks the related rows instead of limiting the update. - expect(executedQueries).to.eql([ - 'select `RelatedModel`.`id` from `RelatedModel` where `RelatedModel`.`id` in (select `RelatedModel`.`id` from `RelatedModel` inner join `JoinModel` on `RelatedModel`.`rid` = `JoinModel`.`relatedId` where `JoinModel`.`ownerId` in (666) and `code` = 55) order by `a` asc limit 2 offset 1', - 'select `JoinModel`.`ownerId`, `JoinModel`.`relatedId` from `JoinModel` where `JoinModel`.`ownerId` in (666) and `JoinModel`.`relatedId` in (5, 6) and exists (select 1 from `RelatedModel` where `RelatedModel`.`rid` = `JoinModel`.`relatedId` and `code` = 55)', - "update `JoinModel` set `extra2` = 'extraVal' where `JoinModel`.`ownerId` in (666) and (`JoinModel`.`ownerId`, `JoinModel`.`relatedId`) in ((666, 5), (666, 6)) and `JoinModel`.`relatedId` in (5, 6)", + expect(result).toBe(2); + // The limit picks the related rows by their first join row instead of + // limiting the update, and the join rows by their `rid` (#360). + expect(executedQueries).toEqual([ + 'select `id`, `objectiontmprelated0` from (select `RelatedModel`.`id`, `RelatedModel`.`rid` as `objectiontmprelated0`, row_number() over (order by `RelatedModel`.`a` asc) as `objectiontmprownum` from `RelatedModel` inner join `JoinModel` on `RelatedModel`.`rid` = `JoinModel`.`relatedId` where `JoinModel`.`ownerId` in (666) and `code` = 55) as `objectiontmprownum` group by `id`, `objectiontmprelated0` order by min(`objectiontmprownum`) limit 2 offset 1', + 'select `JoinModel`.`ownerId`, `JoinModel`.`relatedId` from `JoinModel` where `JoinModel`.`ownerId` in (666) and `JoinModel`.`relatedId` in (15, 16) and exists (select 1 from `RelatedModel` where `RelatedModel`.`rid` = `JoinModel`.`relatedId` and `code` = 55)', + "update `JoinModel` set `extra2` = 'extraVal' where `JoinModel`.`ownerId` in (666) and (`JoinModel`.`ownerId`, `JoinModel`.`relatedId`) in ((666, 15), (666, 16)) and `JoinModel`.`relatedId` in (15, 16)", "update `RelatedModel` set `a` = 'str1' where (`RelatedModel`.`id` in (5, 6))", ]); }); @@ -2047,8 +2034,8 @@ describe('ManyToManyRelation', () => { .delete() .where('code', 55) .then((result) => { - expect(result).to.equal(3); - expect(executedQueries).to.eql([ + expect(result).toBe(3); + expect(executedQueries).toEqual([ 'delete from `RelatedModel` where `RelatedModel`.`id` in (select * from (select `RelatedModel`.`id` from `RelatedModel` inner join `JoinModel` on `RelatedModel`.`rid` = `JoinModel`.`relatedId` where `JoinModel`.`ownerId` in (666) and `code` = 55) as `mysql_subquery_fix`)', ]); }); @@ -2064,8 +2051,8 @@ describe('ManyToManyRelation', () => { .andOnIn('Other.code', OwnerModel.query().select('code')); }) .then((result) => { - expect(result).to.equal(1); - expect(executedQueries).to.eql([ + expect(result).toBe(1); + expect(executedQueries).toEqual([ 'select `JoinModel`.`ownerId`, `JoinModel`.`relatedId` from `JoinModel` where `JoinModel`.`ownerId` in (666) and exists (select 1 from `RelatedModel` inner join `Other` on `Other`.`id` = `RelatedModel`.`otherId` and `Other`.`code` in (select `code` from `OwnerModel`) where `RelatedModel`.`rid` = `JoinModel`.`relatedId`)', 'delete from `JoinModel` where `JoinModel`.`ownerId` in (666) and (`JoinModel`.`ownerId`, `JoinModel`.`relatedId`) in ((666, 5))', ]); @@ -2089,7 +2076,7 @@ describe('ManyToManyRelation', () => { .delete() .where('gender', 'male') .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ "delete from [RelatedModel] where [RelatedModel].[id] in ((select [RelatedModel].[id] from [RelatedModel] inner join [JoinModel] on [RelatedModel].[rid] = [JoinModel].[relatedId] where [JoinModel].[ownerId] in (666) and [gender] = 'male'));select @@rowcount", ]); }); @@ -2105,7 +2092,7 @@ describe('ManyToManyRelation', () => { }) .delete() .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'delete from [RelatedModel] where [RelatedModel].[id] in ((select top (10) [RelatedModel].[id] from [RelatedModel] inner join [JoinModel] on [RelatedModel].[rid] = [JoinModel].[relatedId] where [JoinModel].[ownerId] in (666) order by [someColumn] asc));select @@rowcount', ]); }); @@ -2129,7 +2116,7 @@ describe('ManyToManyRelation', () => { .unrelate() .where('RelatedModel.code', 55) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'delete from [JoinModel] where [JoinModel].[ownerId] in (666) and exists (select 1 from [RelatedModel] where [RelatedModel].[rid] = [JoinModel].[relatedId] and [RelatedModel].[code] = 55);select @@rowcount', ]); }); @@ -2146,7 +2133,7 @@ describe('ManyToManyRelation', () => { return relation.find(builder, RelationOwner.create(owner)); }) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ "select [RelatedModel].*, [JoinModel].[ownerId] as [objectiontmpjoin0] from [RelatedModel] inner join (select [JoinModel].* from [JoinModel] where [someColumn] = 'foo') as [JoinModel] on [RelatedModel].[rid] = [JoinModel].[relatedId] where [JoinModel].[ownerId] in (666)", ]); }); @@ -2169,7 +2156,7 @@ describe('ManyToManyRelation', () => { }, }; - expect(OwnerModel.query().joinRelated('related').toKnexQuery().toString()).to.equal( + expect(OwnerModel.query().joinRelated('related').toKnexQuery().toString()).toBe( "select [OwnerModel].* from [OwnerModel] inner join (select [JoinModel].* from [JoinModel] where [someColumn] = 'foo') as [related_join] on [related_join].[ownerId] = [OwnerModel].[oid] inner join [RelatedModel] as [related] on [related_join].[relatedId] = [related].[rid]", ); }); @@ -2186,7 +2173,7 @@ describe('ManyToManyRelation', () => { .where('JoinModel.extra1', 'foo') .orWhere('code', 55) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ "delete from [JoinModel] where exists (select * from (VALUES (11,22)) AS V([objectiontmpjoin0],[objectiontmpjoin1]) where [objectiontmpjoin0] = [JoinModel].[ownerAId] and [objectiontmpjoin1] = [JoinModel].[ownerBId]) and exists (select 1 from [RelatedModel] where [RelatedModel].[cid] = [JoinModel].[relatedCId] and [RelatedModel].[did] = [JoinModel].[relatedDId] and ([JoinModel].[extra1] = 'foo' or [code] = 55));select @@rowcount", ]); }); @@ -2203,7 +2190,7 @@ describe('ManyToManyRelation', () => { .patch({ extra2: 'extraVal' }) .where('code', 55) .then(() => { - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ 'select distinct [RelatedModel].[id] from [RelatedModel] inner join [JoinModel] on [RelatedModel].[rid] = [JoinModel].[relatedId] where [JoinModel].[ownerId] in (666) and [code] = 55', "update [JoinModel] set [extra2] = 'extraVal' where [JoinModel].[ownerId] in (666) and exists (select 1 from [RelatedModel] where [RelatedModel].[rid] = [JoinModel].[relatedId] and [code] = 55);select @@rowcount", ]); @@ -2227,8 +2214,8 @@ describe('ManyToManyRelation', () => { .unrelate() .whereIn('code', [55, 66, 77]) .then((result) => { - expect(result).to.equal(2); - expect(executedQueries).to.eql([ + expect(result).toBe(2); + expect(executedQueries).toEqual([ 'delete from `JoinModel` where `JoinModel`.`ownerId` in (666) and exists (select 1 from `RelatedModel` where `RelatedModel`.`rid` = `JoinModel`.`relatedId` and `code` in (55, 66, 77))', ]); }); @@ -2245,8 +2232,8 @@ describe('ManyToManyRelation', () => { .patch({ a: 'str1' }) .where('code', 55) .then((result) => { - expect(result).to.equal(1); - expect(executedQueries).to.eql([ + expect(result).toBe(1); + expect(executedQueries).toEqual([ "update `RelatedModel` set `a` = 'str1' where `RelatedModel`.`_rowid_` in (select `RelatedModel`.`_rowid_` from `RelatedModel` inner join `JoinModel` on `RelatedModel`.`rid` = `JoinModel`.`relatedId` where `JoinModel`.`ownerId` in (666) and `code` = 55)", ]); }); @@ -2263,11 +2250,11 @@ describe('ManyToManyRelation', () => { .patch({ a: 'str1', extra2: 'extraVal' }) .where('code', 55) .then((result) => { - expect(result).to.equal(1); - expect(executedQueries).to.eql([ + expect(result).toBe(1); + expect(executedQueries).toEqual([ 'select distinct `RelatedModel`.`id` from `RelatedModel` inner join `JoinModel` on `RelatedModel`.`rid` = `JoinModel`.`relatedId` where `JoinModel`.`ownerId` in (666) and `code` = 55', "update `JoinModel` set `extra2` = 'extraVal' where `JoinModel`.`ownerId` in (666) and exists (select 1 from `RelatedModel` where `RelatedModel`.`rid` = `JoinModel`.`relatedId` and `code` = 55)", - "update `RelatedModel` set `a` = 'str1' where (`RelatedModel`.`id` in (5))", + "update `RelatedModel` set `a` = 'str1' where (`RelatedModel`.`id` in (select value from json_each('[5]')))", ]); }); }); @@ -2283,8 +2270,8 @@ describe('ManyToManyRelation', () => { .delete() .where('code', 55) .then((result) => { - expect(result).to.equal(3); - expect(executedQueries).to.eql([ + expect(result).toBe(3); + expect(executedQueries).toEqual([ 'delete from `RelatedModel` where `RelatedModel`.`_rowid_` in (select `RelatedModel`.`_rowid_` from `RelatedModel` inner join `JoinModel` on `RelatedModel`.`rid` = `JoinModel`.`relatedId` where `JoinModel`.`ownerId` in (666) and `code` = 55)', ]); }); diff --git a/tests/unit/relations/Relation.js b/tests/unit/relations/Relation.js index 2ec8283bf..e70b37b9e 100644 --- a/tests/unit/relations/Relation.js +++ b/tests/unit/relations/Relation.js @@ -1,6 +1,12 @@ -const Knex = require('knex'); -const expect = require('expect.js'); -const objection = require('../../../'); +import { describe, it, expect, beforeEach } from 'vitest'; +import Knex from 'knex'; +import * as objection from 'objection'; +import { createRequire } from 'node:module'; +import EsmRelatedModel from './files/esm/RelatedModel.js'; +import { RelatedModel as EsmRelatedModelNamedExport } from './files/esm/RelatedModelNamedExport.js'; + +// The model files in ./files are CommonJS modules. +const require = createRequire(import.meta.url); // Converts snake_case and kebab-case keys to camelCase. const camelCase = (str) => str.replace(/[-_]+(.)/g, (match, char) => char.toUpperCase()); @@ -12,12 +18,14 @@ describe('Relation', () => { let RelatedModelNamedExport = null; beforeEach(() => { - delete require.cache[__dirname + '/files/OwnerModel.js']; - delete require.cache[__dirname + '/files/RelatedModel.js']; - - OwnerModel = require(__dirname + '/files/OwnerModel'); - RelatedModel = require(__dirname + '/files/RelatedModel'); - RelatedModelNamedExport = require(__dirname + '/files/RelatedModelNamedExport').RelatedModel; + delete require.cache[import.meta.dirname + '/files/OwnerModel.js']; + delete require.cache[import.meta.dirname + '/files/RelatedModel.js']; + + OwnerModel = require(import.meta.dirname + '/files/OwnerModel'); + RelatedModel = require(import.meta.dirname + '/files/RelatedModel'); + RelatedModelNamedExport = require( + import.meta.dirname + '/files/RelatedModelNamedExport', + ).RelatedModel; }); it('should accept a Model subclass as modelClass', () => { @@ -32,12 +40,12 @@ describe('Relation', () => { }, }); - expect(relation.ownerModelClass).to.equal(OwnerModel); - expect(relation.relatedModelClass).to.equal(RelatedModel); - expect(relation.ownerProp.cols).to.eql(['id']); - expect(relation.ownerProp.props).to.eql(['id']); - expect(relation.relatedProp.cols).to.eql(['ownerId']); - expect(relation.relatedProp.props).to.eql(['ownerId']); + expect(relation.ownerModelClass).toBe(OwnerModel); + expect(relation.relatedModelClass).toBe(RelatedModel); + expect(relation.ownerProp.cols).toEqual(['id']); + expect(relation.ownerProp.props).toEqual(['id']); + expect(relation.relatedProp.cols).toEqual(['ownerId']); + expect(relation.relatedProp.props).toEqual(['ownerId']); }); it('should accept a function returning Model subclass as modelClass', () => { @@ -52,12 +60,12 @@ describe('Relation', () => { }, }); - expect(relation.ownerModelClass).to.equal(OwnerModel); - expect(relation.relatedModelClass).to.equal(RelatedModel); - expect(relation.ownerProp.cols).to.eql(['id']); - expect(relation.ownerProp.props).to.eql(['id']); - expect(relation.relatedProp.cols).to.eql(['ownerId']); - expect(relation.relatedProp.props).to.eql(['ownerId']); + expect(relation.ownerModelClass).toBe(OwnerModel); + expect(relation.relatedModelClass).toBe(RelatedModel); + expect(relation.ownerProp.cols).toEqual(['id']); + expect(relation.ownerProp.props).toEqual(['id']); + expect(relation.relatedProp.cols).toEqual(['ownerId']); + expect(relation.relatedProp.props).toEqual(['ownerId']); }); it('should accept a path to a Model subclass as modelClass', () => { @@ -65,23 +73,23 @@ describe('Relation', () => { relation.setMapping({ relation: Relation, - modelClass: __dirname + '/files/RelatedModel', + modelClass: import.meta.dirname + '/files/RelatedModel', join: { from: 'OwnerModel.id', to: 'RelatedModel.ownerId', }, }); - expect(relation.ownerModelClass).to.equal(OwnerModel); - expect(relation.relatedModelClass).to.equal(RelatedModel); - expect(relation.ownerProp.cols).to.eql(['id']); - expect(relation.ownerProp.props).to.eql(['id']); - expect(relation.relatedProp.cols).to.eql(['ownerId']); - expect(relation.relatedProp.props).to.eql(['ownerId']); + expect(relation.ownerModelClass).toBe(OwnerModel); + expect(relation.relatedModelClass).toBe(RelatedModel); + expect(relation.ownerProp.cols).toEqual(['id']); + expect(relation.ownerProp.props).toEqual(['id']); + expect(relation.relatedProp.cols).toEqual(['ownerId']); + expect(relation.relatedProp.props).toEqual(['ownerId']); }); it('should accept a relative path to a Model subclass as modelClass (resolved using Model.modelPaths)', () => { - OwnerModel.modelPaths = [__dirname + '/files/']; + OwnerModel.modelPaths = [import.meta.dirname + '/files/']; let relation = new Relation('testRelation', OwnerModel); relation.setMapping({ @@ -93,16 +101,16 @@ describe('Relation', () => { }, }); - expect(relation.ownerModelClass).to.equal(OwnerModel); - expect(relation.relatedModelClass).to.equal(RelatedModel); - expect(relation.ownerProp.cols).to.eql(['id']); - expect(relation.ownerProp.props).to.eql(['id']); - expect(relation.relatedProp.cols).to.eql(['ownerId']); - expect(relation.relatedProp.props).to.eql(['ownerId']); + expect(relation.ownerModelClass).toBe(OwnerModel); + expect(relation.relatedModelClass).toBe(RelatedModel); + expect(relation.ownerProp.cols).toEqual(['id']); + expect(relation.ownerProp.props).toEqual(['id']); + expect(relation.relatedProp.cols).toEqual(['ownerId']); + expect(relation.relatedProp.props).toEqual(['ownerId']); }); it('multiple items in `Model.modelPaths` should work', () => { - OwnerModel.modelPaths = [__dirname, __dirname + '/files/']; + OwnerModel.modelPaths = [import.meta.dirname, import.meta.dirname + '/files/']; let relation = new Relation('testRelation', OwnerModel); @@ -115,12 +123,12 @@ describe('Relation', () => { }, }); - expect(relation.ownerModelClass).to.equal(OwnerModel); - expect(relation.relatedModelClass).to.equal(RelatedModel); - expect(relation.ownerProp.cols).to.eql(['id']); - expect(relation.ownerProp.props).to.eql(['id']); - expect(relation.relatedProp.cols).to.eql(['ownerId']); - expect(relation.relatedProp.props).to.eql(['ownerId']); + expect(relation.ownerModelClass).toBe(OwnerModel); + expect(relation.relatedModelClass).toBe(RelatedModel); + expect(relation.ownerProp.cols).toEqual(['id']); + expect(relation.ownerProp.props).toEqual(['id']); + expect(relation.relatedProp.cols).toEqual(['ownerId']); + expect(relation.relatedProp.props).toEqual(['ownerId']); }); it('should accept a module with named exports', () => { @@ -128,19 +136,66 @@ describe('Relation', () => { relation.setMapping({ relation: Relation, - modelClass: __dirname + '/files/RelatedModelNamedExport', + modelClass: import.meta.dirname + '/files/RelatedModelNamedExport', + join: { + from: 'OwnerModel.id', + to: 'RelatedModel.ownerId', + }, + }); + + expect(relation.ownerModelClass).toBe(OwnerModel); + expect(relation.relatedModelClass).toBe(RelatedModelNamedExport); + expect(relation.ownerProp.cols).toEqual(['id']); + expect(relation.ownerProp.props).toEqual(['id']); + expect(relation.relatedProp.cols).toEqual(['ownerId']); + expect(relation.relatedProp.props).toEqual(['ownerId']); + }); + + it('should accept a path to an ES module with a default export as modelClass', () => { + let relation = new Relation('testRelation', OwnerModel); + + relation.setMapping({ + relation: Relation, + modelClass: import.meta.dirname + '/files/esm/RelatedModel', + join: { + from: 'OwnerModel.id', + to: 'RelatedModel.ownerId', + }, + }); + + expect(relation.relatedModelClass).toBe(EsmRelatedModel); + expect(relation.relatedProp.cols).toEqual(['ownerId']); + }); + + it('should accept a path to an ES module with named exports as modelClass', () => { + let relation = new Relation('testRelation', OwnerModel); + + relation.setMapping({ + relation: Relation, + modelClass: import.meta.dirname + '/files/esm/RelatedModelNamedExport.js', + join: { + from: 'OwnerModel.id', + to: 'RelatedModel.ownerId', + }, + }); + + expect(relation.relatedModelClass).toBe(EsmRelatedModelNamedExport); + }); + + it('should resolve ES modules using `Model.modelPaths`', () => { + OwnerModel.modelPaths = [import.meta.dirname + '/files/esm/']; + let relation = new Relation('testRelation', OwnerModel); + + relation.setMapping({ + relation: Relation, + modelClass: 'RelatedModel', join: { from: 'OwnerModel.id', to: 'RelatedModel.ownerId', }, }); - expect(relation.ownerModelClass).to.equal(OwnerModel); - expect(relation.relatedModelClass).to.equal(RelatedModelNamedExport); - expect(relation.ownerProp.cols).to.eql(['id']); - expect(relation.ownerProp.props).to.eql(['id']); - expect(relation.relatedProp.cols).to.eql(['ownerId']); - expect(relation.relatedProp.props).to.eql(['ownerId']); + expect(relation.relatedModelClass).toBe(EsmRelatedModel); }); it('should accept a composite key as an array of columns', () => { @@ -155,12 +210,12 @@ describe('Relation', () => { }, }); - expect(relation.ownerModelClass).to.equal(OwnerModel); - expect(relation.relatedModelClass).to.equal(RelatedModel); - expect(relation.ownerProp.cols).to.eql(['name', 'dateOfBirth']); - expect(relation.ownerProp.props).to.eql(['name', 'dateOfBirth']); - expect(relation.relatedProp.cols).to.eql(['ownerName', 'ownerDateOfBirth']); - expect(relation.relatedProp.props).to.eql(['ownerName', 'ownerDateOfBirth']); + expect(relation.ownerModelClass).toBe(OwnerModel); + expect(relation.relatedModelClass).toBe(RelatedModel); + expect(relation.ownerProp.cols).toEqual(['name', 'dateOfBirth']); + expect(relation.ownerProp.props).toEqual(['name', 'dateOfBirth']); + expect(relation.relatedProp.cols).toEqual(['ownerName', 'ownerDateOfBirth']); + expect(relation.relatedProp.props).toEqual(['ownerName', 'ownerDateOfBirth']); }); it('should accept references created with Model.ref()', () => { @@ -175,13 +230,13 @@ describe('Relation', () => { }, }); - expect(relation.ownerModelClass).to.equal(OwnerModel); - expect(relation.relatedModelClass).to.equal(RelatedModel); - expect(relation.ownerProp.cols).to.eql(['id', 'json']); - expect(relation.ownerProp.props).to.eql(['id', 'json']); - expect(relation.ownerProp.getProp({ json: { attr: 1 } }, 1)).to.equal(1); - expect(relation.relatedProp.cols).to.eql(['ownerId', 'ownerAttr']); - expect(relation.relatedProp.props).to.eql(['ownerId', 'ownerAttr']); + expect(relation.ownerModelClass).toBe(OwnerModel); + expect(relation.relatedModelClass).toBe(RelatedModel); + expect(relation.ownerProp.cols).toEqual(['id', 'json']); + expect(relation.ownerProp.props).toEqual(['id', 'json']); + expect(relation.ownerProp.getProp({ json: { attr: 1 } }, 1)).toBe(1); + expect(relation.relatedProp.cols).toEqual(['ownerId', 'ownerAttr']); + expect(relation.relatedProp.props).toEqual(['ownerId', 'ownerAttr']); }); it('should fail if relation property and the relation itself have the same name', () => { @@ -196,11 +251,12 @@ describe('Relation', () => { to: 'RelatedModel.ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - "OwnerModel.relationMappings.foo: join: relation name and join property 'foo' cannot have the same name. If you cannot change one or the other, you can use $parseDatabaseJson and $formatDatabaseJson methods to convert the column name.", - ); - }); + }).toThrow( + expect.objectContaining({ + message: + "OwnerModel.relationMappings.foo: join: relation name and join property 'foo' cannot have the same name. If you cannot change one or the other, you can use $parseDatabaseJson and $formatDatabaseJson methods to convert the column name.", + }), + ); }); it('should pass through erros thrown from jsonSchema getter', () => { @@ -224,9 +280,7 @@ describe('Relation', () => { to: 'RelatedModel.ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal('whoops, invalid json shchema getter'); - }); + }).toThrow(expect.objectContaining({ message: 'whoops, invalid json shchema getter' })); }); it('should fail if modelClass is not a subclass of Model', () => { @@ -241,11 +295,12 @@ describe('Relation', () => { to: 'ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'OwnerModel.relationMappings.testRelation: modelClass: is not a subclass of Model or a file path to a module that exports one. You may be dealing with a require loop. See the documentation section about require loops.', - ); - }); + }).toThrow( + expect.objectContaining({ + message: + 'OwnerModel.relationMappings.testRelation: modelClass: is not a subclass of Model or a file path to a module that exports one. You may be dealing with circular imports (a require loop). See the documentation section about circular imports.', + }), + ); }); it('should fail if modelClass resolves to a module that exports multiple model classes', () => { @@ -254,17 +309,15 @@ describe('Relation', () => { expect(() => { relation.setMapping({ relation: Relation, - modelClass: __dirname + '/files/InvalidModelManyNamedModels', + modelClass: import.meta.dirname + '/files/InvalidModelManyNamedModels', join: { from: 'OwnerModel.id', to: 'ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.match( - /OwnerModel\.relationMappings\.testRelation: modelClass: path .*\/tests\/unit\/relations\/files\/InvalidModelManyNamedModels exports multiple models\. Don't know which one to choose\./, - ); - }); + }).toThrow( + /OwnerModel\.relationMappings\.testRelation: modelClass: path .*\/tests\/unit\/relations\/files\/InvalidModelManyNamedModels exports multiple models\. Don't know which one to choose\./, + ); }); it('should fail if modelClass is missing', () => { @@ -279,11 +332,11 @@ describe('Relation', () => { to: 'ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'OwnerModel.relationMappings.testRelation: modelClass is not defined', - ); - }); + }).toThrow( + expect.objectContaining({ + message: 'OwnerModel.relationMappings.testRelation: modelClass is not defined', + }), + ); }); it('should fail if modelClass is an invalid file path', () => { @@ -298,11 +351,12 @@ describe('Relation', () => { to: 'ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'OwnerModel.relationMappings.testRelation: modelClass: could not resolve blaa using modelPaths', - ); - }); + }).toThrow( + expect.objectContaining({ + message: + 'OwnerModel.relationMappings.testRelation: modelClass: could not resolve blaa using modelPaths', + }), + ); }); it('should fail if modelClass is a file path that points to a non-model', () => { @@ -311,19 +365,15 @@ describe('Relation', () => { expect(() => { relation.setMapping({ relation: Relation, - modelClass: __dirname + '/files/InvalidModel', + modelClass: import.meta.dirname + '/files/InvalidModel', join: { from: 'OwnerModel.id', to: 'ownerId', }, }); - }).to.throwException((err) => { - expect( - /^OwnerModel\.relationMappings\.testRelation: modelClass: (.+)\/InvalidModel is an invalid file path to a model class$/.test( - err.message, - ), - ).to.equal(true); - }); + }).toThrow( + /^OwnerModel\.relationMappings\.testRelation: modelClass: (.+)\/InvalidModel is an invalid file path to a model class$/, + ); }); it('should fail if relation is not defined', () => { @@ -337,11 +387,11 @@ describe('Relation', () => { to: 'ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'OwnerModel.relationMappings.testRelation: relation is not defined', - ); - }); + }).toThrow( + expect.objectContaining({ + message: 'OwnerModel.relationMappings.testRelation: relation is not defined', + }), + ); }); it('should fail if relation is not a Relation subclass', () => { @@ -356,11 +406,11 @@ describe('Relation', () => { to: 'ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'OwnerModel.relationMappings.testRelation: relation is not a subclass of Relation', - ); - }); + }).toThrow( + expect.objectContaining({ + message: 'OwnerModel.relationMappings.testRelation: relation is not a subclass of Relation', + }), + ); }); it('should fail if OwnerModelClass is not a subclass of Model', () => { @@ -375,9 +425,9 @@ describe('Relation', () => { to: 'ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal("Relation: Relation's owner is not a subclass of Model"); - }); + }).toThrow( + expect.objectContaining({ message: "Relation: Relation's owner is not a subclass of Model" }), + ); }); it('join.to should have format ModelName.columnName', () => { @@ -392,11 +442,12 @@ describe('Relation', () => { to: 'ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'OwnerModel.relationMappings.testRelation: join.to must have format TableName.columnName. For example "SomeTable.id" or in case of composite key ["SomeTable.a", "SomeTable.b"].', - ); - }); + }).toThrow( + expect.objectContaining({ + message: + 'OwnerModel.relationMappings.testRelation: join.to must have format TableName.columnName. For example "SomeTable.id" or in case of composite key ["SomeTable.a", "SomeTable.b"].', + }), + ); }); it('join.to should point to either of the related model classes', () => { @@ -411,11 +462,12 @@ describe('Relation', () => { to: 'RelatedModel.ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - "OwnerModel.relationMappings.testRelation: join: either `from` or `to` must point to the owner model table and the other one to the related table. It might be that specified table 'SomeOtherModel' is not correct", - ); - }); + }).toThrow( + expect.objectContaining({ + message: + "OwnerModel.relationMappings.testRelation: join: either `from` or `to` must point to the owner model table and the other one to the related table. It might be that specified table 'SomeOtherModel' is not correct", + }), + ); }); it('join.from should have format ModelName.columnName', () => { @@ -430,11 +482,12 @@ describe('Relation', () => { to: 'RelatedModel.ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'OwnerModel.relationMappings.testRelation: join.from must have format TableName.columnName. For example "SomeTable.id" or in case of composite key ["SomeTable.a", "SomeTable.b"].', - ); - }); + }).toThrow( + expect.objectContaining({ + message: + 'OwnerModel.relationMappings.testRelation: join.from must have format TableName.columnName. For example "SomeTable.id" or in case of composite key ["SomeTable.a", "SomeTable.b"].', + }), + ); }); it('join.from should point to either of the related model classes', () => { @@ -449,11 +502,12 @@ describe('Relation', () => { to: 'SomeOtherModel.ownerId', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - "OwnerModel.relationMappings.testRelation: join: either `from` or `to` must point to the owner model table and the other one to the related table. It might be that specified table 'SomeOtherModel' is not correct", - ); - }); + }).toThrow( + expect.objectContaining({ + message: + "OwnerModel.relationMappings.testRelation: join: either `from` or `to` must point to the owner model table and the other one to the related table. It might be that specified table 'SomeOtherModel' is not correct", + }), + ); }); it('should fail if join object is missing', () => { @@ -464,11 +518,12 @@ describe('Relation', () => { relation: Relation, modelClass: RelatedModel, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'OwnerModel.relationMappings.testRelation: join must be an object that maps the columns of the related models together. For example: {from: "SomeTable.id", to: "SomeOtherTable.someModelId"}', - ); - }); + }).toThrow( + expect.objectContaining({ + message: + 'OwnerModel.relationMappings.testRelation: join must be an object that maps the columns of the related models together. For example: {from: "SomeTable.id", to: "SomeOtherTable.someModelId"}', + }), + ); }); it('should fail if join.from is missing', () => { @@ -482,11 +537,12 @@ describe('Relation', () => { to: 'OwnerModel.id', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'OwnerModel.relationMappings.testRelation: join must be an object that maps the columns of the related models together. For example: {from: "SomeTable.id", to: "SomeOtherTable.someModelId"}', - ); - }); + }).toThrow( + expect.objectContaining({ + message: + 'OwnerModel.relationMappings.testRelation: join must be an object that maps the columns of the related models together. For example: {from: "SomeTable.id", to: "SomeOtherTable.someModelId"}', + }), + ); }); it('should fail if join.to is missing', () => { @@ -500,11 +556,12 @@ describe('Relation', () => { from: 'OwnerModel.id', }, }); - }).to.throwException((err) => { - expect(err.message).to.equal( - 'OwnerModel.relationMappings.testRelation: join must be an object that maps the columns of the related models together. For example: {from: "SomeTable.id", to: "SomeOtherTable.someModelId"}', - ); - }); + }).toThrow( + expect.objectContaining({ + message: + 'OwnerModel.relationMappings.testRelation: join must be an object that maps the columns of the related models together. For example: {from: "SomeTable.id", to: "SomeOtherTable.someModelId"}', + }), + ); }); it('the values of `join.to` and `join.from` can be swapped', () => { @@ -519,12 +576,12 @@ describe('Relation', () => { }, }); - expect(relation.ownerModelClass).to.equal(OwnerModel); - expect(relation.relatedModelClass).to.equal(RelatedModel); - expect(relation.ownerProp.cols).to.eql(['id']); - expect(relation.ownerProp.props).to.eql(['id']); - expect(relation.relatedProp.cols).to.eql(['ownerId']); - expect(relation.relatedProp.props).to.eql(['ownerId']); + expect(relation.ownerModelClass).toBe(OwnerModel); + expect(relation.relatedModelClass).toBe(RelatedModel); + expect(relation.ownerProp.cols).toEqual(['id']); + expect(relation.ownerProp.props).toEqual(['id']); + expect(relation.relatedProp.cols).toEqual(['ownerId']); + expect(relation.relatedProp.props).toEqual(['ownerId']); }); it('relatedCol and ownerCol should be in database format', () => { @@ -567,12 +624,12 @@ describe('Relation', () => { }, }); - expect(relation.ownerModelClass).to.equal(OwnerModel); - expect(relation.relatedModelClass).to.equal(RelatedModel); - expect(relation.ownerProp.cols).to.eql(['id_col']); - expect(relation.ownerProp.props).to.eql(['idCol']); - expect(relation.relatedProp.cols).to.eql(['owner-id']); - expect(relation.relatedProp.props).to.eql(['ownerId']); + expect(relation.ownerModelClass).toBe(OwnerModel); + expect(relation.relatedModelClass).toBe(RelatedModel); + expect(relation.ownerProp.cols).toEqual(['id_col']); + expect(relation.ownerProp.props).toEqual(['idCol']); + expect(relation.relatedProp.cols).toEqual(['owner-id']); + expect(relation.relatedProp.props).toEqual(['ownerId']); }); it('should allow relations on tables under a schema', () => { @@ -599,12 +656,12 @@ describe('Relation', () => { }, }); - expect(relation.ownerModelClass).to.equal(OwnerModel); - expect(relation.relatedModelClass).to.equal(RelatedModel); - expect(relation.ownerProp.cols).to.eql(['id']); - expect(relation.ownerProp.props).to.eql(['id']); - expect(relation.relatedProp.cols).to.eql(['owner_id']); - expect(relation.relatedProp.props).to.eql(['owner_id']); + expect(relation.ownerModelClass).toBe(OwnerModel); + expect(relation.relatedModelClass).toBe(RelatedModel); + expect(relation.ownerProp.cols).toEqual(['id']); + expect(relation.ownerProp.props).toEqual(['id']); + expect(relation.relatedProp.cols).toEqual(['owner_id']); + expect(relation.relatedProp.props).toEqual(['owner_id']); }); it('joinModelClass should return null for relations without join models', () => { @@ -620,6 +677,6 @@ describe('Relation', () => { }); const knex = Knex({ client: 'pg' }); - expect(relation.joinModelClass).to.equal(null); + expect(relation.joinModelClass).toBeNull(); }); }); diff --git a/tests/unit/relations/files/esm/ModelWithARandomError.js b/tests/unit/relations/files/esm/ModelWithARandomError.js new file mode 100644 index 000000000..b80f53d26 --- /dev/null +++ b/tests/unit/relations/files/esm/ModelWithARandomError.js @@ -0,0 +1,9 @@ +import { Model } from '../../../../../lib/objection.js'; + +throw new Error('some random error'); + +export class TestModel extends Model { + static get tableName() { + return 'test'; + } +} diff --git a/tests/unit/relations/files/esm/RelatedModel.js b/tests/unit/relations/files/esm/RelatedModel.js new file mode 100644 index 000000000..28554e837 --- /dev/null +++ b/tests/unit/relations/files/esm/RelatedModel.js @@ -0,0 +1,7 @@ +import { Model } from '../../../../../lib/objection.js'; + +export default class RelatedModel extends Model { + static get tableName() { + return this.name; + } +} diff --git a/tests/unit/relations/files/esm/RelatedModelNamedExport.js b/tests/unit/relations/files/esm/RelatedModelNamedExport.js new file mode 100644 index 000000000..c191c1ca7 --- /dev/null +++ b/tests/unit/relations/files/esm/RelatedModelNamedExport.js @@ -0,0 +1,9 @@ +import { Model } from '../../../../../lib/objection.js'; + +export const someCrap = 42; + +export class RelatedModel extends Model { + static get tableName() { + return this.name; + } +} diff --git a/tests/unit/relations/files/esm/package.json b/tests/unit/relations/files/esm/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/unit/relations/files/esm/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/unit/relations/files/package.json b/tests/unit/relations/files/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/tests/unit/relations/files/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/tests/unit/relations/ownerConditionGrouping.js b/tests/unit/relations/ownerConditionGrouping.js index 387a1a9c0..874cb537c 100644 --- a/tests/unit/relations/ownerConditionGrouping.js +++ b/tests/unit/relations/ownerConditionGrouping.js @@ -1,7 +1,7 @@ -const Knex = require('knex'); -const expect = require('expect.js'); -const { Model } = require('../../../'); -const mockKnexFactory = require('../../../testUtils/mockKnex'); +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import Knex from 'knex'; +import { Model } from 'objection'; +import mockKnexFactory from '../../../testUtils/mockKnex.js'; // The relation's owner condition must always be ANDed with the user's where // clauses as a group. Otherwise `orWhere` would leak rows of other owners. @@ -13,7 +13,7 @@ describe('relation owner condition grouping (#2191)', () => { let Pet = null; let Toy = null; - before(() => { + beforeAll(() => { const knex = Knex({ client: 'pg' }); mockKnex = mockKnexFactory(knex, function (mock, oldImpl, args) { @@ -46,6 +46,12 @@ describe('relation owner condition grouping (#2191)', () => { filter: orFilter, join: { from: 'owners.id', to: 'pets.ownerId' }, }, + petsByIds: { + relation: Model.HasManyRelation, + modelClass: Pet, + filter: (query) => query.findByIds([1, 2]).orWhere('name', 'b'), + join: { from: 'owners.id', to: 'pets.ownerId' }, + }, favoritePet: { relation: Model.BelongsToOneRelation, modelClass: Pet, @@ -120,19 +126,19 @@ describe('relation owner condition grouping (#2191)', () => { describe('HasManyRelation', () => { it('find', () => { - expect(sql(owner().$relatedQuery('pets').where('name', 'a').orWhere('name', 'b'))).to.equal( + expect(sql(owner().$relatedQuery('pets').where('name', 'a').orWhere('name', 'b'))).toBe( `select "pets".* from "pets" where "pets"."ownerId" in (1) and ("name" = 'a' or "name" = 'b')`, ); }); it('find with a single orWhere', () => { - expect(sql(owner().$relatedQuery('pets').orWhere('name', 'a'))).to.equal( + expect(sql(owner().$relatedQuery('pets').orWhere('name', 'a'))).toBe( `select "pets".* from "pets" where "pets"."ownerId" in (1) and ("name" = 'a')`, ); }); it('find with whereRaw', () => { - expect(sql(owner().$relatedQuery('pets').whereRaw(`name = 'a' or name = 'b'`))).to.equal( + expect(sql(owner().$relatedQuery('pets').whereRaw(`name = 'a' or name = 'b'`))).toBe( `select "pets".* from "pets" where "pets"."ownerId" in (1) and (name = 'a' or name = 'b')`, ); }); @@ -145,13 +151,13 @@ describe('relation owner condition grouping (#2191)', () => { .where('name', 'a') .onBuild((query) => query.orWhere('name', 'b')), ), - ).to.equal( + ).toBe( `select "pets".* from "pets" where "pets"."ownerId" in (1) and ("name" = 'a' or "name" = 'b')`, ); }); it('find with orWhere in a modifier', () => { - expect(sql(owner().$relatedQuery('pets').modify('dogsOrCats'))).to.equal( + expect(sql(owner().$relatedQuery('pets').modify('dogsOrCats'))).toBe( `select "pets".* from "pets" where "pets"."ownerId" in (1) and ("species" = 'dog' or "species" = 'cat')`, ); }); @@ -159,9 +165,7 @@ describe('relation owner condition grouping (#2191)', () => { it('delete', () => { expect( sql(owner().$relatedQuery('pets').where('name', 'a').orWhere('name', 'b').delete()), - ).to.equal( - `delete from "pets" where ("name" = 'a' or "name" = 'b') and "pets"."ownerId" in (1)`, - ); + ).toBe(`delete from "pets" where ("name" = 'a' or "name" = 'b') and "pets"."ownerId" in (1)`); }); it('patch', () => { @@ -173,7 +177,7 @@ describe('relation owner condition grouping (#2191)', () => { .orWhere('name', 'b') .patch({ name: 'c' }), ), - ).to.equal( + ).toBe( `update "pets" set "name" = 'c' where ("name" = 'a' or "name" = 'b') and "pets"."ownerId" in (1)`, ); }); @@ -187,7 +191,7 @@ describe('relation owner condition grouping (#2191)', () => { .orWhere('name', 'b') .update({ name: 'c' }), ), - ).to.equal( + ).toBe( `update "pets" set "name" = 'c' where ("name" = 'a' or "name" = 'b') and "pets"."ownerId" in (1)`, ); }); @@ -195,7 +199,7 @@ describe('relation owner condition grouping (#2191)', () => { it('unrelate', async () => { await owner().$relatedQuery('pets').where('name', 'a').orWhere('name', 'b').unrelate(); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `update "pets" set "ownerId" = NULL where ("name" = 'a' or "name" = 'b') and "pets"."ownerId" in (1)`, ]); }); @@ -203,17 +207,17 @@ describe('relation owner condition grouping (#2191)', () => { it('relate', async () => { await owner().$relatedQuery('pets').where('name', 'a').orWhere('name', 'b').relate(5); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `update "pets" set "ownerId" = 1 where ("name" = 'a' or "name" = 'b') and "pets"."id" in (5)`, ]); }); it('queries without orWhere are unchanged', () => { - expect(sql(owner().$relatedQuery('pets').where('name', 'a').where('age', 2))).to.equal( + expect(sql(owner().$relatedQuery('pets').where('name', 'a').where('age', 2))).toBe( `select "pets".* from "pets" where "pets"."ownerId" in (1) and "name" = 'a' and "age" = 2`, ); - expect(sql(owner().$relatedQuery('pets').where('name', 'a').delete())).to.equal( + expect(sql(owner().$relatedQuery('pets').where('name', 'a').delete())).toBe( `delete from "pets" where "name" = 'a' and "pets"."ownerId" in (1)`, ); }); @@ -226,17 +230,15 @@ describe('relation owner condition grouping (#2191)', () => { .where((query) => query.where('name', 'a').orWhere('name', 'b')) .delete(), ), - ).to.equal( - `delete from "pets" where ("name" = 'a' or "name" = 'b') and "pets"."ownerId" in (1)`, - ); + ).toBe(`delete from "pets" where ("name" = 'a' or "name" = 'b') and "pets"."ownerId" in (1)`); }); it('relation filter with orWhere', () => { - expect(sql(owner().$relatedQuery('filteredPets'))).to.equal( + expect(sql(owner().$relatedQuery('filteredPets'))).toBe( `select "pets".* from "pets" where "pets"."ownerId" in (1) and ("species" = 'dog' or "species" = 'cat')`, ); - expect(sql(owner().$relatedQuery('filteredPets').delete())).to.equal( + expect(sql(owner().$relatedQuery('filteredPets').delete())).toBe( `delete from "pets" where "pets"."ownerId" in (1) and ("species" = 'dog' or "species" = 'cat')`, ); }); @@ -244,7 +246,7 @@ describe('relation owner condition grouping (#2191)', () => { it('relation filter with orWhere combined with user orWhere', () => { expect( sql(owner().$relatedQuery('filteredPets').where('name', 'a').orWhere('name', 'b')), - ).to.equal( + ).toBe( `select "pets".* from "pets" where "pets"."ownerId" in (1) and ("species" = 'dog' or "species" = 'cat') and ("name" = 'a' or "name" = 'b')`, ); }); @@ -252,7 +254,7 @@ describe('relation owner condition grouping (#2191)', () => { it('unrelate with relation filter with orWhere', async () => { await owner().$relatedQuery('filteredPets').unrelate(); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `update "pets" set "ownerId" = NULL where "pets"."ownerId" in (1) and ("species" = 'dog' or "species" = 'cat')`, ]); }); @@ -260,9 +262,7 @@ describe('relation owner condition grouping (#2191)', () => { it('static relatedQuery().for()', () => { expect( sql(Owner.relatedQuery('pets').for(1).where('name', 'a').orWhere('name', 'b').delete()), - ).to.equal( - `delete from "pets" where ("name" = 'a' or "name" = 'b') and "pets"."ownerId" in (1)`, - ); + ).toBe(`delete from "pets" where ("name" = 'a' or "name" = 'b') and "pets"."ownerId" in (1)`); }); it('static relatedQuery() as a subquery', () => { @@ -272,7 +272,7 @@ describe('relation owner condition grouping (#2191)', () => { Owner.relatedQuery('pets').where('name', 'a').orWhere('name', 'b'), ), ), - ).to.equal( + ).toBe( `select "owners".* from "owners" where exists (select "pets".* from "pets" where "pets"."ownerId" = "owners"."id" and ("name" = 'a' or "name" = 'b'))`, ); }); @@ -281,7 +281,7 @@ describe('relation owner condition grouping (#2191)', () => { const owners = [Owner.fromJson({ id: 1 }), Owner.fromJson({ id: 2 })]; await Owner.fetchGraph(owners, 'pets(dogsOrCats)'); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `select "pets".* from "pets" where "pets"."ownerId" in (1, 2) and ("species" = 'dog' or "species" = 'cat')`, ]); }); @@ -297,7 +297,7 @@ describe('relation owner condition grouping (#2191)', () => { .orWhere('name', 'b') .patchAndFetchById(1, { name: 'c' }), ), - ).to.equal( + ).toBe( `update "pets" set "name" = 'c' where ("name" = 'a' or "name" = 'b') and "pets"."id" = 1 and "pets"."ownerId" in (1)`, ); }); @@ -307,7 +307,7 @@ describe('relation owner condition grouping (#2191)', () => { sql( Pet.query().where('name', 'a').orWhere('name', 'b').patchAndFetchById(1, { name: 'c' }), ), - ).to.equal( + ).toBe( `update "pets" set "name" = 'c' where ("name" = 'a' or "name" = 'b') and "pets"."id" = 1`, ); }); @@ -321,35 +321,29 @@ describe('relation owner condition grouping (#2191)', () => { .where('name', 'a') .orWhere('name', 'b'), ), - ).to.equal( + ).toBe( `update "pets" set "name" = 'c' where "pets"."id" = 1 and ("name" = 'a' or "name" = 'b')`, ); }); it('deleteById() with orWhere', () => { - expect(sql(Pet.query().deleteById(1).where('name', 'a').orWhere('name', 'b'))).to.equal( + expect(sql(Pet.query().deleteById(1).where('name', 'a').orWhere('name', 'b'))).toBe( `delete from "pets" where "pets"."id" = 1 and ("name" = 'a' or "name" = 'b')`, ); }); it('patchById() with orWhere', () => { - expect(sql(Pet.query().patchById(1, { name: 'c' }).orWhere('name', 'b'))).to.equal( + expect(sql(Pet.query().patchById(1, { name: 'c' }).orWhere('name', 'b'))).toBe( `update "pets" set "name" = 'c' where "pets"."id" = 1 and ("name" = 'b')`, ); }); it('updateById() with orWhere', () => { - expect(sql(Pet.query().updateById(1, { name: 'c' }).orWhere('name', 'b'))).to.equal( + expect(sql(Pet.query().updateById(1, { name: 'c' }).orWhere('name', 'b'))).toBe( `update "pets" set "name" = 'c' where "pets"."id" = 1 and ("name" = 'b')`, ); }); - it('findById() called by the user stays in the user group', () => { - expect(sql(owner().$relatedQuery('pets').findById(1).orWhere('name', 'b'))).to.equal( - `select "pets".* from "pets" where "pets"."ownerId" in (1) and ("pets"."id" = 1 or "name" = 'b')`, - ); - }); - it('asFindQuery() in afterUpdate keeps the where groups of the executed query', async () => { const findQueries = []; @@ -365,15 +359,65 @@ describe('relation owner condition grouping (#2191)', () => { .patchAndFetchById(1, { name: 'c' }) .orWhere('name', 'b'); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `update "pets" set "name" = 'c' where "pets"."id" = 1 and "pets"."ownerId" in (1) and ("name" = 'b')`, ]); - expect(findQueries).to.eql([ + expect(findQueries).toEqual([ `select "pets".* from "pets" where "pets"."ownerId" in (1) and "pets"."id" = 1 and ("name" = 'b')`, ]); }); }); + describe('findById() and findByIds() (#271)', () => { + it('findById() with orWhere', () => { + expect(sql(Pet.query().findById(1).orWhere('name', 'b'))).toBe( + `select "pets".* from "pets" where "pets"."id" = 1 and ("name" = 'b')`, + ); + }); + + it('findById() after orWhere', () => { + expect(sql(Pet.query().where('name', 'a').orWhere('name', 'b').findById(1))).toBe( + `select "pets".* from "pets" where ("name" = 'a' or "name" = 'b') and "pets"."id" = 1`, + ); + }); + + it('findByIds() with orWhere', () => { + expect(sql(Pet.query().findByIds([1, 2]).where('name', 'a').orWhere('name', 'b'))).toBe( + `select "pets".* from "pets" where "pets"."id" in (1, 2) and ("name" = 'a' or "name" = 'b')`, + ); + }); + + it('findById().patch() with orWhere', () => { + expect(sql(Pet.query().findById(1).patch({ name: 'c' }).orWhere('name', 'b'))).toBe( + `update "pets" set "name" = 'c' where "pets"."id" = 1 and ("name" = 'b')`, + ); + }); + + it('findByIds().delete() with orWhere', () => { + expect(sql(Pet.query().findByIds([1, 2]).delete().orWhere('name', 'b'))).toBe( + `delete from "pets" where "pets"."id" in (1, 2) and ("name" = 'b')`, + ); + }); + + it('findById() in a HasManyRelation query with orWhere', () => { + expect(sql(owner().$relatedQuery('pets').findById(1).orWhere('name', 'b'))).toBe( + `select "pets".* from "pets" where "pets"."ownerId" in (1) and "pets"."id" = 1 and ("name" = 'b')`, + ); + }); + + it('findByIds() in a ManyToManyRelation query with orWhere', () => { + expect(sql(owner().$relatedQuery('toys').findByIds([1, 2]).orWhere('name', 'b'))).toBe( + `select "toys".* from "toys" inner join "ownersToys" on "toys"."id" = "ownersToys"."toyId" where "ownersToys"."ownerId" in (1) and "toys"."id" in (1, 2) and ("name" = 'b')`, + ); + }); + + it('findByIds() in a relation filter with orWhere', () => { + expect(sql(owner().$relatedQuery('petsByIds').orWhere('name', 'c'))).toBe( + `select "pets".* from "pets" where "pets"."ownerId" in (1) and "pets"."id" in (1, 2) and ("name" = 'b') and ("name" = 'c')`, + ); + }); + }); + describe('building an executed query again', () => { let findQueries = null; @@ -402,15 +446,15 @@ describe('relation owner condition grouping (#2191)', () => { .orWhere('name', 'b') .patch({ name: 'c' }); - expect(executedQueries).to.eql([`update "pets" set "name" = 'c' ${where}`]); - expect(findQueries).to.eql([findQuery]); + expect(executedQueries).toEqual([`update "pets" set "name" = 'c' ${where}`]); + expect(findQueries).toEqual([findQuery]); }); it('delete() with orWhere and an onBuild() hook', async () => { await onBuild(owner().$relatedQuery('pets')).where('name', 'a').orWhere('name', 'b').delete(); - expect(executedQueries).to.eql([`delete from "pets" ${where}`]); - expect(findQueries).to.eql([findQuery]); + expect(executedQueries).toEqual([`delete from "pets" ${where}`]); + expect(findQueries).toEqual([findQuery]); }); it('patch() with orWhere and an onBuild() hook in a modifier', async () => { @@ -421,8 +465,8 @@ describe('relation owner condition grouping (#2191)', () => { .orWhere('name', 'b') .patch({ name: 'c' }); - expect(executedQueries).to.eql([`update "pets" set "name" = 'c' ${where}`]); - expect(findQueries).to.eql([findQuery]); + expect(executedQueries).toEqual([`update "pets" set "name" = 'c' ${where}`]); + expect(findQueries).toEqual([findQuery]); }); }); @@ -436,7 +480,7 @@ describe('relation owner condition grouping (#2191)', () => { query.where('age', 2); }), ), - ).to.equal(`select "pets".* from "pets" where "name" = 'a' and "age" = 2`); + ).toBe(`select "pets".* from "pets" where "name" = 'a' and "age" = 2`); }); }); @@ -444,7 +488,7 @@ describe('relation owner condition grouping (#2191)', () => { it('find', () => { expect( sql(owner().$relatedQuery('favoritePet').where('name', 'a').orWhere('name', 'b')), - ).to.equal( + ).toBe( `select "pets".* from "pets" where "pets"."id" in (2) and ("name" = 'a' or "name" = 'b')`, ); }); @@ -452,7 +496,7 @@ describe('relation owner condition grouping (#2191)', () => { it('delete', () => { expect( sql(owner().$relatedQuery('favoritePet').where('name', 'a').orWhere('name', 'b').delete()), - ).to.equal(`delete from "pets" where ("name" = 'a' or "name" = 'b') and "pets"."id" in (2)`); + ).toBe(`delete from "pets" where ("name" = 'a' or "name" = 'b') and "pets"."id" in (2)`); }); it('patch', () => { @@ -464,7 +508,7 @@ describe('relation owner condition grouping (#2191)', () => { .orWhere('name', 'b') .patch({ name: 'c' }), ), - ).to.equal( + ).toBe( `update "pets" set "name" = 'c' where ("name" = 'a' or "name" = 'b') and "pets"."id" in (2)`, ); }); @@ -472,7 +516,7 @@ describe('relation owner condition grouping (#2191)', () => { describe('ManyToManyRelation', () => { it('find', () => { - expect(sql(owner().$relatedQuery('toys').where('name', 'a').orWhere('name', 'b'))).to.equal( + expect(sql(owner().$relatedQuery('toys').where('name', 'a').orWhere('name', 'b'))).toBe( `select "toys".* from "toys" inner join "ownersToys" on "toys"."id" = "ownersToys"."toyId" where "ownersToys"."ownerId" in (1) and ("name" = 'a' or "name" = 'b')`, ); }); @@ -480,7 +524,7 @@ describe('relation owner condition grouping (#2191)', () => { it('delete', () => { expect( sql(owner().$relatedQuery('toys').where('name', 'a').orWhere('name', 'b').delete()), - ).to.equal( + ).toBe( `delete from "toys" where "toys"."id" in (select "toys"."id" from "toys" inner join "ownersToys" on "toys"."id" = "ownersToys"."toyId" where "ownersToys"."ownerId" in (1) and ("name" = 'a' or "name" = 'b'))`, ); }); @@ -494,7 +538,7 @@ describe('relation owner condition grouping (#2191)', () => { .orWhere('name', 'b') .patch({ name: 'c' }), ), - ).to.equal( + ).toBe( `update "toys" set "name" = 'c' where "toys"."id" in (select "toys"."id" from "toys" inner join "ownersToys" on "toys"."id" = "ownersToys"."toyId" where "ownersToys"."ownerId" in (1) and ("name" = 'a' or "name" = 'b'))`, ); }); @@ -502,13 +546,13 @@ describe('relation owner condition grouping (#2191)', () => { it('unrelate', async () => { await owner().$relatedQuery('toys').where('name', 'a').orWhere('name', 'b').unrelate(); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `delete from "ownersToys" where "ownersToys"."ownerId" in (1) and exists (select 1 from "toys" where "toys"."id" = "ownersToys"."toyId" and ("name" = 'a' or "name" = 'b'))`, ]); }); it('relation filter and join table modify with orWhere', () => { - expect(sql(owner().$relatedQuery('filteredToys'))).to.equal( + expect(sql(owner().$relatedQuery('filteredToys'))).toBe( `select "toys".* from "toys" inner join (select "ownersToys".* from "ownersToys" where "shared" = false or "primary" = true) as "ownersToys" on "toys"."id" = "ownersToys"."toyId" where "ownersToys"."ownerId" in (1) and ("color" = 'red' or "color" = 'blue')`, ); }); @@ -516,7 +560,7 @@ describe('relation owner condition grouping (#2191)', () => { it('unrelate with relation filter and join table modify with orWhere', async () => { await owner().$relatedQuery('filteredToys').unrelate(); - expect(executedQueries).to.eql([ + expect(executedQueries).toEqual([ `delete from "ownersToys" where "ownersToys"."ownerId" in (1) and exists (select 1 from "toys" where "toys"."id" = "ownersToys"."toyId" and ("color" = 'red' or "color" = 'blue')) and ("shared" = false or "primary" = true)`, ]); }); @@ -524,9 +568,7 @@ describe('relation owner condition grouping (#2191)', () => { describe('HasOneThroughRelation', () => { it('find', () => { - expect( - sql(owner().$relatedQuery('bestToy').where('name', 'a').orWhere('name', 'b')), - ).to.equal( + expect(sql(owner().$relatedQuery('bestToy').where('name', 'a').orWhere('name', 'b'))).toBe( `select "toys".* from "toys" inner join "ownersToys" on "toys"."id" = "ownersToys"."toyId" where "ownersToys"."ownerId" in (1) and ("name" = 'a' or "name" = 'b')`, ); }); @@ -534,7 +576,7 @@ describe('relation owner condition grouping (#2191)', () => { it('delete', () => { expect( sql(owner().$relatedQuery('bestToy').where('name', 'a').orWhere('name', 'b').delete()), - ).to.equal( + ).toBe( `delete from "toys" where "toys"."id" in (select "toys"."id" from "toys" inner join "ownersToys" on "toys"."id" = "ownersToys"."toyId" where "ownersToys"."ownerId" in (1) and ("name" = 'a' or "name" = 'b'))`, ); }); diff --git a/tests/unit/utils.js b/tests/unit/utils.js index 39ee66b7c..f628ee1aa 100644 --- a/tests/unit/utils.js +++ b/tests/unit/utils.js @@ -1,19 +1,19 @@ -const expect = require('expect.js'); -const classUtils = require('../../lib/utils/classUtils'); -const { delay, range } = require('../../testUtils/testUtils'); +import { describe, it, expect } from 'vitest'; +import * as classUtils from '../../lib/utils/classUtils.js'; +import { delay, range } from '../../testUtils/testUtils.js'; -const { +import { snakeCase, camelCase, snakeCaseKeys, camelCaseKeys, snakeCaseMappers, knexSnakeCaseMappers, -} = require('../../lib/utils/identifierMapping'); +} from '../../lib/utils/identifierMapping.js'; -const { compose, mixin } = require('../../lib/utils/mixin'); -const { map } = require('../../lib/utils/promiseUtils'); -const { cloneDeep, jsonEquals, uniqBy, union } = require('../../lib/utils/objectUtils'); +import { compose, mixin } from '../../lib/utils/mixin.js'; +import { map } from '../../lib/utils/promiseUtils/index.js'; +import { cloneDeep, jsonEquals, uniqBy, union } from '../../lib/utils/objectUtils.js'; describe('utils', () => { describe('mixin', () => { @@ -37,19 +37,19 @@ describe('utils', () => { const Y = mixin(X, m1, m2); const y = new Y(); - expect(y.f()).to.equal(2); + expect(y.f()).toBe(2); if (process.version >= 'v8.0.0') { - expect(Y.name).to.equal('X'); + expect(Y.name).toBe('X'); } const Z = mixin(X, [m1, m2]); const z = new Z(); - expect(z.f()).to.equal(2); + expect(z.f()).toBe(2); if (process.version >= 'v8.0.0') { - expect(Z.name).to.equal('X'); + expect(Z.name).toBe('X'); } }); }); @@ -78,19 +78,19 @@ describe('utils', () => { const Y = m3(X); const y = new Y(); - expect(y.f()).to.equal(2); + expect(y.f()).toBe(2); if (process.version >= 'v8.0.0') { - expect(Y.name).to.equal('X'); + expect(Y.name).toBe('X'); } const Z = m4(X); const z = new Z(); - expect(z.f()).to.equal(2); + expect(z.f()).toBe(2); if (process.version >= 'v8.0.0') { - expect(Z.name).to.equal('X'); + expect(Z.name).toBe('X'); } }); }); @@ -205,11 +205,13 @@ describe('utils', () => { backToCamel = backToCamel || camel; it(`${camel} --> ${snake} --> ${backToCamel}`, () => { - expect(snakeCase(camel)).to.equal(snake); - expect(snakeCaseKeys({ [camel]: 'foo' })).to.eql({ [snake]: 'foo' }); + expect(snakeCase(camel)).toBe(snake); + expect(snakeCaseKeys({ [camel]: 'foo' })).toEqual({ [snake]: 'foo' }); - expect(camelCase(snakeCase(camel))).to.equal(backToCamel); - expect(camelCaseKeys(snakeCaseKeys({ [camel]: 'foo' }))).to.eql({ [backToCamel]: 'foo' }); + expect(camelCase(snakeCase(camel))).toBe(backToCamel); + expect(camelCaseKeys(snakeCaseKeys({ [camel]: 'foo' }))).toEqual({ + [backToCamel]: 'foo', + }); }); } @@ -218,8 +220,8 @@ describe('utils', () => { const opt = { underscoreBeforeDigits: true }; it(`${camel} --> ${snake} --> ${backToCamel}`, () => { - expect(snakeCase(camel, opt)).to.equal(snake); - expect(camelCase(snakeCase(camel, opt), opt)).to.equal(backToCamel); + expect(snakeCase(camel, opt)).toBe(snake); + expect(camelCase(snakeCase(camel, opt), opt)).toBe(backToCamel); }); } @@ -228,8 +230,8 @@ describe('utils', () => { const opt = { underscoreBetweenUppercaseLetters: true }; it(`${camel} --> ${snake} --> ${backToCamel}`, () => { - expect(snakeCase(camel, opt)).to.equal(snake); - expect(camelCase(snakeCase(camel, opt), opt)).to.equal(backToCamel); + expect(snakeCase(camel, opt)).toBe(snake); + expect(camelCase(snakeCase(camel, opt), opt)).toBe(backToCamel); }); } @@ -238,22 +240,22 @@ describe('utils', () => { const opt = { noDoubleUnderscores: true, ...extraOpt }; it(`${camel} --> ${snake} --> ${backToCamel} (${JSON.stringify(opt)})`, () => { - expect(snakeCase(camel, opt)).to.equal(snake); - expect(camelCase(snakeCase(camel, opt), opt)).to.equal(backToCamel); + expect(snakeCase(camel, opt)).toBe(snake); + expect(camelCase(snakeCase(camel, opt), opt)).toBe(backToCamel); const mappers = snakeCaseMappers(opt); - expect(mappers.format({ [camel]: 1 })).to.eql({ [snake]: 1 }); + expect(mappers.format({ [camel]: 1 })).toEqual({ [snake]: 1 }); const knexMappers = knexSnakeCaseMappers(opt); - expect(knexMappers.wrapIdentifier(camel, (id) => id)).to.equal(snake); - expect(knexMappers.postProcessResponse({ [snake]: 1 })).to.eql({ [backToCamel]: 1 }); + expect(knexMappers.wrapIdentifier(camel, (id) => id)).toBe(snake); + expect(knexMappers.postProcessResponse({ [snake]: 1 })).toEqual({ [backToCamel]: 1 }); }); } }); describe('snakeCaseMappers and field expressions', () => { - it('maps json keys of field expressions by default (unchanged behaviour)', () => { - const mappers = snakeCaseMappers(); + it('maps json keys of field expressions with `preserveJsonKeys: false`', () => { + const mappers = snakeCaseMappers({ preserveJsonKeys: false }); expect( mappers.format({ @@ -261,15 +263,15 @@ describe('utils', () => { 'jsonCol:someKey.otherKey': 2, fooBar: 3, }), - ).to.eql({ + ).toEqual({ 'json_col:[0][inner_key]': 1, 'json_col:some_key.other_key': 2, foo_bar: 3, }); }); - it('only maps the column part of field expressions with `preserveJsonKeys: true`', () => { - const mappers = snakeCaseMappers({ preserveJsonKeys: true }); + it('only maps the column part of field expressions by default', () => { + const mappers = snakeCaseMappers(); expect( mappers.format({ @@ -279,7 +281,7 @@ describe('utils', () => { 'json_col:someKey': 4, fooBar: 5, }), - ).to.eql({ + ).toEqual({ 'json_col:[0][innerKey]': 1, 'json_col:someKey.otherKey': 2, 'json_col:[a:bC]': 3, @@ -288,19 +290,32 @@ describe('utils', () => { }); }); - it('combines `preserveJsonKeys` with other options', () => { - const mappers = snakeCaseMappers({ preserveJsonKeys: true, upperCase: true }); + it('maps keys with a colon that are not field expressions as a whole', () => { + const mappers = snakeCaseMappers(); + const isFieldExpression = (key) => key !== 'ns:fooBar'; + + expect( + mappers.format({ 'ns:fooBar': 1, 'jsonCol:someKey': 2, fooBar: 3 }, isFieldExpression), + ).toEqual({ + 'ns:foo_bar': 1, + 'json_col:someKey': 2, + foo_bar: 3, + }); + }); + + it('combines preserving json keys with other options', () => { + const mappers = snakeCaseMappers({ upperCase: true }); - expect(mappers.format({ 'jsonCol:someKey': 1, fooBar: 2 })).to.eql({ + expect(mappers.format({ 'jsonCol:someKey': 1, fooBar: 2 })).toEqual({ 'JSON_COL:someKey': 1, FOO_BAR: 2, }); }); it('`preserveJsonKeys` does not affect parse', () => { - const mappers = snakeCaseMappers({ preserveJsonKeys: true }); + const mappers = snakeCaseMappers({ preserveJsonKeys: false }); - expect(mappers.parse({ foo_bar: 1, 'rel:some_prop': 2 })).to.eql( + expect(mappers.parse({ foo_bar: 1, 'rel:some_prop': 2 })).toEqual( snakeCaseMappers().parse({ foo_bar: 1, 'rel:some_prop': 2 }), ); }); @@ -318,7 +333,7 @@ describe('utils', () => { json_col: { foo_bar: 1 }, }, ]), - ).to.eql([ + ).toEqual([ { someTable: { id: 1, foo_bar: 'a', created_at: date }, jsonCol: { foo_bar: 1 }, @@ -348,19 +363,19 @@ describe('utils', () => { return result; }); }).then((result) => { - expect(maxRunning).to.equal(numItems); - expect(result).to.eql(range(numItems).map((it) => it * 2)); - expect(startOrder).to.eql(range(numItems)); + expect(maxRunning).toBe(numItems); + expect(result).toEqual(range(numItems).map((it) => it * 2)); + expect(startOrder).toEqual(range(numItems)); }); }); - it('should not start new operations after an error has been thrown', (done) => { + it('should not start new operations after an error has been thrown', () => { const numItems = 20; let errorThrown = false; let callbackCalledAfterError = false; - map(range(numItems), (item, index) => { + return map(range(numItems), (item, index) => { if (errorThrown) { callbackCalledAfterError = true; } @@ -375,14 +390,28 @@ describe('utils', () => { }); }) .then(() => { - done(new Error('should not get here')); + throw new Error('should not get here'); }) .catch((err) => { - expect(err.message).to.equal('fail'); - expect(callbackCalledAfterError).to.equal(false); - done(); - }) - .catch(done); + expect(err.message).toBe('fail'); + expect(callbackCalledAfterError).toBe(false); + }); + }); + + it('should wait for started operations before rejecting', async () => { + const finished = []; + + await expect( + map(range(4), async (item) => { + if (item === 0) { + throw new Error('fail'); + } + await delay(10 * item); + finished.push(item); + }), + ).rejects.toThrow('fail'); + + expect(finished).toEqual([1, 2, 3]); }); it('should only run opt.concurrency operations at a time', () => { @@ -397,7 +426,7 @@ describe('utils', () => { (item, index) => { startOrder.push(item); running++; - expect(running).to.be.lessThan(concurrency + 1); + expect(running).toBeLessThan(concurrency + 1); return delay(Math.round(Math.random() * 10)) .then(() => 2 * item) @@ -408,8 +437,8 @@ describe('utils', () => { }, { concurrency }, ).then((result) => { - expect(result).to.eql(range(numItems).map((it) => it * 2)); - expect(startOrder).to.eql(range(numItems)); + expect(result).toEqual(range(numItems).map((it) => it * 2)); + expect(startOrder).toEqual(range(numItems)); }); }); @@ -426,8 +455,8 @@ describe('utils', () => { }, { concurrency }, ).then((result) => { - expect(result).to.eql(range(numItems).map((it) => it * 2)); - expect(startOrder).to.eql(range(numItems)); + expect(result).toEqual(range(numItems).map((it) => it * 2)); + expect(startOrder).toEqual(range(numItems)); }); }); }); @@ -438,11 +467,11 @@ describe('utils', () => { const value = { a: 1, b: { c: [1, { d: 'x' }] }, e: null }; const clone = cloneDeep(value); - expect(clone).to.eql(value); - expect(clone).not.to.be(value); - expect(clone.b).not.to.be(value.b); - expect(clone.b.c).not.to.be(value.b.c); - expect(clone.b.c[1]).not.to.be(value.b.c[1]); + expect(clone).toEqual(value); + expect(clone).not.toBe(value); + expect(clone.b).not.toBe(value.b); + expect(clone.b.c).not.toBe(value.b.c); + expect(clone.b.c[1]).not.toBe(value.b.c[1]); }); it('keeps the prototype of class instances', () => { @@ -454,15 +483,15 @@ describe('utils', () => { const clone = cloneDeep({ point: new Point(1) }); - expect(clone.point).to.be.a(Point); - expect(clone.point.x).to.equal(1); + expect(clone.point).toBeInstanceOf(Point); + expect(clone.point.x).toBe(1); }); it('keeps null prototypes', () => { const clone = cloneDeep(Object.assign(Object.create(null), { a: 1 })); - expect(Object.getPrototypeOf(clone)).to.be(null); - expect(clone.a).to.equal(1); + expect(Object.getPrototypeOf(clone)).toBeNull(); + expect(clone.a).toBe(1); }); it('copies own enumerable properties, including symbols', () => { @@ -473,9 +502,9 @@ describe('utils', () => { }); const clone = cloneDeep(value); - expect(clone[symbol]).to.eql({ b: 2 }); - expect(clone[symbol]).not.to.be(value[symbol]); - expect(clone).not.to.have.property('hidden'); + expect(clone[symbol]).toEqual({ b: 2 }); + expect(clone[symbol]).not.toBe(value[symbol]); + expect(clone).not.toHaveProperty('hidden'); }); it('clones objects with a custom `Symbol.toStringTag`', () => { @@ -493,22 +522,22 @@ describe('utils', () => { }; const clone = cloneDeep(value); - expect(clone.decimal).not.to.be(value.decimal); - expect(clone.decimal).to.be.a(Decimal); - expect(clone.decimal.d).to.eql([1]); - expect(clone.decimal.d).not.to.be(value.decimal.d); - expect(clone.tagged).not.to.be(value.tagged); - expect(clone.tagged[Symbol.toStringTag]).to.equal('Tagged'); - expect(clone.tagged.a).to.eql({ b: 1 }); - expect(clone.tagged.a).not.to.be(value.tagged.a); + expect(clone.decimal).not.toBe(value.decimal); + expect(clone.decimal).toBeInstanceOf(Decimal); + expect(clone.decimal.d).toEqual([1]); + expect(clone.decimal.d).not.toBe(value.decimal.d); + expect(clone.tagged).not.toBe(value.tagged); + expect(clone.tagged[Symbol.toStringTag]).toBe('Tagged'); + expect(clone.tagged.a).toEqual({ b: 1 }); + expect(clone.tagged.a).not.toBe(value.tagged.a); }); it('copies a `__proto__` key as a property', () => { const clone = cloneDeep(JSON.parse('{ "__proto__": { "x": 1 } }')); - expect(Object.getPrototypeOf(clone)).to.be(Object.prototype); - expect(clone.x).to.be(undefined); - expect(Object.keys(clone)).to.eql(['__proto__']); + expect(Object.getPrototypeOf(clone)).toBe(Object.prototype); + expect(clone.x).toBeUndefined(); + expect(Object.keys(clone)).toEqual(['__proto__']); }); it('clones dates, regular expressions, maps, sets and binary data', () => { @@ -529,25 +558,25 @@ describe('utils', () => { const clone = cloneDeep(value); - expect(clone.date).not.to.be(value.date); - expect(clone.date.getTime()).to.equal(1000); - expect(clone.regExp).not.to.be(value.regExp); - expect(String(clone.regExp)).to.equal('/a+/g'); - expect(clone.regExp.lastIndex).to.equal(2); - expect(clone.map.get(key)).to.eql({ a: 1 }); - expect(clone.map.get(key)).not.to.be(value.map.get(key)); - expect([...clone.set]).to.eql([{ b: 2 }]); - expect([...clone.set][0]).not.to.be([...value.set][0]); - expect(Buffer.isBuffer(clone.buffer)).to.equal(true); - expect(clone.buffer.toString()).to.equal('abc'); - expect(clone.buffer).not.to.be(value.buffer); - expect([...clone.typedArray]).to.eql([1, 2, 3]); - expect(clone.typedArray.buffer).not.to.be(value.typedArray.buffer); - expect([...new Uint8Array(clone.arrayBuffer)]).to.eql([4, 5]); - expect(clone.arrayBuffer).not.to.be(value.arrayBuffer); - expect(clone.dataView.getUint8(0)).to.equal(7); - expect(clone.dataView.byteLength).to.equal(2); - expect(clone.dataView.buffer).not.to.be(value.dataView.buffer); + expect(clone.date).not.toBe(value.date); + expect(clone.date.getTime()).toBe(1000); + expect(clone.regExp).not.toBe(value.regExp); + expect(String(clone.regExp)).toBe('/a+/g'); + expect(clone.regExp.lastIndex).toBe(2); + expect(clone.map.get(key)).toEqual({ a: 1 }); + expect(clone.map.get(key)).not.toBe(value.map.get(key)); + expect([...clone.set]).toEqual([{ b: 2 }]); + expect([...clone.set][0]).not.toBe([...value.set][0]); + expect(Buffer.isBuffer(clone.buffer)).toBe(true); + expect(clone.buffer.toString()).toBe('abc'); + expect(clone.buffer).not.toBe(value.buffer); + expect([...clone.typedArray]).toEqual([1, 2, 3]); + expect(clone.typedArray.buffer).not.toBe(value.typedArray.buffer); + expect([...new Uint8Array(clone.arrayBuffer)]).toEqual([4, 5]); + expect(clone.arrayBuffer).not.toBe(value.arrayBuffer); + expect(clone.dataView.getUint8(0)).toBe(7); + expect(clone.dataView.byteLength).toBe(2); + expect(clone.dataView.buffer).not.toBe(value.dataView.buffer); }); it('keeps circular and shared references', () => { @@ -557,9 +586,9 @@ describe('utils', () => { const clone = cloneDeep(value); - expect(clone.self).to.be(clone); - expect(clone.x).to.be(clone.y[0]); - expect(clone.x).not.to.be(shared); + expect(clone.self).toBe(clone); + expect(clone.x).toBe(clone.y[0]); + expect(clone.x).not.toBe(shared); }); it("keeps values it can't clone", () => { @@ -575,7 +604,7 @@ describe('utils', () => { const clone = cloneDeep(value); for (const key of Object.keys(value)) { - expect(clone[key]).to.be(value[key]); + expect(clone[key]).toBe(value[key]); } }); @@ -616,13 +645,13 @@ describe('utils', () => { const clone = cloneDeep(value); for (const key of Object.keys(value)) { - expect(clone[key]).to.be(value[key]); + expect(clone[key]).toBe(value[key]); } - expect(clone.url.href).to.equal('http://example.com/path?a=1'); - expect(clone.searchParams.get('a')).to.equal('1'); - expect(clone.temporalLike.value).to.equal(1); - expect(clone.temporalLikeValue.value).to.equal(2); + expect(clone.url.href).toBe('http://example.com/path?a=1'); + expect(clone.searchParams.get('a')).toBe('1'); + expect(clone.temporalLike.value).toBe(1); + expect(clone.temporalLikeValue.value).toBe(2); }); it('clones objects with an own or inherited writable `Symbol.toStringTag`', () => { @@ -638,65 +667,65 @@ describe('utils', () => { const inherited = new Decimal(1); const clone = cloneDeep({ own, inherited }); - expect(clone.own).not.to.be(own); - expect(clone.inherited).not.to.be(inherited); - expect(clone.inherited.d).not.to.be(inherited.d); + expect(clone.own).not.toBe(own); + expect(clone.inherited).not.toBe(inherited); + expect(clone.inherited.d).not.toBe(inherited.d); // The tags are left as they were. - expect(Object.getOwnPropertySymbols(own)).to.eql([Symbol.toStringTag]); - expect(own[Symbol.toStringTag]).to.equal('Own'); - expect(Object.getOwnPropertySymbols(inherited)).to.eql([]); - expect(inherited[Symbol.toStringTag]).to.equal('Decimal'); + expect(Object.getOwnPropertySymbols(own)).toEqual([Symbol.toStringTag]); + expect(own[Symbol.toStringTag]).toBe('Own'); + expect(Object.getOwnPropertySymbols(inherited)).toEqual([]); + expect(inherited[Symbol.toStringTag]).toBe('Decimal'); }); }); describe('jsonEquals', () => { it('should work with primitives', () => { - expect(jsonEquals(1, 1)).to.equal(true); - expect(jsonEquals('foo', 'foo')).to.equal(true); - expect(jsonEquals(false, false)).to.equal(true); - expect(jsonEquals(true, true)).to.equal(true); + expect(jsonEquals(1, 1)).toBe(true); + expect(jsonEquals('foo', 'foo')).toBe(true); + expect(jsonEquals(false, false)).toBe(true); + expect(jsonEquals(true, true)).toBe(true); const date = new Date(); - expect(jsonEquals(date, date)).to.equal(true); - expect(jsonEquals(date, new Date(date))).to.equal(true); - expect(jsonEquals(new Date(date), date)).to.equal(true); - - expect(jsonEquals(1, 2)).to.equal(false); - expect(jsonEquals('foo', 'bar')).to.equal(false); - expect(jsonEquals(true, false)).to.equal(false); - expect(jsonEquals(0, false)).to.equal(false); - expect(jsonEquals(false, 0)).to.equal(false); - expect(jsonEquals('1', 1)).to.equal(false); - expect(jsonEquals(1, '1')).to.equal(false); - expect(jsonEquals(true, false)).to.equal(false); - expect(jsonEquals('true', true)).to.equal(false); - expect(jsonEquals(true, 'true')).to.equal(false); - expect(jsonEquals(new Date(), new Date(Date.now() + 1))).to.equal(false); + expect(jsonEquals(date, date)).toBe(true); + expect(jsonEquals(date, new Date(date))).toBe(true); + expect(jsonEquals(new Date(date), date)).toBe(true); + + expect(jsonEquals(1, 2)).toBe(false); + expect(jsonEquals('foo', 'bar')).toBe(false); + expect(jsonEquals(true, false)).toBe(false); + expect(jsonEquals(0, false)).toBe(false); + expect(jsonEquals(false, 0)).toBe(false); + expect(jsonEquals('1', 1)).toBe(false); + expect(jsonEquals(1, '1')).toBe(false); + expect(jsonEquals(true, false)).toBe(false); + expect(jsonEquals('true', true)).toBe(false); + expect(jsonEquals(true, 'true')).toBe(false); + expect(jsonEquals(new Date(), new Date(Date.now() + 1))).toBe(false); }); it('should work with arrays', () => { - expect(jsonEquals([], [])).to.equal(true); - expect(jsonEquals([1], [1])).to.equal(true); - expect(jsonEquals([1, 2], [1, 2])).to.equal(true); - expect(jsonEquals(['foo', 'bar'], ['foo', 'bar'])).to.equal(true); - - expect(jsonEquals(['1', 2], [1, '2'])).to.equal(false); - expect(jsonEquals([1], 1)).to.equal(false); - expect(jsonEquals(2, [2])).to.equal(false); - expect(jsonEquals([0], [])).to.equal(false); - expect(jsonEquals([], [0])).to.equal(false); - expect(jsonEquals([1], [2])).to.equal(false); - expect(jsonEquals([1, 2], [2, 1])).to.equal(false); - expect(jsonEquals([1, 2], [1, 2, 3])).to.equal(false); - expect(jsonEquals([1, 2, 3], [1, 2])).to.equal(false); - expect(jsonEquals(['2', 2], [1, '2'])).to.equal(false); + expect(jsonEquals([], [])).toBe(true); + expect(jsonEquals([1], [1])).toBe(true); + expect(jsonEquals([1, 2], [1, 2])).toBe(true); + expect(jsonEquals(['foo', 'bar'], ['foo', 'bar'])).toBe(true); + + expect(jsonEquals(['1', 2], [1, '2'])).toBe(false); + expect(jsonEquals([1], 1)).toBe(false); + expect(jsonEquals(2, [2])).toBe(false); + expect(jsonEquals([0], [])).toBe(false); + expect(jsonEquals([], [0])).toBe(false); + expect(jsonEquals([1], [2])).toBe(false); + expect(jsonEquals([1, 2], [2, 1])).toBe(false); + expect(jsonEquals([1, 2], [1, 2, 3])).toBe(false); + expect(jsonEquals([1, 2, 3], [1, 2])).toBe(false); + expect(jsonEquals(['2', 2], [1, '2'])).toBe(false); }); it('should work with objects', () => { - expect(jsonEquals({}, {})).to.equal(true); - expect(jsonEquals({ a: 1, b: 2 }, { b: 2, a: 1 })).to.equal(true); - expect(jsonEquals({ a: 1, b: 2 }, { b: 2, a: 2 })).to.equal(false); - expect(jsonEquals({ a: 1, b: 2 }, { a: 1, b: 2, c: 3 })).to.equal(false); - expect(jsonEquals({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 })).to.equal(false); + expect(jsonEquals({}, {})).toBe(true); + expect(jsonEquals({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); + expect(jsonEquals({ a: 1, b: 2 }, { b: 2, a: 2 })).toBe(false); + expect(jsonEquals({ a: 1, b: 2 }, { a: 1, b: 2, c: 3 })).toBe(false); + expect(jsonEquals({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 })).toBe(false); }); it('should work with nested stuff', () => { @@ -709,7 +738,7 @@ describe('utils', () => { a: [1, { b: 'foo' }, false], }, ), - ).to.equal(true); + ).toBe(true); expect( jsonEquals( @@ -720,7 +749,7 @@ describe('utils', () => { a: [1, { b: 'bar' }, false], }, ), - ).to.equal(false); + ).toBe(false); expect( jsonEquals( @@ -731,7 +760,7 @@ describe('utils', () => { a: [1, { b: 'foo' }, true], }, ), - ).to.equal(false); + ).toBe(false); expect( jsonEquals( @@ -748,7 +777,7 @@ describe('utils', () => { 1, ], ), - ).to.equal(true); + ).toBe(true); expect( jsonEquals( @@ -765,7 +794,7 @@ describe('utils', () => { 1, ], ), - ).to.equal(false); + ).toBe(false); }); }); describe('uniqBy', () => { @@ -776,23 +805,23 @@ describe('utils', () => { ]; it('should work with Buffer items', () => { const itemsForTest = items.map(([value]) => value); - expect(uniqBy(itemsForTest)).to.eql(itemsForTest); + expect(uniqBy(itemsForTest)).toEqual(itemsForTest); }); it('should work with Buffer[] items', () => { - expect(uniqBy(items)).to.eql(items); + expect(uniqBy(items)).toEqual(items); }); it('should work with Buffer[] items with custom keyGetter function', () => { expect( uniqBy(items, (item) => item.map((x) => (Buffer.isBuffer(x) ? x.toString('hex') : x)).join(','), ), - ).to.eql(items); + ).toEqual(items); }); }); describe('union', () => { it('does not keep duplicates from the first array', () => { - expect(union([1, 1, 2], [2, 3])).to.eql([1, 2, 3]); + expect(union([1, 1, 2], [2, 3])).toEqual([1, 2, 3]); }); }); }); diff --git a/tests/unit/utils/resolveModel.js b/tests/unit/utils/resolveModel.js index e8741b58f..7e335eabe 100644 --- a/tests/unit/utils/resolveModel.js +++ b/tests/unit/utils/resolveModel.js @@ -1,12 +1,22 @@ -const { resolveModel } = require('../../../lib/utils/resolveModel'); -const expect = require('expect.js'); -const path = require('path'); +import { describe, it, expect } from 'vitest'; +import { resolveModel } from '../../../lib/utils/resolveModel.js'; +import path from 'node:path'; -describe('resolveModule', function () { +describe('resolveModule', () => { it("should throw a correct error when resolving a module which has a some error in it's body", () => { // see GH issue #962 expect(() => { - resolveModel(path.resolve(__dirname, '../relations/files/ModelWithARandomError.js')); - }).throwError(/some random error/); + resolveModel( + path.resolve(import.meta.dirname, '../relations/files/ModelWithARandomError.js'), + ); + }).toThrow(/some random error/); + }); + + it("should throw a correct error when resolving an ES module which has a some error in it's body", () => { + expect(() => { + resolveModel( + path.resolve(import.meta.dirname, '../relations/files/esm/ModelWithARandomError.js'), + ); + }).toThrow(/some random error/); }); }); diff --git a/tsconfig.json b/tsconfig.json index c52d4b9cf..75bcfc43c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,18 @@ { "compilerOptions": { "allowJs": false, + "declaration": true, "experimentalDecorators": true, - "module": "commonjs", - "moduleResolution": "node", + "lib": ["es2022"], + "module": "nodenext", + "moduleResolution": "nodenext", "noEmit": true, "noImplicitReturns": true, - "noImplicitUseStrict": false, "noImplicitAny": true, "strict": true, - "target": "es6", + "target": "es2022", "types": ["node"] }, - "include": ["tests/ts/**/*.ts"] + "include": ["tests/ts/**/*.ts"], + "exclude": ["tests/ts/strict"] } diff --git a/typings/objection/index.d.ts b/typings/objection/index.d.ts index 5fb8c413c..4e53552da 100644 --- a/typings/objection/index.d.ts +++ b/typings/objection/index.d.ts @@ -12,382 +12,432 @@ // * Karl Blomster // * And many others: See -import Ajv, { Options as AjvOptions } from 'ajv'; -import * as dbErrors from 'db-errors'; +import { Ajv, Options as AjvOptions } from 'ajv'; import { Knex } from 'knex'; -// Export the entire Objection namespace. -export = Objection; +// Only the declarations marked with `export` are exported. +export {}; // Phantom brand of the relation types, used by `TypedRelationMappings`. declare const relationKind: unique symbol; // Phantom brand of `Generated` properties. declare const generated: unique symbol; +// Phantom brand of the rows narrowed by `select()`, holding their SQL aliases. +declare const selected: unique symbol; +// Phantom brand of the rows with `jsonExtract()` aliases. +declare const extracted: unique symbol; -declare namespace Objection { - const raw: RawFunction; - const val: ValueFunction; - const ref: ReferenceFunction; - const fn: FunctionFunction; - - const compose: ComposeFunction; - const mixin: MixinFunction; - - const snakeCaseMappers: SnakeCaseMappersFactory; - const knexSnakeCaseMappers: KnexSnakeCaseMappersFactory; - const knexIdentifierMapping: KnexIdentifierMappingFactory; - - const transaction: transaction; - const initialize: initialize; - - // Import aliases re-export both the value and the type side of the classes, - // so they can be used with `instanceof` as well as in type annotations. - export import DBError = dbErrors.DBError; - export import DataError = dbErrors.DataError; - export import CheckViolationError = dbErrors.CheckViolationError; - export import UniqueViolationError = dbErrors.UniqueViolationError; - export import ConstraintViolationError = dbErrors.ConstraintViolationError; - export import ForeignKeyViolationError = dbErrors.ForeignKeyViolationError; - export import NotNullViolationError = dbErrors.NotNullViolationError; - - export interface RawBuilder extends Aliasable {} - - export interface RawFunction extends RawInterface {} - export interface RawInterface { - (sql: string, ...bindings: any[]): R; - } +export const raw: RawFunction; +export const val: ValueFunction; +export const ref: ReferenceFunction; +export const fn: FunctionFunction; - export interface ValueBuilder extends Castable {} - export interface ValueFunction { - ( - value: PrimitiveValue | PrimitiveValue[] | PrimitiveValueObject | PrimitiveValueObject[], - ): ValueBuilder; - } +export const compose: ComposeFunction; +export const mixin: MixinFunction; - export interface ReferenceBuilder extends Castable { - from(tableReference: string): this; - } - export interface ReferenceFunction { - (expression: string): ReferenceBuilder; - } +export const asArray: AsArrayFunction; - export interface FunctionBuilder extends Castable {} - export interface SqlFunctionShortcut { - (...args: any[]): FunctionBuilder; - } - export interface FunctionFunction { - (functionName: string, ...arguments: any[]): FunctionBuilder; - - now(precision: number): FunctionBuilder; - now(): FunctionBuilder; - - coalesce: SqlFunctionShortcut; - concat: SqlFunctionShortcut; - sum: SqlFunctionShortcut; - avg: SqlFunctionShortcut; - min: SqlFunctionShortcut; - max: SqlFunctionShortcut; - count: SqlFunctionShortcut; - upper: SqlFunctionShortcut; - lower: SqlFunctionShortcut; - } - - export interface ComposeFunction { - (...plugins: Plugin[]): Plugin; - (plugins: Plugin[]): Plugin; - } +export const snakeCaseMappers: SnakeCaseMappersFactory; +export const knexSnakeCaseMappers: KnexSnakeCaseMappersFactory; +export const knexIdentifierMapping: KnexIdentifierMappingFactory; - export interface Plugin { - (modelClass: M): M; - } +export const transaction: transaction; +export const initialize: initialize; - export interface MixinFunction { - (modelClass: MC, ...plugins: Plugin[]): MC; - (modelClass: MC, plugins: Plugin[]): MC; - } +// Database errors, vendored from the db-errors library. +export class DBError extends Error { + name: string; + nativeError: Error; +} - interface Aliasable { - as(alias: string): this; - } +export class CheckViolationError extends DBError { + table: string; + constraint: string; +} - interface Castable extends Aliasable { - castText(): this; - castInt(): this; - castBigInt(): this; - castFloat(): this; - castDecimal(): this; - castReal(): this; - castBool(): this; - castJson(): this; - castArray(): this; - asArray(): this; - castType(sqlType: string): this; - castTo(sqlType: string): this; - } +export class ConstraintViolationError extends DBError {} - type Raw = RawBuilder | Knex.Raw; - type Operator = string; - type ColumnRef = string | Raw | ReferenceBuilder; - type TableRef = ColumnRef | AnyQueryBuilder | CallbackVoid; - - type PrimitiveValue = - | string - | number - | boolean - | bigint - | Date - | Buffer - | string[] - | number[] - | boolean[] - | bigint[] - | Date[] - | Buffer[] - | null; - - type Expression = T | Raw | ReferenceBuilder | ValueBuilder | AnyQueryBuilder; - - type Id = string | number | BigInt | Buffer; - type CompositeId = Id[]; - type MaybeCompositeId = Id | CompositeId; - - interface ExpressionObject { - [key: string]: Expression; - } +export class DataError extends DBError {} - interface PrimitiveValueObject { - [key: string]: PrimitiveValue; - } +export class ForeignKeyViolationError extends ConstraintViolationError { + table: string; + constraint: string; + schema?: string; +} - interface CallbackVoid { - (this: T, arg: T): void; - } +export class NotNullViolationError extends ConstraintViolationError { + table: string; + column: string; + database?: string; + schema?: string; +} - type Identity = (value: T) => T; - type AnyQueryBuilder = QueryBuilder; - type AnyModelConstructor = ModelConstructor; - type ModifierFunction = (qb: QB, ...args: any[]) => void; - type Modifier = - ModifierFunction | string | string[] | Record>; - type OrderByDirection = 'asc' | 'desc' | 'ASC' | 'DESC'; - type OrderByNulls = 'first' | 'last'; - - interface Modifiers { - [key: string]: Modifier; - } +export class UniqueViolationError extends ConstraintViolationError { + table: string; + columns: string[]; + constraint: string; + schema?: string; +} - type RelationExpression = string | object; +export interface RawBuilder extends Aliasable {} - // Type-level parsing of relation expressions, used to narrow the result - // types of `withGraphFetched()`, `withGraphJoined()` and `fetchGraph()`. +export interface RawFunction extends RawInterface {} +export interface RawInterface { + (sql: string, ...bindings: any[]): R; +} - /** - * Parses a relation expression, in string or object notation, into a tree - * of fetched relations: '[pets.owner, children]' and - * `{ pets: { owner: true }, children: true }` both become - * `{ pets: { owner: {} }, children: {} }`. - * - * Anything that isn't a literal expression (e.g. a `string` variable) yields - * `{}`, and so do nodes the parser doesn't understand (aliases, `*`, - * recursion with `^`), so these simply don't narrow. - */ - type ParseRelationExpression = string extends E - ? {} - : E extends string - ? ParseRelationString - : ParseRelationObject; +export interface ValueBuilder extends Castable {} +export interface ValueFunction { + ( + value: PrimitiveValue | PrimitiveValue[] | PrimitiveValueObject | PrimitiveValueObject[], + ): ValueBuilder; +} - type Whitespace = ' ' | '\n' | '\r' | '\t'; +export interface ReferenceBuilder extends Castable { + from(tableReference: string): this; +} +export interface ReferenceFunction { + (expression: string): ReferenceBuilder; +} - type Trim = S extends `${Whitespace}${infer R}` - ? Trim - : S extends `${infer L}${Whitespace}` - ? Trim - : S; +export interface FunctionBuilder extends Castable {} +export interface SqlFunctionShortcut { + (...args: any[]): FunctionBuilder; +} +export interface FunctionFunction { + (functionName: string, ...arguments: any[]): FunctionBuilder; + + now(precision: number): FunctionBuilder; + now(): FunctionBuilder; + + coalesce: SqlFunctionShortcut; + concat: SqlFunctionShortcut; + sum: SqlFunctionShortcut; + avg: SqlFunctionShortcut; + min: SqlFunctionShortcut; + max: SqlFunctionShortcut; + count: SqlFunctionShortcut; + upper: SqlFunctionShortcut; + lower: SqlFunctionShortcut; +} - /** - * Counts the occurrences of character C in S, as a tuple length. - */ - type CountChar< - S extends string, - C extends string, - N extends 0[] = [], - > = S extends `${string}${C}${infer R}` ? CountChar : N['length']; +export interface ComposeFunction { + (...plugins: Plugin[]): Plugin; + (plugins: Plugin[]): Plugin; +} - /** - * True if S has as many opening as closing brackets and parentheses. - */ - type IsBalanced = - CountChar extends CountChar - ? CountChar extends CountChar - ? true - : false - : false; +export interface AsArrayFunction { + (value: T | T[] | undefined): T[]; +} - /** - * Splits a list of expressions at the commas that aren't nested inside - * brackets or parentheses: 'a, b.[c, d]' -> ['a', 'b.[c, d]']. - */ - type SplitList< - S extends string, - Acc extends string = '', - Out extends string[] = [], - > = S extends `${infer Head},${infer Rest}` - ? IsBalanced<`${Acc}${Head}`> extends true - ? SplitList - : SplitList - : [...Out, `${Acc}${S}`]; - - type ParseRelationList = L extends [ - infer H extends string, - ...infer T extends string[], - ] - ? ParseRelationList> - : Acc; +export interface Plugin { + (modelClass: M): M; +} - /** - * Parses '[a, b]', 'a.b' and 'a' style expressions. - */ - type ParseRelationString = - Trim extends `[${infer Inner}]` - ? ParseRelationList> - : Trim extends `${infer Head}.${infer Rest}` - ? IsBalanced extends true - ? RelationNode, ParseRelationString> - : {} - : RelationNode, {}>; +export interface MixinFunction { + (modelClass: MC, ...plugins: Plugin[]): MC; + (modelClass: MC, plugins: Plugin[]): MC; +} - /** - * Extracts the relation name from a single node, dropping modifiers: - * 'pets(selectName)' -> 'pets'. Returns never for nodes that can't be - * narrowed: aliases ('pets as p'), '*' and recursion ('^', '^2'). - */ - type RelationName = - Trim extends `${infer Name}(${string})` - ? RelationName - : Trim extends infer Name extends string - ? Name extends '' | '*' | `^${string}` | `${string}${Whitespace}${string}` - ? never - : string extends Name - ? never - : Name - : never; +export interface Aliasable { + as(alias: string): this; +} - type RelationNode = [Name] extends [never] - ? {} - : { [K in Name]: Children }; +export interface Castable extends Aliasable { + castText(): this; + castInt(): this; + castBigInt(): this; + castFloat(): this; + castDecimal(): this; + castReal(): this; + castBool(): this; + castJson(): this; + castArray(): this; + asArray(): this; + castType(sqlType: string): this; + castTo(sqlType: string): this; +} - /** - * Parses `{ a: true, b: { c: true } }` style expressions. Keys starting with - * `$` are options, and aliased nodes (`{ p: { $relation: 'pets' } }`) are - * skipped. - */ - type ParseRelationObject = string extends keyof E - ? {} - : { - [ - K in keyof E as K extends `$${string}` - ? never - : false extends E[K] - ? never - : E[K] extends { $relation: any } - ? never - : K - ]: E[K] extends object ? ParseRelationObject : {}; - }; +export type Raw = RawBuilder | Knex.Raw; +export type Operator = string; +export type ColumnRef = string | Raw | ReferenceBuilder; +export type TableRef = ColumnRef | AnyQueryBuilder | CallbackVoid; + +export type PrimitiveValue = + | string + | number + | boolean + | bigint + | Date + | Buffer + | string[] + | number[] + | boolean[] + | bigint[] + | Date[] + | Buffer[] + | null; + +export type Expression = T | Raw | ReferenceBuilder | ValueBuilder | AnyQueryBuilder; + +export type Id = string | number | BigInt | Buffer; +export type CompositeId = Id[]; +export type MaybeCompositeId = Id | CompositeId; + +export interface ExpressionObject { + [key: string]: Expression; +} - /** - * Marks the relations in tree T as fetched on model M: they become required - * and are narrowed recursively. Only `undefined` is removed: declared `null` - * is kept, as to-one relations can be null when there is no related row. - */ - type WithGraph = [keyof T] extends [never] - ? M - : WithGraphRelations>; +export interface PrimitiveValueObject { + [key: string]: PrimitiveValue; +} - type WithGraphRelations = [K] extends [never] - ? M - : { -readonly [P in K]-?: WithGraphProperty, T[P]> } & UnnarrowedMethods & M; +export interface CallbackVoid { + (this: T, arg: T): void; +} - /** - * `$query()` of the un-narrowed model M, as it doesn't fetch the relations - * that were fetched on the instance. Otherwise its polymorphic `this` would - * resolve to the narrowed model. With chained narrowing, the first `$query` - * signature of the intersection wins, which is the one of the innermost, - * un-narrowed model. - */ - type UnnarrowedMethods = Pick>; +export type Identity = (value: T) => T; +export type AnyQueryBuilder = QueryBuilder; +export type AnyModelConstructor = ModelConstructor; +export type ModifierFunction = (qb: QB, ...args: any[]) => void; +export type Modifier = + ModifierFunction | string | string[] | Record>; +export type OrderByDirection = 'asc' | 'desc' | 'ASC' | 'DESC'; +export type OrderByNulls = 'first' | 'last'; + +export interface Modifiers { + [key: string]: Modifier; +} - /** - * The keys of tree T that are relation properties of model M. - */ - type FetchedRelations = { - [K in keyof M & keyof T]: NonNullable extends Model | Model[] ? K : never; - }[keyof M & keyof T]; - - type WithGraphProperty = [keyof T] extends [never] - ? P - : P extends Array - ? WithGraph[] - : P extends Model - ? WithGraph - : P; +export type RelationExpression = string | object; + +// Type-level parsing of relation expressions, used to narrow the result +// types of `withGraphFetched()`, `withGraphJoined()` and `fetchGraph()`. + +/** + * Parses a relation expression, in string or object notation, into a tree + * of fetched relations: '[pets.owner, children]' and + * `{ pets: { owner: true }, children: true }` both become + * `{ pets: { owner: {} }, children: {} }`. + * + * Anything that isn't a literal expression (e.g. a `string` variable) yields + * `{}`, and so do nodes the parser doesn't understand (aliases, `*`, + * recursion with `^`), so these simply don't narrow. So does `never`, which + * TypeScript substitutes for E when it checks the intersections of `Rebind`. + */ +export type ParseRelationExpression = [E] extends [never] + ? {} + : string extends E + ? {} + : E extends string + ? ParseRelationString + : ParseRelationObject; - /** - * The model type after fetching the relation expression E on model M. - */ - type WithGraphModel = WithGraph>; +export type Whitespace = ' ' | '\n' | '\r' | '\t'; + +export type Trim = S extends `${Whitespace}${infer R}` + ? Trim + : S extends `${infer L}${Whitespace}` + ? Trim + : S; + +/** + * Counts the occurrences of character C in S, as a tuple length. + */ +export type CountChar< + S extends string, + C extends string, + N extends 0[] = [], +> = S extends `${string}${C}${infer R}` ? CountChar : N['length']; + +/** + * True if S has as many opening as closing brackets and parentheses. + */ +export type IsBalanced = + CountChar extends CountChar + ? CountChar extends CountChar + ? true + : false + : false; + +/** + * Splits a list of expressions at the commas that aren't nested inside + * brackets or parentheses: 'a, b.[c, d]' -> ['a', 'b.[c, d]']. + */ +export type SplitList< + S extends string, + Acc extends string = '', + Out extends string[] = [], +> = S extends `${infer Head},${infer Rest}` + ? IsBalanced<`${Acc}${Head}`> extends true + ? SplitList + : SplitList + : [...Out, `${Acc}${S}`]; + +export type ParseRelationList = L extends [ + infer H extends string, + ...infer T extends string[], +] + ? ParseRelationList> + : Acc; + +/** + * Parses '[a, b]', 'a.b' and 'a' style expressions. + */ +export type ParseRelationString = + Trim extends `[${infer Inner}]` + ? ParseRelationList> + : Trim extends `${infer Head}.${infer Rest}` + ? IsBalanced extends true + ? RelationNode, ParseRelationString> + : {} + : RelationNode, {}>; + +/** + * Extracts the relation name from a single node, dropping modifiers: + * 'pets(selectName)' -> 'pets'. Returns never for nodes that can't be + * narrowed: aliases ('pets as p'), '*' and recursion ('^', '^2'). + */ +export type RelationName = + Trim extends `${infer Name}(${string})` + ? RelationName + : Trim extends infer Name extends string + ? Name extends '' | '*' | `^${string}` | `${string}${Whitespace}${string}` + ? never + : string extends Name + ? never + : Name + : never; - /** - * The query builder type for model M after fetching relation expression E. - * `M extends unknown` makes this distributive, so that in generic code (e.g. - * `this` in model methods), TypeScript can resolve it through the constraint - * of M. - */ - type WithGraphModelQueryBuilder = M extends unknown - ? WithGraphModel['QueryBuilderType'] - : never; +export type RelationNode = [Name] extends [never] + ? {} + : { [K in Name]: Children }; + +/** + * Parses `{ a: true, b: { c: true } }` style expressions. Keys starting with + * `$` are options, and aliased nodes (`{ p: { $relation: 'pets' } }`) are + * skipped. + */ +export type ParseRelationObject = string extends keyof E + ? {} + : { + [ + K in keyof E as K extends `$${string}` + ? never + : false extends E[K] + ? never + : E[K] extends { $relation: any } + ? never + : K + ]: E[K] extends object ? ParseRelationObject : {}; + }; - /** - * The query builder type after `withGraphFetched(E)` / `withGraphJoined(E)` - * on QB. The model type is narrowed, and the query builder for the narrowed - * model is looked up through its `QueryBuilderType`, so custom query - * builders are kept. Falls back to QB if nothing can be narrowed, or if the - * model type is `any` (`0 extends 1 & M`), e.g. for AnyQueryBuilder. - * `QB extends unknown` below serves the same purpose as in - * WithGraphModelQueryBuilder. - */ - type WithGraphQueryBuilder = WithGraphTreeQueryBuilder< - QB, - ParseRelationExpression - >; - - type WithGraphTreeQueryBuilder = [keyof T] extends [never] - ? QB - : QB extends unknown - ? 0 extends 1 & ModelType +/** + * Marks the relations in tree T as fetched on model M: they become required + * and are narrowed recursively. Only `undefined` is removed: declared `null` + * is kept, as to-one relations can be null when there is no related row. + */ +export type WithGraph = [keyof T] extends [never] + ? M + : WithGraphRelations>; + +export type WithGraphRelations = [K] extends [never] + ? M + : { -readonly [P in K]-?: WithGraphProperty, T[P]> } & UnnarrowedMethods & M; + +/** + * `$query()` of the un-narrowed model M, as it doesn't fetch the relations + * that were fetched on the instance. Otherwise its polymorphic `this` would + * resolve to the narrowed model. With chained narrowing, the first `$query` + * signature of the intersection wins, which is the one of the innermost, + * un-narrowed model. + */ +export type UnnarrowedMethods = Pick>; + +/** + * The keys of tree T that are relation properties of model M. + */ +export type FetchedRelations = { + [K in keyof M & keyof T]: NonNullable extends Model | Model[] ? K : never; +}[keyof M & keyof T]; + +export type WithGraphProperty = [keyof T] extends [never] + ? P + : P extends Array + ? WithGraph[] + : P extends Model + ? WithGraph + : P; + +/** + * The model type after fetching the relation expression E on model M. + */ +export type WithGraphModel = WithGraph>; + +/** + * The query builder type after `withGraphFetched(E)` / `withGraphJoined(E)` + * on QB. The model type is narrowed, and the query builder for the narrowed + * model is looked up through its `QueryBuilderType`, so custom query + * builders are kept. Falls back to QB if nothing can be narrowed, or if the + * model type is `any` (`0 extends 1 & M`), e.g. for AnyQueryBuilder. + * `QB extends unknown` makes this distributive, so that in generic code (e.g. + * `this` in model methods), TypeScript can resolve it through the constraint + * of QB. + */ +export type WithGraphQueryBuilder = WithGraphTreeQueryBuilder< + QB, + ParseRelationExpression +>; + +export type WithGraphTreeQueryBuilder = [keyof T] extends [never] + ? QB + : QB extends unknown + ? 0 extends 1 & ModelType + ? QB + : [FetchedRelations, T>] extends [never] ? QB - : [FetchedRelations, T>] extends [never] - ? QB - : WithResultKind, T>['QueryBuilderType']> - : never; + : WithResultKind< + QB, + WithGraph, T>['QueryBuilderType'], + ModelType, + ResultType, + T + > + : never; - /** - * Converts the query builder NQB to the same result kind as QB: - * array, page, single or maybe-single. Any other result type, e.g. after - * `castTo()` or `patch()`, falls back to QB, without narrowing. - * QB's model type M and result type R are separate parameters so that QB - * only appears in the result, and TypeScript can relate the kinds of two - * query builders, e.g. of a custom query builder and its base class, through - * QB alone. The page and `object` checks come before the single ones, so - * that results like `number` or `Page` are ruled out for a generic M too. - */ - type WithResultKind< - QB extends AnyQueryBuilder, - NQB extends AnyQueryBuilder, - M extends Model = ModelType, - R = ResultType, - > = [R] extends [M[]] +/** + * Narrows the rows in the result R by the relation tree T, after `select()` + * narrowed them in strict mode. + */ +type WithGraphResult = R extends readonly (infer I)[] + ? WithGraph[] + : R extends Page + ? Page> + : R extends object + ? WithGraph + : R; + +/** + * Converts the query builder NQB to the same result kind as QB: + * array, page, single or maybe-single. Rows that `select()` narrowed in + * strict mode or that have `jsonExtract()` values get the relations of tree T + * instead. Any other result type, e.g. after `castTo()` or `patch()`, falls + * back to QB, without narrowing. + * QB's model type M and result type R are separate parameters so that QB + * only appears in the result, and TypeScript can relate the kinds of two + * query builders, e.g. of a custom query builder and its base class, through + * QB alone. The page and `object` checks come before the single ones, so + * that results like `number` or `Page` are ruled out for a generic M too. + */ +export type WithResultKind< + QB extends AnyQueryBuilder, + NQB extends AnyQueryBuilder, + M extends Model = ModelType, + R = ResultType, + T = {}, +> = [R] extends [ExtractedRows] + ? Rebind> + : [R] extends [M[]] ? ArrayQueryBuilder : [R] extends [Page] ? PageQueryBuilder @@ -396,2047 +446,3035 @@ declare namespace Objection { ? SingleQueryBuilder : [R] extends [M | undefined] ? MaybeSingleQueryBuilder - : QB + : Rebind> : QB; - /** - * If T is an array, returns the item type, otherwise returns T. - */ - type ItemType = T extends Array ? T[number] : T; - - /** - * Type for keys of non-function properties of T. - */ - type NonFunctionPropertyNames = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T]; - - /** - * Type that attempts to only select the user-defined model properties. - */ - type DataPropertyNames = Exclude, 'QueryBuilderType'>; - - /** - * Removes `undefined` from a type. - */ - type Defined = Exclude; - - /** - * A Pojo version of model. - */ - type ModelObject = Pick>; - - /** - * Any object that has some of the properties of model class T match this type. - */ - type PartialModelObject = { - [K in DataPropertyNames]?: Defined extends Model - ? T[K] | PartialModelObject> - : Defined extends Array - ? I extends Model - ? I[] | PartialModelObject[] - : Expression - : NonNullable extends Model - ? Expression | PartialModelObject> // nullable to-one relations - : Expression; - }; +/** + * The item of the result R of a query for model M, which `first()`, + * `findById()`, `page()` and the other result kinds keep: in strict mode, the + * item of an array or a page, or the single result, e.g. the rows narrowed by + * `select()`. M in loose mode, and for other results, e.g. numbers. + */ +type ResultItem = IfStrict, M>>; + +type ItemOf = R extends readonly (infer I)[] + ? I + : R extends Page + ? I + : R extends object + ? R + : M; + +/** + * If T is an array, returns the item type, otherwise returns T. + */ +export type ItemType = T extends Array ? T[number] : T; + +/** + * Type for keys of non-function properties of T. + */ +export type NonFunctionPropertyNames = { + [K in keyof T]: T[K] extends Function ? never : K; +}[keyof T]; + +// The type-only members of models, which aren't data. +type ModelTypeMembers = 'QueryBuilderType' | 'ColumnNameMapping'; + +/** + * Type that attempts to only select the user-defined model properties. + * Properties starting with `$` are internal, like in `toJSON()`. + */ +export type DataPropertyNames = Exclude< + NonFunctionPropertyNames, + ModelTypeMembers | `$${string}` +>; + +/** + * Removes `undefined` from a type. + */ +export type Defined = Exclude; + +/** + * A Pojo version of model. + */ +export type ModelObject = Pick>; + +/** + * The JSON of model M, as `toJSON()` returns it: its properties without + * methods, and the related models as JSON too. Values aren't converted, e.g. + * dates stay dates. + */ +export type ModelJson = { + [K in keyof Pick & keyof M>]: K extends RelationPropertyNames + ? RelationJson + : M[K]; +}; + +type RelationJson

= P extends (infer I)[] + ? ModelJson[] + : P extends readonly (infer I)[] + ? readonly ModelJson[] + : P extends ModelLike + ? ModelJson

+ : P; + +/** + * Any object that has some of the properties of model class T match this type. + */ +export type PartialModelObject = { + [K in DataPropertyNames]?: Defined extends Model + ? T[K] | PartialModelObject> + : Defined extends Array + ? I extends Model + ? I[] | PartialModelObject[] + : Expression + : NonNullable extends Model + ? Expression | PartialModelObject> // nullable to-one relations + : Expression; +}; + +/** + * Like PartialModelObject, but relation properties are not allowed. Unlike + * PartialModelObject, this doesn't depend on the relation types, so query + * builders for models with narrowed relations (see WithGraphModel) remain + * assignable to the query builders for the plain models. + */ +export type PartialModelProps = { + [K in DataPropertyNames]?: K extends RelationPropertyNames ? never : Expression; +}; + +/** + * The data of a row of model M, as `insert()`, `patch()`, `update()` and + * `relate()` take it: a model instance, or some of its properties without + * relations, as these methods don't write relations. Only the columns of + * instances are checked, so that instances can also be passed to the query + * builders of narrowed models, e.g. after `withGraphFetched()`. + */ +export type PartialModelRow = + (ModelLike & { [K in InsertablePropertyNames]?: M[K] }) | PartialModelProps; + +/** + * The data of `patch()` and `merge()`: a row of model M, also with field + * expression keys like `'address:street'` that write parts of json columns. + */ +export type PartialModelPatch = + | PartialModelRow + | (PartialModelProps & { + [K in `${InsertablePropertyNames & string}:${string}`]?: unknown; + }); + +/** + * Additional optional parameters that may be used in graphs. + */ +export type GraphParameters = { + '#dbRef'?: MaybeCompositeId; + '#ref'?: string; + '#id'?: string; + '#unrelate'?: boolean; + '#delete'?: boolean; +}; + +/** + * Just like PartialModelObject but this is applied recursively to relations. + */ +export type PartialModelGraph = T extends any + ? { + [K in DataPropertyNames]?: null extends T[K] + ? PartialModelGraphField> | null // handle nullable BelongsToOneRelations + : PartialModelGraphField; + } + : never; + +export type PartialModelGraphField = + Defined extends Model + ? PartialModelGraph> + : Defined extends Array + ? I extends Model + ? PartialModelGraph[] + : Expression + : Expression; + +/** + * Marks a model property whose value the database generates, like an + * auto-incremented id, a timestamp or a column with a default value. It + * reads as T and accepts T, but `Insertable` and `InsertableGraph` treat + * the property as optional: + * + * id!: Generated; + */ +export type Generated = T extends null | undefined ? T : T & GeneratedBrand; + +export interface GeneratedBrand { + readonly [generated]?: true; +} - /** - * Like PartialModelObject, but relation properties are not allowed. Unlike - * PartialModelObject, this doesn't depend on the relation types, so query - * builders for models with narrowed relations (see WithGraphModel) remain - * assignable to the query builders for the plain models. - */ - type PartialModelProps = { - [K in DataPropertyNames]?: Defined extends Model | Model[] ? never : Expression; +/** + * Insert keys are optional for optional, nullable and generated properties. + */ +export type OptionalInsertPropertyNames = { + [K in keyof M]-?: {} extends Pick + ? K + : null extends M[K] + ? K + : typeof generated extends keyof NonNullable + ? K + : never; +}[keyof M]; + +export type InsertablePropertyNames = Exclude, RelationPropertyNames>; + +/** + * The data to insert a model M with `insert()`. Unlike `PartialModelObject`, + * all properties are required except optional, nullable and `Generated` + * ones. Relation properties are left out. + */ +export type Insertable = { + [K in Exclude, OptionalInsertPropertyNames>]: Expression; +} & { + [K in Extract, OptionalInsertPropertyNames>]?: Expression; +}; + +/** + * Like `Insertable`, but for `insertGraph()`: relation properties are + * optional and hold the insertable graphs of the related models, or + * references to existing ones through `#dbRef` or `#ref`. + */ +export type InsertableGraph = Insertable & + Omit & { + [K in RelationPropertyNames]?: InsertableGraphField; }; - /** - * Additional optional parameters that may be used in graphs. - */ - type GraphParameters = { - '#dbRef'?: MaybeCompositeId; - '#ref'?: string; - '#id'?: string; - '#unrelate'?: boolean; - '#delete'?: boolean; - }; +export type InsertableGraphReference = { '#dbRef': MaybeCompositeId } | { '#ref': string }; - /** - * Just like PartialModelObject but this is applied recursively to relations. - */ - type PartialModelGraph = T extends any - ? { - [K in DataPropertyNames]?: null extends T[K] - ? PartialModelGraphField> | null // handle nullable BelongsToOneRelations - : PartialModelGraphField; - } - : never; +export type InsertableGraphField = + NonNullable extends (infer I extends Model)[] + ? (InsertableGraph | InsertableGraphReference)[] + : NonNullable extends infer I extends Model + ? InsertableGraph | InsertableGraphReference | Extract + : never; - type PartialModelGraphField = - Defined extends Model - ? PartialModelGraph> - : Defined extends Array +/** + * Extracts the property names (excluding relations) of a model class. + */ +export type ModelProps = Exclude< + { + [K in keyof T]?: Defined extends Model + ? never + : Defined extends Array ? I extends Model - ? PartialModelGraph[] - : Expression - : Expression; - - /** - * Marks a model property whose value the database generates, like an - * auto-incremented id, a timestamp or a column with a default value. It - * reads as T and accepts T, but `Insertable` and `InsertableGraph` treat - * the property as optional: - * - * id!: Generated; - */ - export type Generated = T extends null | undefined ? T : T & GeneratedBrand; - - interface GeneratedBrand { - readonly [generated]?: true; - } - - /** - * Insert keys are optional for optional, nullable and generated properties. - */ - type OptionalInsertPropertyNames = { - [K in keyof M]-?: {} extends Pick + ? never + : K + : T[K] extends Function + ? never + : K; + }[keyof T], + undefined | ModelTypeMembers +>; + +/** + * Extracts the relation names of the a model class. + */ +export type ModelRelations = Defined< + { + [K in keyof T]?: Defined extends Model ? K - : null extends M[K] - ? K - : typeof generated extends keyof NonNullable + : Defined extends Array + ? I extends Model ? K - : never; - }[keyof M]; - - type InsertablePropertyNames = Exclude, RelationPropertyNames>; - - /** - * The data to insert a model M with `insert()`. Unlike `PartialModelObject`, - * all properties are required except optional, nullable and `Generated` - * ones. Relation properties are left out. - */ - export type Insertable = { - [K in Exclude, OptionalInsertPropertyNames>]: Expression; - } & { - [K in Extract, OptionalInsertPropertyNames>]?: Expression; - }; + : never + : never; + }[keyof T] +>; + +/** + * Given a model property type, returns a query builer type of + * correct kind if the property is a model or a model array. Queries for + * to-one relations resolve to `undefined` when there's no related row. + */ +export type RelatedQueryBuilder = T extends Model + ? MaybeSingleQueryBuilder> + : T extends Array + ? I extends Model + ? QueryBuilderType + : never + : never; - /** - * Like `Insertable`, but for `insertGraph()`: relation properties are - * optional and hold the insertable graphs of the related models, or - * references to existing ones through `#dbRef` or `#ref`. - */ - export type InsertableGraph = Insertable & - Omit & { - [K in RelationPropertyNames]?: InsertableGraphField; - }; +/** + * Just like RelatedQueryBuilder but always returns an array + * query builder even if the property type is a model and not + * an array of models. + */ +export type ArrayRelatedQueryBuilder = T extends Model + ? QueryBuilderType + : T extends Array + ? I extends Model + ? QueryBuilderType + : never + : never; - type InsertableGraphReference = { '#dbRef': MaybeCompositeId } | { '#ref': string }; +/** + * Gets the query builder type for a model type. + */ +export type QueryBuilderType = T['QueryBuilderType']; + +/** + * Gets the model type from a query builder type. + */ +export type ModelType = T['ModelType']; + +/** + * Gets the result type from a query builder type. + */ +export type ResultType = T['ResultType']; + +/** + * Re-instantiates the query builder QB with the model type M and the result + * type R, through its `RebindType`. The `~rebind` slot is `unknown`, so the + * intersection resolves it to exactly `[M, R]`. Rebinding QB's own + * `RebindType` instead of QB keeps the intersection small, which TypeScript + * checks much faster. + */ +type Rebind = (QB['RebindType'] & { + '~rebind': [M, R]; +})['RebindType']; + +/** + * Gets the single item query builder type for a query builder. + */ +export type SingleQueryBuilder = QB['~single']; + +/** + * Gets the single or undefined item query builder type for a query builder. + */ +export type MaybeSingleQueryBuilder = QB['~maybeSingle']; + +/** + * Gets the multi-item query builder type for a query builder. + */ +export type ArrayQueryBuilder = QB['~array']; + +/** + * Gets the number query builder type for a query builder. + */ +export type NumberQueryBuilder = QB['~number']; + +/** + * Gets the page query builder type for a query builder. + */ +export type PageQueryBuilder = QB['~page']; + +// Strict mode, on by default, checks the column names and values of queries +// against the models. Augment `TypeConfig` to opt out: +// +// declare module 'objection' { +// interface TypeConfig { +// strict: false; +// } +// } + +export interface TypeConfig {} + +type IsStrict = TypeConfig extends { strict: false } ? false : true; + +// As IsStrict isn't generic, IfStrict resolves eagerly. +type IfStrict = IsStrict extends true ? Strict : Loose; + +/** + * The column names of model M: its properties without methods and relations, + * in snake case for models that declare a snake case `ColumnNameMapping`. + */ +export type ColumnName = keyof ModelColumns & string; + +/** + * The columns of model M by their names in queries, with their types. + */ +type ModelColumns = M extends { ColumnNameMapping: 'snakeCase' | 'upperSnakeCase' } + ? { + [ + K in InsertablePropertyNames & string as M extends { + ColumnNameMapping: 'upperSnakeCase'; + } + ? Uppercase> + : SnakeCase + ]: M[K]; + } + : Pick & keyof M>; + +/** + * Converts a property name to snake case, as `snakeCaseMappers()` does with its + * default options: 'firstName' -> 'first_name', 'fooBAR' -> 'foo_bar'. + */ +type SnakeCase< + S extends string, + Prev extends string = '', + Out extends string = '', +> = S extends `${infer C}${infer Rest}` + ? SnakeCase< + Rest, + C, + `${Out}${Prev extends '' + ? Lowercase + : IsUpperCase extends true + ? IsUpperCase extends true + ? Lowercase + : `_${Lowercase}` + : C}` + > + : Out; + +type IsUpperCase = + C extends Uppercase ? (C extends Lowercase ? false : true) : false; + +/** + * The model at the end of the relation path P of model M, e.g. 'pets:owner', + * as objection names joined relations. `never` if P isn't a relation path. + */ +type RelationPathModel = P extends `${infer R}:${infer Rest}` + ? R extends RelationPropertyNames + ? RelationPathModel, Rest> + : never + : P extends RelationPropertyNames + ? RelatedModel + : never; - type InsertableGraphField = - NonNullable extends (infer I extends Model)[] - ? (InsertableGraph | InsertableGraphReference)[] - : NonNullable extends infer I extends Model - ? InsertableGraph | InsertableGraphReference | Extract +type RelatedModel

= NonNullable

extends readonly (infer I)[] ? I : NonNullable

; + +/** + * Checks the column C of a query for model M: a column of M, or qualified + * with a relation path, a column of the related model. Other qualifiers, e.g. + * table names and aliases, aren't checked. Resolves to C if it is valid, or + * else to the valid columns, for the error message. + */ +type CheckColumn = C extends `${infer Q}.${infer Col}` + ? [RelationPathModel] extends [never] + ? C + : Col extends ColumnName> + ? C + : `${Q}.${ColumnName>}` + : C extends ColumnName + ? C + : ColumnName; + +/** + * The type of the column C of model M, `unknown` if it can't be resolved. + */ +type ColumnType = C extends `${infer Q}.${infer Col}` + ? PropertyType, Col> + : PropertyType; + +type PropertyType = [M] extends [never] + ? unknown + : C extends keyof ModelColumns + ? ModelColumns[C] + : unknown; +/** + * Whether the columns of model M are checked: not for the base `Model` and + * other models without declared columns, e.g. in untyped modifiers, nor for + * models that declare `ColumnNameMapping: 'open'`, e.g. for custom column name + * mappers, nor for `any`, which is also what TypeScript checks arguments + * against when M is generic, e.g. in the methods of custom query builders. + * `any` is detected by a member that no type has: unlike `0 extends 1 & M`, + * the check of `[M]` is generic in M, which TypeScript needs to only relate + * arguments to the `false` branch of conditionals on IsConcreteModel in + * generic code. + */ +type IsConcreteModel = [M] extends [{ '~anyModel': true }] + ? false + : M extends { ColumnNameMapping: 'open' } + ? false + : [ColumnName] extends [never] + ? false + : true; + +type CheckedColumn = + IsConcreteModel extends true ? CheckColumn : string; + +/** + * A column argument: checked against model M in strict mode, any column in + * loose mode, where the model's properties come first, for autocompletion. + */ +type ColumnArg = IfStrict, CheckedColumn>; + +/** + * The value of a column of type T in conditions: `null` is always accepted, + * for `IS NULL` checks, dates accept strings too, and objects (JSON columns) + * and columns of unknown type anything. + */ +type ColumnValue = unknown extends T + ? PrimitiveValue | object + : NonNullable extends Date + ? Date | string | null + : NonNullable extends readonly unknown[] | string | number | boolean | bigint + ? NonNullable | null + : NonNullable extends object + ? PrimitiveValue | object + : NonNullable | null; + +/** + * The operators that compare a value of the column's type. Others, e.g. + * 'like' or the JSON operators, take any value. + */ +type ComparisonOperator = '=' | '<>' | '!=' | '<' | '>' | '<=' | '>=' | 'in' | 'not in'; + +type ValueArg = IfStrict< + Expression, + IsConcreteModel extends true + ? Expression>> + : Expression +>; + +/** + * The values of conditions with the operator O: comparisons are checked + * against the column's type, also as lists for 'in', other operators take + * any value. + */ +type OperatorValueArg = IfStrict< + Expression | readonly Expression[], + Lowercase extends ComparisonOperator + ? ValueArg | readonly ValueArg[] + : Expression | readonly Expression[] +>; + +/** + * The column checks of strict mode: C resolved to the checked column, or + * `string` for models that aren't concrete. In loose mode, `never`, which + * disables the overloads that use it. + */ +type StrictColumn = IfStrict>; + +/** + * Column references that aren't checked: any in loose mode, only raw and + * `ref()` in strict mode, the way to refer to columns of other queries, and + * any column for query builders whose model isn't concrete. Some generic + * query builders, e.g. after `withGraphFetched()` in model methods, resolve to + * unions, on which only these non-generic signatures can be called. + */ +type RefArg = IfStrict< + ColumnRef, + Raw | ReferenceBuilder | (IsConcreteModel> extends true ? never : string) +>; + +/** + * The columns of `orderBy()`, which also takes the aliases of the selections. + * `groupBy()` and `having()` don't: not all databases resolve them there. + */ +type StrictOutputColumn = IfStrict< + never, + C extends OutputColumn ? C : CheckedColumn, C> +>; + +/** + * Checks the columns C of a column list. Raw and `ref()` are passed through. + */ +type StrictColumns = IfStrict< + never, + { + [I in keyof C]: C[I] extends string + ? StrictColumn + : C[I] extends Raw | ReferenceBuilder + ? C[I] + : Raw | ReferenceBuilder; + } +>; + +/** + * Field expressions: a column, optionally followed by a JSON path, + * 'column:path.to.key'. The column is checked as in CheckColumn. + */ +type StrictFieldExpression = IfStrict< + never, + IsConcreteModel extends true + ? CheckColumn> extends FieldColumn + ? C + : CheckColumn> + : string +>; + +type FieldColumn = C extends `${infer Q}.${infer Rest}` + ? [RelationPathModel] extends [never] + ? FieldRootColumn + : `${Q}.${FieldRootColumn}` + : FieldRootColumn; + +type FieldRootColumn = C extends `${infer Col}:${string}` ? Col : C; + +/** + * Selections and aggregations: '*', 'table.*', columns and 'column as alias'. + */ +type CheckSelection = C extends '*' | `${string}.*` + ? C + : C extends `${infer Col} ${'as' | 'AS'} ${string}` + ? Col extends '*' + ? C + : CheckColumn extends Col + ? C + : `${CheckColumn} as ${string}` + : CheckColumn; + +type CheckedSelection = + IsConcreteModel extends true ? CheckSelection : string; + +type StrictSelections = IfStrict< + never, + { + [I in keyof C]: C[I] extends string + ? CheckedSelection + : C[I] extends number | Raw | ReferenceBuilder | AnyQueryBuilder + ? C[I] + : C[I] extends Function + ? CallbackVoid + : C[I] extends object + ? StrictSelectionObject + : never; + } +>; + +type StrictSelectionObject = { + [K in keyof O]: O[K] extends string + ? CheckedSelection + : O[K] extends readonly unknown[] + ? StrictSelections + : O[K] extends Raw | ReferenceBuilder | AnyQueryBuilder + ? O[K] : never; +}; + +/** + * The query builder QB with its result rows narrowed to the properties P in + * strict mode, next to the model's methods and relations, as after `select()` + * and the aggregates, marked with SelectedBrand. Repeated selections add up. Queries + * that aren't checked, e.g. in generic code, aren't narrowed. + */ +type NarrowRows = RowsQueryBuilder< + QB, + SelectBase, ModelType>, ModelType, All> & P & SelectedBrand<{}> +>; + +/** + * The query builder QB with the item of its result replaced by Row in strict + * mode. Queries that aren't checked keep their rows. + */ +type RowsQueryBuilder = IfStrict< + QB, + IsConcreteModel> extends true + ? Rebind, SelectResult, Row>> + : QB +>; + +/** + * The query builder QB after `clearSelect()`, which selects all columns again, + * next to the values of `jsonExtract()`. + */ +type ClearSelectQueryBuilder = + ResultItem, ModelType> extends infer Item + ? Item extends SelectedBrand + ? RowsQueryBuilder< + QB, + ModelType & + (Item extends ExtractedBrand + ? ExtractedProperties, keyof A & string> + : {}) + > + : QB + : never; - /** - * Extracts the property names (excluding relations) of a model class. - */ - type ModelProps = Exclude< - { - [K in keyof T]?: Defined extends Model - ? never - : Defined extends Array - ? I extends Model - ? never - : K - : T[K] extends Function - ? never - : K; - }[keyof T], - undefined | 'QueryBuilderType' - >; +/** + * The query builder QB after `jsonExtract()` with the alias A, which adds the + * extracted value to the rows. It isn't a select, so it doesn't narrow them. + */ +type JsonExtractQueryBuilder = RowsQueryBuilder< + QB, + ResultItem, ModelType> & ExtractedProperties, A> +>; + +type ExtractedProperties = { + [K in ResultKey]: unknown; +} & ExtractedBrand<{ [K in A]: true }>; + +/** + * The query builder QB after selecting C. Selections of all columns keep the + * columns in the rows. + */ +type SelectQueryBuilder = NarrowRows< + QB, + SelectedRow, C>, + true extends SelectsAll ? true : false +>; + +/** + * The query builder QB after the aggregation C. The values of `count()`, + * `sum()` and `avg()` are numbers, but some drivers return them as strings, + * e.g. Postgres for bigint and numeric. The ones of `min()` and `max()` have + * the column's type. + */ +type AggregateQueryBuilder< + QB extends AnyQueryBuilder, + C, + V extends AggregateValue, + A extends string = never, +> = NarrowRows< + QB, + [A] extends [never] + ? AggregatedProperties, C, V> + : { [K in ResultKey, A>]: number | string } & SelectedBrand<{ [K in A]: true }> +>; + +type AggregateValue = 'number' | 'column'; + +type AggregatedProperties = C extends `${infer Col} ${ + 'as' | 'AS'} ${infer A}` + ? { [K in ResultKey]: AggregateType } & SelectedBrand<{ [K in A]: true }> + : C extends string + ? UnknownSelection + : C extends readonly unknown[] + ? AggregatedList + : { + [K in keyof C & string as ResultKey]: C[K] extends string + ? AggregateType + : unknown; + } & SelectedBrand<{ [K in keyof C & string]: true }>; + +type AggregatedList< + M, + C extends readonly unknown[], + V extends AggregateValue, +> = C extends readonly [infer X, ...infer Rest] + ? AggregatedProperties & AggregatedList + : {}; + +type AggregateType = V extends 'number' + ? number | string + : SelectedType; + +/** + * The rows narrowed by `select()` carry the SQL aliases A of their selections, + * which `orderBy()` takes, see OutputColumn. It is exported for declaration + * files to name the rows, like GeneratedBrand. + */ +export type SelectedBrand = { readonly [selected]: A }; + +/** + * The rows with values of `jsonExtract()` carry their SQL aliases A apart from + * SelectedBrand, as they aren't narrowed. + */ +export type ExtractedBrand = { readonly [extracted]: A }; + +// The results whose rows have `jsonExtract()` values, which `withGraph*()` +// keeps instead of converting the result kind: +type ExtractedRows = + ExtractedBrand[] | Page> | ExtractedBrand | undefined; + +/** + * The base of the rows of a selection: the model without its columns for the + * first one, the rows selected so far for further ones, and the model's + * columns too when selecting all of them. + */ +type SelectBase = All extends true + ? M & Item + : Item extends SelectedBrand + ? Item + : Omit>; + +// Selections that can't be resolved, e.g. raw ones, add any properties of +// unknown types, under any aliases. +type UnknownSelection = { [key: string]: unknown } & SelectedBrand<{ [key: string]: true }>; + +type SelectsAll = X extends '*' | `${string}.*` ? true : false; + +/** + * Replaces the item of the result R with the row. + */ +type SelectResult = R extends readonly unknown[] + ? Row[] + : R extends Page + ? Page + : R extends undefined + ? undefined + : R extends object + ? Row + : R; + +/** + * The properties of the rows of the selections C. + */ +type SelectedRow = C extends readonly [infer X, ...infer Rest] + ? SelectedProperties & SelectedRow + : {}; + +type SelectedProperties = X extends '*' | `${string}.*` + ? {} + : X extends `${infer Col} ${'as' | 'AS'} ${infer A}` + ? { [K in ResultKey]: SelectedType } & SelectedBrand<{ [K in A]: true }> + : X extends string + ? { [K in ResultKey]: SelectedType } + : X extends Raw | ReferenceBuilder | AnyQueryBuilder | Function | number + ? UnknownSelection + : { + [K in keyof X & string as ResultKey]: X[K] extends string + ? SelectedType + : unknown; + } & SelectedBrand<{ [K in keyof X & string]: true }>; + +// Columns qualified with a name that isn't a relation path, usually the +// table name, are looked up in the model. +type SelectedType = C extends `${infer Q}.${infer Col}` + ? [RelationPathModel] extends [never] + ? PropertyType + : ColumnType + : ColumnType; + +/** + * The property of the column or alias C in the result rows: `snakeCaseMappers()` + * converts them to camel case, with `upperCase: true` after lowering the case + * of names in upper case. + */ +type ResultKey = M extends { ColumnNameMapping: 'snakeCase' } + ? CamelCase + : M extends { ColumnNameMapping: 'upperSnakeCase' } + ? CamelCase ? Lowercase : C> + : C; + +type CamelCase = S extends `${infer H}_${infer T}` + ? `${H}${CamelCase>}` + : S; + +/** + * The SQL aliases of the selections of QB, which `orderBy()` takes next to the + * columns. + */ +type OutputColumn = + ResultItem, ModelType> extends infer Item + ? | (Item extends SelectedBrand ? keyof A & string : never) + | (Item extends ExtractedBrand ? keyof A & string : never) + : never; - /** - * Extracts the relation names of the a model class. - */ - type ModelRelations = Defined< - { - [K in keyof T]?: Defined extends Model - ? K - : Defined extends Array - ? I extends Model - ? K +/** + * Checks the keys of a where object as columns, and their values. + */ +type StrictWhereObject = IfStrict< + never, + IsConcreteModel extends true + ? { + [K in keyof O]: K extends string + ? CheckColumn extends K + ? Expression>> : never : never; - }[keyof T] - >; - - /** - * Given a model property type, returns a query builer type of - * correct kind if the property is a model or a model array. - */ - type RelatedQueryBuilder = T extends Model - ? SingleQueryBuilder> - : T extends Array - ? I extends Model - ? QueryBuilderType - : never - : never; - - /** - * Just like RelatedQueryBuilder but always returns an array - * query builder even if the property type is a model and not - * an array of models. - */ - type ArrayRelatedQueryBuilder = T extends Model - ? QueryBuilderType - : T extends Array - ? I extends Model - ? QueryBuilderType - : never - : never; + } + : object +>; + +/** + * The names in the relation tree T that aren't relations of model M, or of + * the related models along the tree. + */ +type UnknownRelations = { + [K in keyof T & string]: K extends RelationPropertyNames + ? UnknownRelations, T[K]> + : K; +}[keyof T & string]; + +/** + * In strict mode, relation expressions are intersected with this, to reject + * the names of unknown relations. + */ +type CheckRelationExpression = IfStrict< + unknown, + IsConcreteModel extends true + ? [UnknownRelations>] extends [never] + ? unknown + : { '~unknownRelation': UnknownRelations> } + : unknown +>; + +export interface ForClassMethod { + (modelClass: ModelConstructor): QueryBuilderType; +} - /** - * Gets the query builder type for a model type. - */ - type QueryBuilderType = T['QueryBuilderType']; +/** + * https://ditojs.github.io/objection/api/types/#type-fieldexpression + */ +export type FieldExpression = string; - /** - * Gets the model type from a query builder type. - */ - type ModelType = T['ModelType']; +export type JsonObjectOrFieldExpression = object | object[] | FieldExpression; - /** - * Gets the result type from a query builder type. - */ - type ResultType = T['ResultType']; +export type Selection = ColumnRef | AnyQueryBuilder | CallbackVoid; - /** - * Gets the single item query builder type for a query builder. - */ - type SingleQueryBuilder = T['SingleQueryBuilderType']; +export interface SelectMethod { + // These must come first so that we get autocomplete. + (...columns: IfStrict>, never>[]): QB; + (columns: IfStrict>, never>[]): QB; - /** - * Gets the single or undefined item query builder type for a query builder. - */ - type MaybeSingleQueryBuilder = QB['MaybeSingleQueryBuilderType']; + (...columns: IfStrict, never>[]): QB; + (columns: IfStrict, never>[]): QB; - /** - * Gets the multi-item query builder type for a query builder. - */ - type ArrayQueryBuilder = T['ArrayQueryBuilderType']; + // Allows things like `select(1)`, not sure if we should be more specific here? + (...args: IfStrict[]): QB; - /** - * Gets the number query builder type for a query builder. - */ - type NumberQueryBuilder = T['NumberQueryBuilderType']; + ( + ...columns: StrictSelections, C> + ): SelectQueryBuilder; + ( + columns: StrictSelections, C>, + ): SelectQueryBuilder; +} - /** - * Gets the page query builder type for a query builder. - */ - type PageQueryBuilder = T['PageQueryBuilderType']; +export interface AsMethod { + (alias: string): QB; +} - interface ForClassMethod { - (modelClass: ModelConstructor): QueryBuilderType; - } +export interface FromMethod { + (table: TableRef, options?: { only?: boolean }): QB; +} - /** - * https://ditojs.github.io/objection/api/types/#type-fieldexpression - */ - type FieldExpression = string; +export interface FromRawMethod extends RawInterface {} - type JsonObjectOrFieldExpression = object | object[] | FieldExpression; +export interface JsonExtraction { + column: string | Raw | Knex.QueryBuilder; + path: string; + alias?: string; + singleValue?: boolean; +} - type Selection = ColumnRef | AnyQueryBuilder | CallbackVoid; +// Extractions without an alias, or with several, add any properties. +export interface JsonExtract { + // These must come first so that we get autocomplete. + ( + column: ColumnArg, C>, + path: string, + alias?: A, + singleValue?: boolean, + ): JsonExtractQueryBuilder; + + ( + column: RefArg, + path: string, + alias?: A, + singleValue?: boolean, + ): JsonExtractQueryBuilder; + ( + column: JsonExtraction[] | any[][], + singleValue?: boolean, + ): JsonExtractQueryBuilder; +} - interface SelectMethod { - // These must come first so that we get autocomplete. - (...columns: ModelProps>[]): QB; - (columns: ModelProps>[]): QB; +export interface JsonSet { + // These must come first so that we get autocomplete. + ( + column: ColumnArg, C>, + path: string, + value: any, + alias?: string, + ): QB; - (...columns: Selection[]): QB; - (columns: Selection[]): QB; + (column: RefArg, path: string, value: any, alias?: string): QB; +} - // Allows things like `select(1)`, not sure if we should be more specific here? - (...args: any[]): QB; - } +export interface JsonInsert { + // These must come first so that we get autocomplete. + ( + column: ColumnArg, C>, + path: string, + value: any, + alias?: string, + ): QB; - interface AsMethod { - (alias: string): QB; - } + (column: RefArg, path: string, value: any, alias?: string): QB; +} - interface FromMethod { - (table: TableRef, options?: { only?: boolean }): QB; - } +export interface JsonRemove { + // These must come first so that we get autocomplete. + ( + column: ColumnArg, C>, + path: string, + alias?: string, + ): QB; - interface FromRawMethod extends RawInterface {} + (column: RefArg, path: string, alias?: string): QB; +} - interface JsonExtraction { - column: string | Raw | Knex.QueryBuilder; - path: string; - alias?: string; - singleValue?: boolean; - } +export interface WhereMethod { + (condition: boolean): QB; + (cb: CallbackVoid): QB; + (raw: Raw): QB; + (qb: QBA): QB; + + (col: RefArg, op: Operator, expr: Expression): QB; + (col: RefArg, expr: Expression): QB; + + // The column overloads come last, so that errors are reported for them. + ( + col: ColumnArg, C>, + op: O, + expr: OperatorValueArg, C, O>, + ): QB; + ( + col: ColumnArg, C>, + expr: ValueArg, C>, + ): QB; + + (obj: IfStrict>, never>): QB; + // We must allow any keys in the object. The previous type + // is kind of useless, but maybe one day vscode and other + // tools can autocomplete using it. + (obj: IfStrict): QB; + (obj: O & StrictWhereObject, O>): QB; +} - interface JsonExtract { - // These must come first so that we get autocomplete. - ( - column: ModelProps>, - path: string, - alias?: string, - singleValue?: boolean, - ): QB; - - (column: ColumnRef, path: string, alias?: string, singleValue?: boolean): QB; - (column: JsonExtraction[] | any[][], singleValue?: boolean): QB; - } +export interface WhereLikeMethod { + (col: RefArg, expr: Expression): QB; + ( + col: ColumnArg, C>, + expr: Expression, + ): QB; +} - interface JsonSet { - // These must come first so that we get autocomplete. - ( - column: ModelProps>, - path: string, - value: any, - alias?: string, - ): QB; +export interface WhereRawMethod extends RawInterface {} - (column: ColumnRef, path: string, value: any, alias?: string): QB; - } +export interface WhereWrappedMethod { + (cb: CallbackVoid): QB; +} - interface JsonInsert { - // These must come first so that we get autocomplete. - ( - column: ModelProps>, - path: string, - value: any, - alias?: string, - ): QB; +export interface WhereExistsMethod { + (cb: CallbackVoid): QB; + (raw: Raw): QB; + (qb: QBA): QB; +} - (column: ColumnRef, path: string, value: any, alias?: string): QB; - } +export interface WhereInMethod { + // These must come first so that we get autocomplete. + ( + col: IfStrict>, never>, + expr: Expression, + ): QB; + (col: IfStrict>, never>, cb: CallbackVoid): QB; + (col: IfStrict>, never>, qb: AnyQueryBuilder): QB; + + (col: RefArg | RefArg[], expr: readonly Expression[]): QB; + (col: RefArg | RefArg[], cb: CallbackVoid): QB; + (col: RefArg | RefArg[], qb: AnyQueryBuilder): QB; + + ( + col: StrictColumn, C>, + expr: readonly ValueArg, C>[] | Raw | AnyQueryBuilder | CallbackVoid, + ): QB; + ( + col: StrictColumns, C>, + expr: readonly Expression[] | Raw | AnyQueryBuilder | CallbackVoid, + ): QB; +} - interface JsonRemove { - // These must come first so that we get autocomplete. - (column: ModelProps>, path: string, alias?: string): QB; +export interface WhereBetweenMethod { + (column: RefArg, range: [Expression, Expression]): QB; + ( + column: StrictColumn, C>, + range: [ValueArg, C>, ValueArg, C>], + ): QB; +} - (column: ColumnRef, path: string, alias?: string): QB; - } +export interface WhereNullMethod { + (column: RefArg): QB; + (column: StrictColumn, C>): QB; +} - interface WhereMethod { - // These must come first so that we get autocomplete. - ( - col: ModelProps>, - op: Operator, - expr: Expression, - ): QB; - - (col: ModelProps>, expr: Expression): QB; - - (col: ColumnRef, op: Operator, expr: Expression): QB; - (col: ColumnRef, expr: Expression): QB; - - (condition: boolean): QB; - (cb: CallbackVoid): QB; - (raw: Raw): QB; - (qb: QBA): QB; - - (obj: PartialModelProps>): QB; - // We must allow any keys in the object. The previous type - // is kind of useless, but maybe one day vscode and other - // tools can autocomplete using it. - (obj: object): QB; - } +export interface WhereColumnMethod { + // These must come first so that we get autocomplete. + ( + col1: ColumnArg, C>, + op: Operator, + col2: RefArg | StrictColumn, D>, + ): QB; + ( + col1: ColumnArg, C>, + col2: RefArg | StrictColumn, D>, + ): QB; + + ( + col1: RefArg, + op: Operator, + col2: RefArg | StrictColumn, D>, + ): QB; + ( + col1: RefArg, + col2: RefArg | StrictColumn, D>, + ): QB; +} - interface WhereRawMethod extends RawInterface {} +export interface WhereJsonObject { + // These must come first so that we get autocomplete. + ( + col: ColumnArg, C>, + value: any, + ): QB; - interface WhereWrappedMethod { - (cb: CallbackVoid): QB; - } + (col: RefArg, value: any): QB; +} - interface WhereExistsMethod { - (cb: CallbackVoid): QB; - (raw: Raw): QB; - (qb: QBA): QB; - } +export interface WhereJsonPath { + // These must come first so that we get autocomplete. + ( + col: ColumnArg, C>, + jsonPath: string, + operator: string, + value: any, + ): QB; - interface WhereInMethod { - // These must come first so that we get autocomplete. - (col: ModelProps>, expr: Expression): QB; - (col: ModelProps>, cb: CallbackVoid): QB; - (col: ModelProps>, qb: AnyQueryBuilder): QB; + (col: RefArg, jsonPath: string, operator: string, value: any): QB; +} - (col: ColumnRef | ColumnRef[], expr: readonly Expression[]): QB; - (col: ColumnRef | ColumnRef[], cb: CallbackVoid): QB; - (col: ColumnRef | ColumnRef[], qb: AnyQueryBuilder): QB; - } +/** + * Field expressions, checked in strict mode. + */ +type FieldExpressionArg = IfStrict< + FieldExpression, + StrictFieldExpression +>; + +export interface WhereJsonMethod { + ( + fieldExpression: FieldExpressionArg, C>, + jsonObjectOrFieldExpression: JsonObjectOrFieldExpression, + ): QB; +} - interface WhereBetweenMethod { - (column: ColumnRef, range: [Expression, Expression]): QB; - } +export interface WhereFieldExpressionMethod { + ( + fieldExpression: FieldExpressionArg, C>, + ): QB; +} - interface WhereNullMethod { - (column: ColumnRef): QB; - } +export interface WhereJsonExpressionMethod { + ( + fieldExpression: FieldExpressionArg, C>, + keys: string | string[], + ): QB; +} - interface WhereColumnMethod { - // These must come first so that we get autocomplete. - (col1: ModelProps>, op: Operator, col2: ColumnRef): QB; - (col1: ModelProps>, col2: ColumnRef): QB; +export interface WhereJsonField { + ( + fieldExpression: FieldExpressionArg, C>, + operator: string, + value: boolean | number | string | null, + ): QB; +} - (col1: ColumnRef, op: Operator, col2: ColumnRef): QB; - (col1: ColumnRef, col2: ColumnRef): QB; - } +export interface WhereCompositeMethod { + (column: RefArg[], op: Operator, expr: readonly Expression[]): QB; + (column: RefArg, expr: Expression): QB; + (column: RefArg, op: Operator, expr: Expression): QB; + (column: RefArg[], expr: readonly Expression[]): QB; + (column: RefArg[], qb: AnyQueryBuilder): QB; + + ( + column: StrictColumns, C>, + op: Operator, + expr: readonly Expression[], + ): QB; + ( + column: StrictColumn, C>, + expr: ValueArg, C>, + ): QB; + ( + column: StrictColumn, C>, + op: O, + expr: OperatorValueArg, C, O>, + ): QB; + ( + column: StrictColumns, C>, + expr: readonly Expression[] | AnyQueryBuilder, + ): QB; +} - interface WhereJsonObject { - // These must come first so that we get autocomplete. - (col: ModelProps>, value: any): QB; +export interface WhereInCompositeMethod { + (column: RefArg, expr: readonly Expression[]): QB; + (column: RefArg, qb: AnyQueryBuilder): QB; + (column: RefArg[], expr: readonly Expression[][]): QB; + (column: RefArg[], qb: AnyQueryBuilder): QB; + + ( + column: StrictColumn, C>, + expr: readonly ValueArg, C>[] | AnyQueryBuilder, + ): QB; + ( + column: StrictColumns, C>, + expr: readonly (readonly Expression[])[] | AnyQueryBuilder, + ): QB; +} - (col: ColumnRef, value: any): QB; - } +export type QBOrCallback = AnyQueryBuilder | CallbackVoid; - interface WhereJsonPath { - // These must come first so that we get autocomplete. - ( - col: ModelProps>, - jsonPath: string, - operator: string, - value: any, - ): QB; +export interface BaseSetOperations { + (callbackOrBuilder: QBOrCallback, wrap?: boolean): QB; + (callbacksOrBuilders: QBOrCallback[], wrap?: boolean): QB; +} - (col: ColumnRef, jsonPath: string, operator: string, value: any): QB; - } +export interface SetOperationsMethod extends BaseSetOperations { + (...callbacksOrBuilders: QBOrCallback[]): QB; +} - interface WhereJsonMethod { - ( - fieldExpression: FieldExpression, - jsonObjectOrFieldExpression: JsonObjectOrFieldExpression, - ): QB; - } +export interface UnionMethod extends BaseSetOperations { + (arg1: QBOrCallback, wrap?: boolean): QB; + (arg1: QBOrCallback, arg2: QBOrCallback, wrap?: boolean): QB; + (arg1: QBOrCallback, arg2: QBOrCallback, arg3: QBOrCallback, wrap?: boolean): QB; + ( + arg1: QBOrCallback, + arg2: QBOrCallback, + arg3: QBOrCallback, + arg4: QBOrCallback, + wrap?: boolean, + ): QB; + ( + arg1: QBOrCallback, + arg2: QBOrCallback, + arg3: QBOrCallback, + arg4: QBOrCallback, + arg5: QBOrCallback, + wrap?: boolean, + ): QB; + ( + arg1: QBOrCallback, + arg2: QBOrCallback, + arg3: QBOrCallback, + arg4: QBOrCallback, + arg5: QBOrCallback, + arg6: QBOrCallback, + wrap?: boolean, + ): QB; + ( + arg1: QBOrCallback, + arg2: QBOrCallback, + arg3: QBOrCallback, + arg4: QBOrCallback, + arg5: QBOrCallback, + arg6: QBOrCallback, + arg7: QBOrCallback, + wrap?: boolean, + ): QB; +} - interface WhereFieldExpressionMethod { - (fieldExpression: FieldExpression): QB; - } +export interface WithMethod { + (alias: string, expr: CallbackVoid | AnyQueryBuilder | Raw): QB; + (alias: string, columns: string[], expr: CallbackVoid | AnyQueryBuilder | Raw): QB; +} - interface WhereJsonExpressionMethod { - (fieldExpression: FieldExpression, keys: string | string[]): QB; - } +export interface JoinRelatedOptions { + alias?: string | boolean; + aliases?: Record; +} - interface WhereJsonField { - ( - fieldExpression: FieldExpression, - operator: string, - value: boolean | number | string | null, - ): QB; - } +export interface JoinRelatedMethod { + > = never>( + expr: E & CheckRelationExpression, E>, + opt?: JoinRelatedOptions, + ): QB; +} - interface WhereCompositeMethod { - (column: ColumnRef[], op: Operator, expr: readonly Expression[]): QB; - (column: ColumnRef, expr: Expression): QB; - (column: ColumnRef, op: Operator, expr: Expression): QB; - (column: ColumnRef[], expr: readonly Expression[]): QB; - (column: ColumnRef[], qb: AnyQueryBuilder): QB; - } +export interface JoinMethod { + (table: TableRef, leftCol: ColumnRef, op: Operator, rightCol: ColumnRef): QB; + (table: TableRef, leftCol: ColumnRef, rightCol: ColumnRef): QB; + (table: TableRef, cb: CallbackVoid): QB; + (table: TableRef, columns: { [leftCol: string]: ColumnRef }): QB; + (table: TableRef, raw: Raw): QB; + (raw: Raw): QB; +} - interface WhereInCompositeMethod { - (column: ColumnRef, expr: readonly Expression[]): QB; - (column: ColumnRef, qb: AnyQueryBuilder): QB; - (column: ColumnRef[], expr: readonly Expression[][]): QB; - (column: ColumnRef[], qb: AnyQueryBuilder): QB; - } +export interface JoinRawMethod extends RawInterface {} - type QBOrCallback = AnyQueryBuilder | CallbackVoid; +export interface IncrementDecrementMethod { + (column: IfStrict, amount?: number): QB; + ( + column: StrictColumn, C>, + amount?: number, + ): QB; +} - interface BaseSetOperations { - (callbackOrBuilder: QBOrCallback, wrap?: boolean): QB; - (callbacksOrBuilders: QBOrCallback[], wrap?: boolean): QB; - } +export interface AggregateMethod< + out QB extends AnyQueryBuilder, + V extends AggregateValue = 'column', +> { + (column: RefArg): QB; + (aliasToColumnDict: IfStrict<{ [alias: string]: ColumnRef }, never>): QB; + ( + column: StrictAggregation, C>, + ): AggregateQueryBuilder; + ( + aliasToColumnDict: O & IfStrict, O>>, + ): AggregateQueryBuilder; +} - interface SetOperationsMethod extends BaseSetOperations { - (...callbacksOrBuilders: QBOrCallback[]): QB; - } +export interface CountMethod { + (column?: RefArg, options?: { as: string }): AggregateQueryBuilder; + (aliasToColumnDict: IfStrict<{ [alias: string]: string | string[] }, never>): QB; + (...columns: RefArg[]): AggregateQueryBuilder; + ( + column: StrictAggregation, C>, + options?: { as: A }, + ): AggregateQueryBuilder; + ( + aliasToColumnDict: O & IfStrict, O>>, + ): AggregateQueryBuilder; + ( + ...columns: StrictSelections, C> + ): AggregateQueryBuilder; +} - interface UnionMethod extends BaseSetOperations { - (arg1: QBOrCallback, wrap?: boolean): QB; - (arg1: QBOrCallback, arg2: QBOrCallback, wrap?: boolean): QB; - (arg1: QBOrCallback, arg2: QBOrCallback, arg3: QBOrCallback, wrap?: boolean): QB; - ( - arg1: QBOrCallback, - arg2: QBOrCallback, - arg3: QBOrCallback, - arg4: QBOrCallback, - wrap?: boolean, - ): QB; - ( - arg1: QBOrCallback, - arg2: QBOrCallback, - arg3: QBOrCallback, - arg4: QBOrCallback, - arg5: QBOrCallback, - wrap?: boolean, - ): QB; - ( - arg1: QBOrCallback, - arg2: QBOrCallback, - arg3: QBOrCallback, - arg4: QBOrCallback, - arg5: QBOrCallback, - arg6: QBOrCallback, - wrap?: boolean, - ): QB; - ( - arg1: QBOrCallback, - arg2: QBOrCallback, - arg3: QBOrCallback, - arg4: QBOrCallback, - arg5: QBOrCallback, - arg6: QBOrCallback, - arg7: QBOrCallback, - wrap?: boolean, - ): QB; - } +/** + * Aggregations: '*', columns and 'column as alias', checked in strict mode. + */ +type StrictAggregation = IfStrict>; + +export interface GroupByMethod { + (...columns: RefArg[]): QB; + (columns: RefArg[]): QB; + ( + ...columns: StrictColumns, C> + ): QB; + ( + columns: StrictColumns, C>, + ): QB; +} - interface WithMethod { - (alias: string, expr: CallbackVoid | AnyQueryBuilder | Raw): QB; - (alias: string, columns: string[], expr: CallbackVoid | AnyQueryBuilder | Raw): QB; - } +export interface OrderByDescriptor { + column: ColumnRef; + order?: OrderByDirection; + nulls?: OrderByNulls; +} - interface JoinRelatedOptions { - alias?: string | boolean; - aliases?: Record; +export type ColumnRefOrOrderByDescriptor = ColumnRef | OrderByDescriptor; + +type StrictOrderBy = IfStrict< + never, + { + [I in keyof C]: C[I] extends string + ? StrictOutputColumn + : C[I] extends { column: infer D extends string } + ? Omit & { column: StrictOutputColumn } + : C[I] extends Raw | ReferenceBuilder | OrderByDescriptor + ? C[I] + : never; } +>; + +export interface OrderByMethod { + (column: RefArg, order?: OrderByDirection, nulls?: OrderByNulls): QB; + (columns: IfStrict): QB; + ( + column: StrictOutputColumn, + order?: OrderByDirection, + nulls?: OrderByNulls, + ): QB; + (columns: StrictOrderBy): QB; +} - interface JoinRelatedMethod { - (expr: RelationExpression>, opt?: JoinRelatedOptions): QB; - } +export interface OrderByRawMethod extends RawInterface {} - interface JoinMethod { - (table: TableRef, leftCol: ColumnRef, op: Operator, rightCol: ColumnRef): QB; - (table: TableRef, leftCol: ColumnRef, rightCol: ColumnRef): QB; - (table: TableRef, cb: CallbackVoid): QB; - (table: TableRef, columns: { [leftCol: string]: ColumnRef }): QB; - (table: TableRef, raw: Raw): QB; - (raw: Raw): QB; - } +export interface FirstMethod { + ( + this: QB, + ): [ResultType] extends [any[]] ? MaybeSingleQueryBuilder : QB; +} - interface JoinRawMethod extends RawInterface {} +export type ForIdValue = MaybeCompositeId | AnyQueryBuilder; - interface IncrementDecrementMethod { - (column: string, amount?: number): QB; - } +export interface AllowGraphMethod { + (expr: RelationExpression>): QB; +} - interface AggregateMethod { - (column: ColumnRef): QB; - (aliasToColumnDict: { [alias: string]: ColumnRef }): QB; - } +export interface IdentityMethod { + (): QB; +} - interface CountMethod { - (column?: ColumnRef, options?: { as: string }): QB; - (aliasToColumnDict: { [alias: string]: string | string[] }): QB; - (...columns: ColumnRef[]): QB; - } +export interface OneArgMethod { + (arg: T): QB; +} - interface GroupByMethod { - (...columns: ColumnRef[]): QB; - (columns: ColumnRef[]): QB; - } +export interface StringReturningMethod { + (): string; +} - interface OrderByDescriptor { - column: ColumnRef; - order?: OrderByDirection; - nulls?: OrderByNulls; - } +export interface BooleanReturningMethod { + (): boolean; +} - type ColumnRefOrOrderByDescriptor = ColumnRef | OrderByDescriptor; +export interface HasMethod { + (selector: string | RegExp): boolean; +} - interface OrderByMethod { - (column: ColumnRef, order?: OrderByDirection, nulls?: OrderByNulls): QB; - (columns: ColumnRefOrOrderByDescriptor[]): QB; - } +export interface ClearSelectMethod { + (): ClearSelectQueryBuilder; +} - interface OrderByRawMethod extends RawInterface {} +export interface ClearMethod { + (selector: string | RegExp): QB; +} - interface FirstMethod { - ( - this: QB, - ): [ResultType] extends [any[]] ? MaybeSingleQueryBuilder : QB; - } +export interface ColumnInfoMethod { + (): Promise; +} - type ForIdValue = MaybeCompositeId | AnyQueryBuilder; +export interface TableRefForMethod { + (modelClassOrTableName: string | AnyModelConstructor): string; +} - interface AllowGraphMethod { - (expr: RelationExpression>): QB; - } +export interface AliasForMethod { + (modelClassOrTableName: string | AnyModelConstructor): string | null; + (modelClassOrTableName: string | AnyModelConstructor, alias: string): QB; +} - interface IdentityMethod { - (): QB; - } +export interface ModelClassMethod { + (): ModelClass; +} - interface OneArgMethod { - (arg: T): QB; - } +export interface ReturningOptions { + includeTriggerModifications?: boolean; +} - interface StringReturningMethod { - (): string; - } +export type ReturningColumn = string | Raw | readonly (string | Raw)[]; + +/** + * The arguments of `returning()`: any number of columns or column arrays, + * optionally followed by an options object. + */ +export type ReturningArgs = + ReturningColumn[] | [ReturningColumn, ...ReturningColumn[], ReturningOptions | undefined]; + +/** + * With `returning()`, `relate()` returns the inserted join rows for + * many-to-many relations, the updated related rows for has-many relations + * and the updated owner rows for belongs-to-one relations. MySQL has no + * `returning`: there, many-to-many relations return the join rows built from + * the input, without columns generated by the database, and the other + * relations still return the number of affected rows. The typings can't tell + * these apart, so the rows are typed as plain objects. + */ +export interface RelateReturningMethod { + (...args: ReturningArgs): QueryBuilder; +} - interface BooleanReturningMethod { - (): boolean; - } +/** + * The query builder QB with result R after `patch()`, `update()` or `delete()`. + * With `returning()` it resolves to the written rows in the result kind of QB: + * one row for single and maybe-single query builders, e.g. after `findById()`, + * `first()`, `$query()` or `$relatedQuery()` of a to-one relation, an array + * otherwise. + */ +type WriteQueryBuilder = [R] extends [ + readonly unknown[] | Page | number, +] + ? Rebind + : { returning(...args: ReturningArgs): Rebind> } & Rebind; + +/** + * The writes after `returning()` on single and maybe-single query builders, + * which resolve to one row. + */ +interface ReturningWrites { + update(update: PartialModelRow): Rebind; + update(): Rebind; + patch(update: PartialModelPatch): Rebind; + patch(): Rebind; + del(): Rebind; + delete(): Rebind; +} - interface HasMethod { - (selector: string | RegExp): boolean; - } +type WrittenRows = undefined extends R ? M | undefined : M; - interface ClearMethod { - (selector: string | RegExp): QB; - } +export type RelateQueryBuilder = { + returning: RelateReturningMethod>; +} & NQB; - interface ColumnInfoMethod { - (): Promise; - } +export interface TimeoutOptions { + cancel: boolean; +} - interface TableRefForMethod { - (modelClassOrTableName: string | AnyModelConstructor): string; - } +export interface TimeoutMethod { + (ms: number, options?: TimeoutOptions): QB; +} - interface AliasForMethod { - (modelClassOrTableName: string | AnyModelConstructor): string | null; - (modelClassOrTableName: string | AnyModelConstructor, alias: string): QB; - } +export interface Page { + total: number; + results: M[]; +} - interface ModelClassMethod { - (): ModelClass; - } +export interface RunBeforeCallback { + (this: QB, result: any, query: QB): any; +} - interface ReturningOptions { - includeTriggerModifications?: boolean; - } +export interface RunBeforeMethod { + (cb: RunBeforeCallback): QB; +} - type ReturningColumn = string | Raw | readonly (string | Raw)[]; +export interface RunAfterCallback { + (this: QB, result: ResultType, query: QB): any; +} - /** - * The arguments of `returning()`: any number of columns or column arrays, - * optionally followed by an options object. - */ - type ReturningArgs = - ReturningColumn[] | [ReturningColumn, ...ReturningColumn[], ReturningOptions | undefined]; - - interface ReturningMethod { - ( - this: QB, - ...args: ReturningArgs - ): QB extends NumberQueryBuilder ? ArrayQueryBuilder : QB; - } +export interface RunAfterMethod { + (cb: RunAfterCallback): QB; +} - /** - * With `returning()`, `relate()` returns the inserted join rows for - * many-to-many relations, the updated related rows for has-many relations - * and the updated owner rows for belongs-to-one relations. MySQL has no - * `returning`: there, many-to-many relations return the join rows built from - * the input, without columns generated by the database, and the other - * relations still return the number of affected rows. The typings can't tell - * these apart, so the rows are typed as `any`. - */ - interface RelateReturningMethod { - (...args: ReturningArgs): QueryBuilder; - } +export interface OnBuildMethod { + (cb: CallbackVoid): QB; +} - type RelateQueryBuilder = { - returning: RelateReturningMethod>; - } & NumberQueryBuilder; +export interface OnBuildKnexCallback { + (this: QB, knexQuery: Knex.QueryBuilder, query: QB): void; +} - interface TimeoutOptions { - cancel: boolean; - } +export interface OnBuildKnexMethod { + (cb: OnBuildKnexCallback): QB; +} - interface TimeoutMethod { - (ms: number, options?: TimeoutOptions): QB; - } +export interface OnErrorCallback { + (this: QB, error: Error, query: QB): any; +} - export interface Page { - total: number; - results: M[]; - } +export interface OnErrorMethod { + (cb: OnErrorCallback): QB; +} - interface RunBeforeCallback { - (this: QB, result: any, query: QB): any; - } +export interface InsertGraphOptions { + relate?: boolean | string | string[]; + noRelate?: boolean | string | string[]; + noInsert?: boolean | string | string[]; + allowRefs?: boolean; +} - interface RunBeforeMethod { - (cb: RunBeforeCallback): QB; - } +export interface InsertGraphMethod { + ( + this: QB, + graph: PartialModelGraph, + options?: InsertGraphOptions, + ): SingleQueryBuilder; + + ( + this: QB, + graph: PartialModelGraph[], + options?: InsertGraphOptions, + ): ArrayQueryBuilder; +} - interface RunAfterCallback { - (this: QB, result: ResultType, query: QB): any; - } +export interface UpsertGraphOptions { + relate?: boolean | string | string[]; + unrelate?: boolean | string | string[]; + insertMissing?: boolean | string | string[]; + update?: boolean | string | string[]; + noInsert?: boolean | string | string[]; + noUpdate?: boolean | string | string[]; + noDelete?: boolean | string | string[]; + noRelate?: boolean | string | string[]; + noUnrelate?: boolean | string | string[]; + allowRefs?: boolean; + allowMarkers?: boolean; +} - interface RunAfterMethod { - (cb: RunAfterCallback): QB; - } +export interface UpsertGraphMethod { + ( + this: QB, + graph: PartialModelGraph[], + options?: UpsertGraphOptions, + ): ArrayQueryBuilder; + + ( + this: QB, + graph: PartialModelGraph, + options?: UpsertGraphOptions, + ): SingleQueryBuilder; +} - interface OnBuildMethod { - (cb: CallbackVoid): QB; - } +export interface GraphExpressionObjectMethod { + (): any; +} - interface OnBuildKnexCallback { - (this: QB, knexQuery: Knex.QueryBuilder, query: QB): void; - } +export interface GraphOptions { + minimize?: boolean; + separator?: string; + aliases?: { [key: string]: string }; + joinOperation?: string; + maxBatchSize?: number; + /** + * Apply `limit()`, `offset()` and `first()` in graph modifiers to the related + * items of each parent. Defaults to `true`. Only applies to `withGraphFetched()`. + */ + limitPerParent?: boolean; +} - interface OnBuildKnexMethod { - (cb: OnBuildKnexCallback): QB; - } +export interface WithGraphOptions extends GraphOptions { + algorithm?: 'fetch' | 'join'; +} - interface OnErrorCallback { - (this: QB, error: Error, query: QB): any; - } +export interface ModifyGraphMethod { + ( + expr: RelationExpression>, + modifier: Modifier>, + ): QB; +} - interface OnErrorMethod { - (cb: OnErrorCallback): QB; - } +export interface JoinAliases { + owner: string; + related: string; + /** The alias of the join table of a `ManyToManyRelation` or `HasOneThroughRelation`. */ + joinTable?: string; +} - export interface InsertGraphOptions { - relate?: boolean | string | string[]; - allowRefs?: boolean; - /** - * @deprecated Has no effect, since `insertGraph()` rejects `#unrelate` and - * `#delete` markers. Removed in objection 4.0. - */ - allowMarkers?: boolean; - } +export interface ModifyJoinMethod { + ( + expr: RelationExpression>, + modifier: (join: Knex.JoinClause, aliases: JoinAliases) => void, + ): QB; +} - interface InsertGraphMethod { - ( - this: QB, - graph: PartialModelGraph, - options?: InsertGraphOptions, - ): SingleQueryBuilder; - - ( - this: QB, - graph: PartialModelGraph[], - options?: InsertGraphOptions, - ): ArrayQueryBuilder; - } +export interface ContextMethod { + (context: object): QB; + (): QueryContext; +} - export interface UpsertGraphOptions { - relate?: boolean | string | string[]; - unrelate?: boolean | string | string[]; - insertMissing?: boolean | string | string[]; - update?: boolean | string | string[]; - noInsert?: boolean | string | string[]; - noUpdate?: boolean | string | string[]; - noDelete?: boolean | string | string[]; - noRelate?: boolean | string | string[]; - noUnrelate?: boolean | string | string[]; - allowRefs?: boolean; - allowMarkers?: boolean; - } +export interface ClearContextMethod { + (): QB; +} - interface UpsertGraphMethod { - ( - this: QB, - graph: PartialModelGraph[], - options?: UpsertGraphOptions, - ): ArrayQueryBuilder; - - ( - this: QB, - graph: PartialModelGraph, - options?: UpsertGraphOptions, - ): SingleQueryBuilder; - } +export interface ModifyMethod { + (modifier: Modifier | Modifier[], ...args: any[]): QB; +} - interface GraphExpressionObjectMethod { - (): any; - } +export interface ModifiersMethod { + (modifiers: Modifiers): QB; + (): QB; +} - export interface GraphOptions { - minimize?: boolean; - separator?: string; - aliases?: { [key: string]: string }; - joinOperation?: string; - maxBatchSize?: number; - limitPerParent?: boolean; - } +export interface Pojo { + [key: string]: any; +} - export interface WithGraphOptions extends GraphOptions { - algorithm?: 'fetch' | 'join'; - } +export interface CatchablePromiseLike extends PromiseLike { + catch( + onrejected?: ((reason: any) => FR | PromiseLike) | undefined | null, + ): Promise; +} - interface ModifyGraphMethod { - ( - expr: RelationExpression>, - modifier: Modifier>, - ): QB; - } +export class QueryBuilder implements CatchablePromiseLike { + static forClass: ForClassMethod; + + constructor(modelClass: ModelConstructor); + + select: SelectMethod; + columns: SelectMethod; + column: SelectMethod; + distinct: SelectMethod; + // distinctOn() takes columns like groupBy(), and doesn't select them. + distinctOn: GroupByMethod; + as: AsMethod; + + from: FromMethod; + table: FromMethod; + into: FromMethod; + fromRaw: FromRawMethod; + + jsonExtract: JsonExtract; + jsonSet: JsonSet; + jsonInsert: JsonInsert; + jsonRemove: JsonRemove; + + where: WhereMethod; + andWhere: WhereMethod; + orWhere: WhereMethod; + whereNot: WhereMethod; + andWhereNot: WhereMethod; + orWhereNot: WhereMethod; + whereLike: IfStrict, WhereLikeMethod>; + andWhereLike: IfStrict, WhereLikeMethod>; + orWhereLike: IfStrict, WhereLikeMethod>; + whereILike: IfStrict, WhereLikeMethod>; + andWhereILike: IfStrict, WhereLikeMethod>; + orWhereILike: IfStrict, WhereLikeMethod>; + + whereRaw: WhereRawMethod; + orWhereRaw: WhereRawMethod; + andWhereRaw: WhereRawMethod; + + whereWrapped: WhereWrappedMethod; + havingWrapped: WhereWrappedMethod; + + whereExists: WhereExistsMethod; + orWhereExists: WhereExistsMethod; + whereNotExists: WhereExistsMethod; + orWhereNotExists: WhereExistsMethod; + + whereIn: WhereInMethod; + orWhereIn: WhereInMethod; + whereNotIn: WhereInMethod; + orWhereNotIn: WhereInMethod; + + whereBetween: WhereBetweenMethod; + orWhereBetween: WhereBetweenMethod; + andWhereBetween: WhereBetweenMethod; + whereNotBetween: WhereBetweenMethod; + orWhereNotBetween: WhereBetweenMethod; + andWhereNotBetween: WhereBetweenMethod; + + whereNull: WhereNullMethod; + orWhereNull: WhereNullMethod; + whereNotNull: WhereNullMethod; + orWhereNotNull: WhereNullMethod; + + whereColumn: WhereColumnMethod; + orWhereColumn: WhereColumnMethod; + andWhereColumn: WhereColumnMethod; + whereNotColumn: WhereColumnMethod; + orWhereNotColumn: WhereColumnMethod; + andWhereNotColumn: WhereColumnMethod; + + whereJsonObject: WhereJsonObject; + orWhereJsonObject: WhereJsonObject; + andWhereJsonObject: WhereJsonObject; + whereNotJsonObject: WhereJsonObject; + orWhereNotJsonObject: WhereJsonObject; + andWhereNotJsonObject: WhereJsonObject; + + whereJsonPath: WhereJsonPath; + orWhereJsonPath: WhereJsonPath; + andWhereJsonPath: WhereJsonPath; + + whereJsonSupersetOf: WhereJsonMethod; + andWhereJsonSupersetOf: WhereJsonMethod; + orWhereJsonSupersetOf: WhereJsonMethod; + whereJsonNotSupersetOf: WhereJsonMethod; + andWhereJsonNotSupersetOf: WhereJsonMethod; + orWhereJsonNotSupersetOf: WhereJsonMethod; + whereJsonSubsetOf: WhereJsonMethod; + andWhereJsonSubsetOf: WhereJsonMethod; + orWhereJsonSubsetOf: WhereJsonMethod; + whereJsonNotSubsetOf: WhereJsonMethod; + andWhereJsonNotSubsetOf: WhereJsonMethod; + orWhereJsonNotSubsetOf: WhereJsonMethod; + whereJsonIsArray: WhereFieldExpressionMethod; + orWhereJsonIsArray: WhereFieldExpressionMethod; + whereJsonNotArray: WhereFieldExpressionMethod; + orWhereJsonNotArray: WhereFieldExpressionMethod; + whereJsonIsObject: WhereFieldExpressionMethod; + orWhereJsonIsObject: WhereFieldExpressionMethod; + whereJsonNotObject: WhereFieldExpressionMethod; + orWhereJsonNotObject: WhereFieldExpressionMethod; + whereJsonHasAny: WhereJsonExpressionMethod; + orWhereJsonHasAny: WhereJsonExpressionMethod; + whereJsonHasAll: WhereJsonExpressionMethod; + orWhereJsonHasAll: WhereJsonExpressionMethod; + + having: WhereMethod; + andHaving: WhereMethod; + orHaving: WhereMethod; + + havingRaw: WhereRawMethod; + orHavingRaw: WhereRawMethod; + + havingIn: WhereInMethod; + orHavingIn: WhereInMethod; + havingNotIn: WhereInMethod; + orHavingNotIn: WhereInMethod; + + havingNull: WhereNullMethod; + orHavingNull: WhereNullMethod; + havingNotNull: WhereNullMethod; + orHavingNotNull: WhereNullMethod; + + havingExists: WhereExistsMethod; + orHavingExists: WhereExistsMethod; + havingNotExists: WhereExistsMethod; + orHavingNotExists: WhereExistsMethod; + + havingBetween: WhereBetweenMethod; + orHavingBetween: WhereBetweenMethod; + havingNotBetween: WhereBetweenMethod; + orHavingNotBetween: WhereBetweenMethod; + + whereComposite: WhereCompositeMethod; + whereInComposite: WhereInCompositeMethod; + whereNotInComposite: WhereInCompositeMethod; + + union: UnionMethod; + unionAll: UnionMethod; + intersect: SetOperationsMethod; + except: SetOperationsMethod; + + with: WithMethod; + withRecursive: WithMethod; + withWrapped: WithMethod; + withMaterialized: WithMethod; + withNotMaterialized: WithMethod; + + joinRelated: JoinRelatedMethod; + innerJoinRelated: JoinRelatedMethod; + outerJoinRelated: JoinRelatedMethod; + leftJoinRelated: JoinRelatedMethod; + leftOuterJoinRelated: JoinRelatedMethod; + rightJoinRelated: JoinRelatedMethod; + rightOuterJoinRelated: JoinRelatedMethod; + fullOuterJoinRelated: JoinRelatedMethod; + + join: JoinMethod; + joinRaw: JoinRawMethod; + innerJoin: JoinMethod; + leftJoin: JoinMethod; + leftOuterJoin: JoinMethod; + rightJoin: JoinMethod; + rightOuterJoin: JoinMethod; + outerJoin: JoinMethod; + fullOuterJoin: JoinMethod; + crossJoin: JoinMethod; + + count: CountMethod; + countDistinct: CountMethod; + min: AggregateMethod; + max: AggregateMethod; + sum: AggregateMethod; + sumDistinct: AggregateMethod; + avg: AggregateMethod; + avgDistinct: AggregateMethod; + increment: IncrementDecrementMethod; + decrement: IncrementDecrementMethod; + first: FirstMethod; + none: IdentityMethod; + + orderBy: OrderByMethod; + orderByRaw: OrderByRawMethod; + + groupBy: GroupByMethod; + groupByRaw: RawInterface; + + findById(id: MaybeCompositeId): MaybeSingleQueryBuilder; + findByIds(ids: MaybeCompositeId[]): this; + findOne: WhereMethod>; + + execute(): Promise; + castTo(modelClass: ModelConstructor): QueryBuilderType; + castTo(): QueryBuilder; + + update(update: PartialModelRow): NumberQueryBuilder; + update(): NumberQueryBuilder; + updateById( + id: MaybeCompositeId, + update: PartialModelRow, + ): NumberQueryBuilder>; + updateAndFetch(update: PartialModelRow): SingleQueryBuilder; + updateAndFetchById(id: MaybeCompositeId, update: PartialModelRow): SingleQueryBuilder; + + patch(update: PartialModelPatch): NumberQueryBuilder; + patch(): NumberQueryBuilder; + patchById( + id: MaybeCompositeId, + update: PartialModelPatch, + ): NumberQueryBuilder>; + patchAndFetch(update: PartialModelPatch): SingleQueryBuilder; + patchAndFetchById(id: MaybeCompositeId, update: PartialModelPatch): SingleQueryBuilder; + + del(): NumberQueryBuilder; + delete(): NumberQueryBuilder; + deleteById(id: MaybeCompositeId): NumberQueryBuilder>; + + insert(insert: PartialModelRow): SingleQueryBuilder; + insert(insert: PartialModelRow[]): ArrayQueryBuilder; + insert(): SingleQueryBuilder; + + onConflict(column?: ColumnRef | ColumnRef[] | true): this; + ignore(): this; + merge(merge?: PartialModelPatch | string[]): this; + + insertAndFetch(insert: PartialModelRow): SingleQueryBuilder; + insertAndFetch(insert: PartialModelRow[]): ArrayQueryBuilder; + insertAndFetch(): SingleQueryBuilder; + + relate( + ids: MaybeCompositeId | MaybeCompositeId[] | PartialModelRow | PartialModelRow[], + ): RelateQueryBuilder>; + + unrelate(): NumberQueryBuilder; + for(ids: ForIdValue | ForIdValue[]): this; + + // With literal relation expressions, the fetched relations become required + // on the result type, see WithGraphQueryBuilder. + withGraphFetched>( + expr: E & CheckRelationExpression, + options?: GraphOptions, + ): WithGraphQueryBuilder; + withGraphJoined>( + expr: E & CheckRelationExpression, + options?: GraphOptions, + ): WithGraphQueryBuilder; + withGraph>( + expr: E & CheckRelationExpression, + options?: WithGraphOptions, + ): WithGraphQueryBuilder; + + truncate(): Promise; + allowGraph: AllowGraphMethod; + + throwIfNotFound: (arg?: any) => R extends Model | undefined ? SingleQueryBuilder : this; + + // With the class's own R, the result kind also resolves where `this` is + // generic, e.g. in the methods of custom query builders. For `any` results + // it's `any`, so that query builders with other results stay assignable to + // AnyQueryBuilder and back. + returning( + ...args: ReturningArgs + ): 0 extends 1 & R + ? any + : [R] extends [number] + ? ArrayQueryBuilder + : [R] extends [readonly unknown[] | Page] + ? this + : ReturningWrites> & this; + forUpdate: IdentityMethod; + forShare: IdentityMethod; + forNoKeyUpdate: IdentityMethod; + forKeyShare: IdentityMethod; + skipLocked: IdentityMethod; + noWait: IdentityMethod; + debug: IdentityMethod; + alias: OneArgMethod; + aliasFor: AliasForMethod; + withSchema: OneArgMethod; + modelClass: ModelClassMethod; + tableNameFor: TableRefForMethod; + tableRefFor: TableRefForMethod; + tableName(): string; + tableRef(): string; + reject: OneArgMethod; + resolve: OneArgMethod; + transacting: OneArgMethod; + connection: OneArgMethod; + timeout: TimeoutMethod; + columnInfo: ColumnInfoMethod; + + toKnexQuery>(): Knex.QueryBuilder; + knex(knex?: Knex): Knex; + clone(): this; + emptyInstance(): this; + + page(page: number, pageSize: number): PageQueryBuilder; + range(): PageQueryBuilder; + range(start: number, end: number): PageQueryBuilder; + offset(offset: number, options?: boolean | { skipBinding?: boolean }): this; + limit(limit: number, options?: boolean | { skipBinding?: boolean }): this; + resultSize(): Promise; + + runBefore: RunBeforeMethod; + runAfter: RunAfterMethod; + + onBuild: OnBuildMethod; + onBuildKnex: OnBuildKnexMethod; + onError: OnErrorMethod; + + insertGraph: InsertGraphMethod; + insertGraphAndFetch: InsertGraphMethod; + + upsertGraph: UpsertGraphMethod; + upsertGraphAndFetch: UpsertGraphMethod; + + graphExpressionObject: GraphExpressionObjectMethod; + + modifyGraph: ModifyGraphMethod; + modifyJoin: ModifyJoinMethod; + + context: ContextMethod; + clearContext: ClearContextMethod; + + modify: ModifyMethod; + modifiers: ModifiersMethod; + + isFind: BooleanReturningMethod; + isExecutable: BooleanReturningMethod; + isInsert: BooleanReturningMethod; + isUpdate: BooleanReturningMethod; + isDelete: BooleanReturningMethod; + isRelate: BooleanReturningMethod; + isUnrelate: BooleanReturningMethod; + isInternal: BooleanReturningMethod; + isJoinChildQuery: BooleanReturningMethod; + hasWheres: BooleanReturningMethod; + hasSelects: BooleanReturningMethod; + hasWithGraph: BooleanReturningMethod; + + has: HasMethod; + clear: ClearMethod; + + clearSelect: ClearSelectMethod; + clearOrder: IdentityMethod; + clearWhere: IdentityMethod; + clearWithGraph: IdentityMethod; + clearAllowGraph: IdentityMethod; + + ModelType: M; + ResultType: R; + + // The model and result types that `RebindType` is instantiated with. They + // are `any` where `this` isn't rebound, e.g. when TypeScript checks the + // members of a custom query builder against this class. + '~rebind': unknown; + '~M': this['~rebind'] extends [any, any] ? this['~rebind'][0] : any; + '~R': this['~rebind'] extends [any, any] ? this['~rebind'][1] : any; - interface ContextMethod { - (context: object): QB; - (): QueryContext; - } + /** + * This query builder, re-instantiated with other model and result types, + * e.g. by `first()` or `page()`. Custom query builders redeclare it to keep + * their own type: + * + * declare RebindType: MyQueryBuilder; + */ + RebindType: QueryBuilder; + + // The query builder rebound to the result kinds of the helper types, e.g. + // `SingleQueryBuilder`. As members, they also resolve where `this` is + // generic, e.g. in the methods of custom query builders. Of the members + // starting with `~`, only `~M` and `~R` are API, for `RebindType`. + '~single': Rebind>; + '~maybeSingle': Rebind | undefined>; + '~array': Rebind[]>; + '~number': WriteQueryBuilder; + '~page': Rebind>>; + + then( + onfulfilled?: ((value: R) => R1 | PromiseLike) | undefined | null, + onrejected?: ((reason: any) => R2 | PromiseLike) | undefined | null, + ): Promise; + + catch( + onrejected?: ((reason: any) => FR | PromiseLike) | undefined | null, + ): Promise; +} - interface ClearContextMethod { - (): QB; - } +export type X = Promise; - interface ModifyMethod { - (modifier: Modifier | Modifier[], ...args: any[]): QB; - } +export interface FetchGraphOptions { + transaction?: TransactionOrKnex; + skipFetched?: boolean; +} - interface ModifiersMethod { - (modifiers: Modifiers): QB; - (): QB; - } +export interface TraverserFunction { + (model: Model, parentModel: Model, relationName: string): void; +} - export interface Pojo { - [key: string]: any; - } +export type ArrayQueryBuilderThunk = () => ArrayQueryBuilder>; +export type CancelQueryThunk = (result: any) => void; + +export interface StaticHookArguments { + asFindQuery: ArrayQueryBuilderThunk; + cancelQuery: CancelQueryThunk; + context: QueryContext; + transaction: TransactionOrKnex; + relation?: Relation; + modelOptions?: ModelOptions; + items: Model[]; + inputItems: M[]; + result: R; +} - export interface CatchablePromiseLike extends PromiseLike { - catch( - onrejected?: ((reason: any) => FR | PromiseLike) | undefined | null, - ): Promise; - } +export type Transaction = Knex.Transaction; +export type TransactionOrKnex = Transaction | Knex; - export class QueryBuilder implements CatchablePromiseLike { - static forClass: ForClassMethod; - - constructor(modelClass: ModelConstructor); - - select: SelectMethod; - columns: SelectMethod; - column: SelectMethod; - distinct: SelectMethod; - distinctOn: SelectMethod; - as: AsMethod; - - from: FromMethod; - table: FromMethod; - into: FromMethod; - fromRaw: FromRawMethod; - - jsonExtract: JsonExtract; - jsonSet: JsonSet; - jsonInsert: JsonInsert; - jsonRemove: JsonRemove; - - where: WhereMethod; - andWhere: WhereMethod; - orWhere: WhereMethod; - whereNot: WhereMethod; - andWhereNot: WhereMethod; - orWhereNot: WhereMethod; - whereLike: WhereMethod; - andWhereLike: WhereMethod; - orWhereLike: WhereMethod; - whereILike: WhereMethod; - andWhereILike: WhereMethod; - orWhereILike: WhereMethod; - - whereRaw: WhereRawMethod; - orWhereRaw: WhereRawMethod; - andWhereRaw: WhereRawMethod; - - whereWrapped: WhereWrappedMethod; - havingWrapped: WhereWrappedMethod; - - whereExists: WhereExistsMethod; - orWhereExists: WhereExistsMethod; - whereNotExists: WhereExistsMethod; - orWhereNotExists: WhereExistsMethod; - - whereIn: WhereInMethod; - orWhereIn: WhereInMethod; - whereNotIn: WhereInMethod; - orWhereNotIn: WhereInMethod; - - whereBetween: WhereBetweenMethod; - orWhereBetween: WhereBetweenMethod; - andWhereBetween: WhereBetweenMethod; - whereNotBetween: WhereBetweenMethod; - orWhereNotBetween: WhereBetweenMethod; - andWhereNotBetween: WhereBetweenMethod; - - whereNull: WhereNullMethod; - orWhereNull: WhereNullMethod; - whereNotNull: WhereNullMethod; - orWhereNotNull: WhereNullMethod; - - whereColumn: WhereColumnMethod; - orWhereColumn: WhereColumnMethod; - andWhereColumn: WhereColumnMethod; - whereNotColumn: WhereColumnMethod; - orWhereNotColumn: WhereColumnMethod; - andWhereNotColumn: WhereColumnMethod; - - whereJsonObject: WhereJsonObject; - orWhereJsonObject: WhereJsonObject; - andWhereJsonObject: WhereJsonObject; - whereNotJsonObject: WhereJsonObject; - orWhereNotJsonObject: WhereJsonObject; - andWhereNotJsonObject: WhereJsonObject; - - whereJsonPath: WhereJsonPath; - orWhereJsonPath: WhereJsonPath; - andWhereJsonPath: WhereJsonPath; - - whereJsonSupersetOf: WhereJsonMethod; - andWhereJsonSupersetOf: WhereJsonMethod; - orWhereJsonSupersetOf: WhereJsonMethod; - whereJsonNotSupersetOf: WhereJsonMethod; - andWhereJsonNotSupersetOf: WhereJsonMethod; - orWhereJsonNotSupersetOf: WhereJsonMethod; - whereJsonSubsetOf: WhereJsonMethod; - andWhereJsonSubsetOf: WhereJsonMethod; - orWhereJsonSubsetOf: WhereJsonMethod; - whereJsonNotSubsetOf: WhereJsonMethod; - andWhereJsonNotSubsetOf: WhereJsonMethod; - orWhereJsonNotSubsetOf: WhereJsonMethod; - whereJsonIsArray: WhereFieldExpressionMethod; - orWhereJsonIsArray: WhereFieldExpressionMethod; - whereJsonNotArray: WhereFieldExpressionMethod; - orWhereJsonNotArray: WhereFieldExpressionMethod; - whereJsonIsObject: WhereFieldExpressionMethod; - orWhereJsonIsObject: WhereFieldExpressionMethod; - whereJsonNotObject: WhereFieldExpressionMethod; - orWhereJsonNotObject: WhereFieldExpressionMethod; - whereJsonHasAny: WhereJsonExpressionMethod; - orWhereJsonHasAny: WhereJsonExpressionMethod; - whereJsonHasAll: WhereJsonExpressionMethod; - orWhereJsonHasAll: WhereJsonExpressionMethod; - - having: WhereMethod; - andHaving: WhereMethod; - orHaving: WhereMethod; - - havingRaw: WhereRawMethod; - orHavingRaw: WhereRawMethod; - - havingIn: WhereInMethod; - orHavingIn: WhereInMethod; - havingNotIn: WhereInMethod; - orHavingNotIn: WhereInMethod; - - havingNull: WhereNullMethod; - orHavingNull: WhereNullMethod; - havingNotNull: WhereNullMethod; - orHavingNotNull: WhereNullMethod; - - havingExists: WhereExistsMethod; - orHavingExists: WhereExistsMethod; - havingNotExists: WhereExistsMethod; - orHavingNotExists: WhereExistsMethod; - - havingBetween: WhereBetweenMethod; - orHavingBetween: WhereBetweenMethod; - havingNotBetween: WhereBetweenMethod; - orHavingNotBetween: WhereBetweenMethod; - - whereComposite: WhereCompositeMethod; - whereInComposite: WhereInCompositeMethod; - whereNotInComposite: WhereInCompositeMethod; - - union: UnionMethod; - unionAll: UnionMethod; - intersect: SetOperationsMethod; - except: SetOperationsMethod; - - with: WithMethod; - withRecursive: WithMethod; - withWrapped: WithMethod; - withMaterialized: WithMethod; - withNotMaterialized: WithMethod; - - joinRelated: JoinRelatedMethod; - innerJoinRelated: JoinRelatedMethod; - outerJoinRelated: JoinRelatedMethod; - leftJoinRelated: JoinRelatedMethod; - leftOuterJoinRelated: JoinRelatedMethod; - rightJoinRelated: JoinRelatedMethod; - rightOuterJoinRelated: JoinRelatedMethod; - fullOuterJoinRelated: JoinRelatedMethod; - - join: JoinMethod; - joinRaw: JoinRawMethod; - innerJoin: JoinMethod; - leftJoin: JoinMethod; - leftOuterJoin: JoinMethod; - rightJoin: JoinMethod; - rightOuterJoin: JoinMethod; - outerJoin: JoinMethod; - fullOuterJoin: JoinMethod; - crossJoin: JoinMethod; - - count: CountMethod; - countDistinct: CountMethod; - min: AggregateMethod; - max: AggregateMethod; - sum: AggregateMethod; - sumDistinct: AggregateMethod; - avg: AggregateMethod; - avgDistinct: AggregateMethod; - increment: IncrementDecrementMethod; - decrement: IncrementDecrementMethod; - first: FirstMethod; - none: IdentityMethod; - - orderBy: OrderByMethod; - orderByRaw: OrderByRawMethod; - - groupBy: GroupByMethod; - groupByRaw: RawInterface; - - findById(id: MaybeCompositeId): MaybeSingleQueryBuilder; - findByIds(ids: MaybeCompositeId[]): this; - findOne: WhereMethod>; - - execute(): Promise; - castTo(modelClass: ModelConstructor): QueryBuilderType; - castTo(): QueryBuilder; - - update(update: PartialModelObject): NumberQueryBuilder; - update(): NumberQueryBuilder; - updateById(id: MaybeCompositeId, update: PartialModelObject): NumberQueryBuilder; - updateAndFetch(update: PartialModelObject): SingleQueryBuilder; - updateAndFetchById( - id: MaybeCompositeId, - update: PartialModelObject, - ): SingleQueryBuilder; - - patch(update: PartialModelObject): NumberQueryBuilder; - patch(): NumberQueryBuilder; - patchById(id: MaybeCompositeId, update: PartialModelObject): NumberQueryBuilder; - patchAndFetch(update: PartialModelObject): SingleQueryBuilder; - patchAndFetchById( - id: MaybeCompositeId, - update: PartialModelObject, - ): SingleQueryBuilder; - - del(): NumberQueryBuilder; - delete(): NumberQueryBuilder; - deleteById(id: MaybeCompositeId): NumberQueryBuilder; - - insert(insert: PartialModelObject): SingleQueryBuilder; - insert(insert: PartialModelObject[]): ArrayQueryBuilder; - insert(): SingleQueryBuilder; - - onConflict(column?: ColumnRef | ColumnRef[] | true): this; - ignore(): this; - merge(merge?: PartialModelObject | string[]): this; - - insertAndFetch(insert: PartialModelObject): SingleQueryBuilder; - insertAndFetch(insert: PartialModelObject[]): ArrayQueryBuilder; - insertAndFetch(): SingleQueryBuilder; - - relate( - ids: MaybeCompositeId | MaybeCompositeId[] | PartialModelObject | PartialModelObject[], - ): RelateQueryBuilder; - - unrelate(): NumberQueryBuilder; - for(ids: ForIdValue | ForIdValue[]): this; - - // With literal relation expressions, the fetched relations become required - // on the result type, see WithGraphQueryBuilder. - withGraphFetched>( - expr: E, - options?: GraphOptions, - ): WithGraphQueryBuilder; - withGraphJoined>( - expr: E, - options?: GraphOptions, - ): WithGraphQueryBuilder; - withGraph>( - expr: E, - options?: WithGraphOptions, - ): WithGraphQueryBuilder; - - truncate(): Promise; - allowGraph: AllowGraphMethod; - - throwIfNotFound: (arg?: any) => R extends Model | undefined ? SingleQueryBuilder : this; - - returning: ReturningMethod; - forUpdate: IdentityMethod; - forShare: IdentityMethod; - forNoKeyUpdate: IdentityMethod; - forKeyShare: IdentityMethod; - skipLocked: IdentityMethod; - noWait: IdentityMethod; - skipUndefined: IdentityMethod; - debug: IdentityMethod; - alias: OneArgMethod; - aliasFor: AliasForMethod; - withSchema: OneArgMethod; - modelClass: ModelClassMethod; - tableNameFor: TableRefForMethod; - tableRefFor: TableRefForMethod; - tableName(): string; - tableRef(): string; - reject: OneArgMethod; - resolve: OneArgMethod; - transacting: OneArgMethod; - connection: OneArgMethod; - timeout: TimeoutMethod; - columnInfo: ColumnInfoMethod; - - toKnexQuery>(): Knex.QueryBuilder; - knex(knex?: Knex): Knex; - clone(): this; - emptyInstance(): this; - - page(page: number, pageSize: number): PageQueryBuilder; - range(): PageQueryBuilder; - range(start: number, end: number): PageQueryBuilder; - offset(offset: number, options?: boolean | { skipBinding?: boolean }): this; - limit(limit: number, options?: boolean | { skipBinding?: boolean }): this; - resultSize(): Promise; - - runBefore: RunBeforeMethod; - runAfter: RunAfterMethod; - - onBuild: OnBuildMethod; - onBuildKnex: OnBuildKnexMethod; - onError: OnErrorMethod; - - insertGraph: InsertGraphMethod; - insertGraphAndFetch: InsertGraphMethod; - - upsertGraph: UpsertGraphMethod; - upsertGraphAndFetch: UpsertGraphMethod; - - graphExpressionObject: GraphExpressionObjectMethod; - - modifyGraph: ModifyGraphMethod; - - context: ContextMethod; - clearContext: ClearContextMethod; - - modify: ModifyMethod; - modifiers: ModifiersMethod; - - isFind: BooleanReturningMethod; - isExecutable: BooleanReturningMethod; - isInsert: BooleanReturningMethod; - isUpdate: BooleanReturningMethod; - isDelete: BooleanReturningMethod; - isRelate: BooleanReturningMethod; - isUnrelate: BooleanReturningMethod; - isInternal: BooleanReturningMethod; - isJoinChildQuery: BooleanReturningMethod; - hasWheres: BooleanReturningMethod; - hasSelects: BooleanReturningMethod; - hasWithGraph: BooleanReturningMethod; - - has: HasMethod; - clear: ClearMethod; - - clearSelect: IdentityMethod; - clearOrder: IdentityMethod; - clearWhere: IdentityMethod; - clearWithGraph: IdentityMethod; - clearAllowGraph: IdentityMethod; - - ModelType: M; - ResultType: R; - - ArrayQueryBuilderType: QueryBuilder; - SingleQueryBuilderType: QueryBuilder; - MaybeSingleQueryBuilderType: QueryBuilder; - NumberQueryBuilderType: QueryBuilder; - PageQueryBuilderType: QueryBuilder>; - - then( - onfulfilled?: ((value: R) => R1 | PromiseLike) | undefined | null, - onrejected?: ((reason: any) => R2 | PromiseLike) | undefined | null, - ): Promise; - - catch( - onrejected?: ((reason: any) => FR | PromiseLike) | undefined | null, - ): Promise; - } +export interface RelationMappings { + [relationName: string]: RelationMapping; +} - type X = Promise; +export type RelationMappingsThunk = () => RelationMappings; + +export type ModelClassFactory = () => AnyModelConstructor; +export type ModelClassSpecifier = ModelClassFactory | AnyModelConstructor | string; +export type RelationMappingHook = ( + model: M, + context: QueryContext, +) => Promise | void; +export type RelationMappingBeforeInsertHook = ( + model: M, + context: QueryContext, + owner?: Model, +) => Promise | void; +export type StringOrReferenceBuilder = string | ReferenceBuilder; +export type RelationMappingColumnRef = StringOrReferenceBuilder | StringOrReferenceBuilder[]; + +export interface RelationMapping { + relation: RelationType; + modelClass: ModelClassSpecifier; + join: RelationJoin; + modify?: Modifier>; + filter?: Modifier>; + beforeInsert?: RelationMappingBeforeInsertHook; +} - interface FetchGraphOptions { - transaction?: TransactionOrKnex; - skipFetched?: boolean; - } +export interface RelationJoin { + from: RelationMappingColumnRef; + to: RelationMappingColumnRef; + through?: RelationThrough; +} - interface TraverserFunction { - (model: Model, parentModel: Model, relationName: string): void; - } +export interface RelationThrough { + from: RelationMappingColumnRef; + to: RelationMappingColumnRef; + extra?: string | string[] | Record; + modelClass?: ModelClassSpecifier; + modify?: Modifier>; + filter?: Modifier>; + beforeInsert?: RelationMappingHook; +} - type ArrayQueryBuilderThunk = () => ArrayQueryBuilder>; - type CancelQueryThunk = (result: any) => void; - - export interface StaticHookArguments { - asFindQuery: ArrayQueryBuilderThunk; - cancelQuery: CancelQueryThunk; - context: QueryContext; - transaction: TransactionOrKnex; - relation?: Relation; - modelOptions?: ModelOptions; - items: Model[]; - inputItems: M[]; - result?: R; - } +export interface RelationType extends Constructor {} - export type Transaction = Knex.Transaction; - export type TransactionOrKnex = Transaction | Knex; +// The relation types are branded so that `TypedRelationMappings` can tell +// them apart. The brand is optional, so any `RelationType` is assignable. +export interface BelongsToOneRelationType extends RelationType { + readonly [relationKind]?: 'BelongsToOne'; +} +export interface HasOneRelationType extends RelationType { + readonly [relationKind]?: 'HasOne'; +} +export interface HasManyRelationType extends RelationType { + readonly [relationKind]?: 'HasMany'; +} +export interface ManyToManyRelationType extends RelationType { + readonly [relationKind]?: 'ManyToMany'; +} +export interface HasOneThroughRelationType extends RelationType { + readonly [relationKind]?: 'HasOneThrough'; +} +export interface BelongsToManyRelationType extends RelationType { + readonly [relationKind]?: 'BelongsToMany'; +} - export interface RelationMappings { - [relationName: string]: RelationMapping; - } +/** + * Names of the properties of M that hold related models. Properties typed + * `any` or `any[]` are columns. + */ +export type RelationPropertyNames = { + [K in keyof M]-?: HoldsModels> extends true ? K : never; +}[keyof M]; + +type HoldsModels

= [P] extends [readonly (infer I)[]] ? IsModelLike : IsModelLike

; + +type IsModelLike

= 0 extends 1 & P ? false : [P] extends [ModelLike] ? true : false; + +// Model instances, recognized by a method, as structural checks against +// `Model` circle back through the query builder types of the related models. +// Spreads of instances and their JSON don't have it. +type ModelLike = { $clone(opt?: CloneOptions): unknown }; + +export type TypedModelClassSpecifier = + string | ModelConstructor | (() => ModelConstructor); + +/** + * A `RelationMapping` for related models of type M with relation type R. + */ +export interface TypedRelationMapping< + M extends Model, + R extends RelationType = RelationType, +> extends RelationMapping { + relation: R; + modelClass: TypedModelClassSpecifier; +} - export type RelationMappingsThunk = () => RelationMappings; - - type ModelClassFactory = () => AnyModelConstructor; - type ModelClassSpecifier = ModelClassFactory | AnyModelConstructor | string; - type RelationMappingHook = ( - model: M, - context: QueryContext, - ) => Promise | void; - type RelationMappingBeforeInsertHook = ( - model: M, - context: QueryContext, - owner?: Model, - ) => Promise | void; - type StringOrReferenceBuilder = string | ReferenceBuilder; - type RelationMappingColumnRef = StringOrReferenceBuilder | StringOrReferenceBuilder[]; - - export interface RelationMapping { - relation: RelationType; - modelClass: ModelClassSpecifier; - join: RelationJoin; - modify?: Modifier>; - filter?: Modifier>; - beforeInsert?: RelationMappingBeforeInsertHook; - } +export type TypedRelationMappingFor = + NonNullable extends (infer I extends Model)[] + ? TypedRelationMapping< + I, + HasManyRelationType | ManyToManyRelationType | BelongsToManyRelationType + > + : NonNullable extends infer I extends Model + ? TypedRelationMapping< + I, + BelongsToOneRelationType | HasOneRelationType | HasOneThroughRelationType + > + : never; - export interface RelationJoin { - from: RelationMappingColumnRef; - to: RelationMappingColumnRef; - through?: RelationThrough; - } +/** + * Relation mappings checked against the relation properties of model M. + * Use it with `satisfies`, so the inferred type of the mappings is kept: + * + * static relationMappings = { + * pets: { ... }, + * } satisfies TypedRelationMappings; + */ +export type TypedRelationMappings = [RelationPropertyNames] extends [never] + ? Record + : { [K in RelationPropertyNames]?: TypedRelationMappingFor }; + +export interface Relation { + name: string; + ownerModelClass: typeof Model; + relatedModelClass: typeof Model; + ownerProp: RelationProperty; + relatedProp: RelationProperty; + joinModelClass: typeof Model; + joinTable: string; + joinTableOwnerProp: RelationProperty; + joinTableRelatedProp: RelationProperty; + isOneToOne(): boolean; + keySide: 'owner' | 'related' | 'join'; +} - export interface RelationThrough { - from: RelationMappingColumnRef; - to: RelationMappingColumnRef; - extra?: string | string[] | Record; - modelClass?: ModelClassSpecifier; - modify?: Modifier>; - filter?: Modifier>; - beforeInsert?: RelationMappingHook; - } +export interface RelationProperty { + size: number; + modelClass: typeof Model; + props: string[]; + cols: string[]; +} - export interface RelationType extends Constructor {} +export interface Relations { + [name: string]: Relation; +} - // The relation types are branded so that `TypedRelationMappings` can tell - // them apart. The brand is optional, so any `RelationType` is assignable. - export interface BelongsToOneRelationType extends RelationType { - readonly [relationKind]?: 'BelongsToOne'; - } - export interface HasOneRelationType extends RelationType { - readonly [relationKind]?: 'HasOne'; - } - export interface HasManyRelationType extends RelationType { - readonly [relationKind]?: 'HasMany'; - } - export interface ManyToManyRelationType extends RelationType { - readonly [relationKind]?: 'ManyToMany'; - } - export interface HasOneThroughRelationType extends RelationType { - readonly [relationKind]?: 'HasOneThrough'; - } +export interface QueryContext { + transaction: Transaction; + [key: string]: any; +} - /** - * Names of the properties of M that hold related models. - */ - type RelationPropertyNames = { - [K in keyof M]-?: NonNullable extends Model | Model[] ? K : never; - }[keyof M]; +export interface ModelOptions { + patch?: boolean; + skipValidation?: boolean; + old?: object; +} - type TypedModelClassSpecifier = - string | ModelConstructor | (() => ModelConstructor); +export interface CloneOptions { + shallow?: boolean; +} - /** - * A `RelationMapping` for related models of type M with relation type R. - */ - export interface TypedRelationMapping< - M extends Model, - R extends RelationType = RelationType, - > extends RelationMapping { - relation: R; - modelClass: TypedModelClassSpecifier; - } +export interface ToJsonOptions extends CloneOptions { + virtuals?: boolean | string[]; + format?: Pojo; +} - type TypedRelationMappingFor = - NonNullable extends (infer I extends Model)[] - ? TypedRelationMapping - : NonNullable extends infer I extends Model - ? TypedRelationMapping< - I, - BelongsToOneRelationType | HasOneRelationType | HasOneThroughRelationType - > - : never; +export interface ValidatorContext { + [key: string]: any; +} - /** - * Relation mappings checked against the relation properties of model M. - * Use it with `satisfies`, so the inferred type of the mappings is kept: - * - * static relationMappings = { - * pets: { ... }, - * } satisfies TypedRelationMappings; - */ - export type TypedRelationMappings = [RelationPropertyNames] extends [never] - ? Record - : { [K in RelationPropertyNames]?: TypedRelationMappingFor }; - - export interface Relation { - name: string; - ownerModelClass: typeof Model; - relatedModelClass: typeof Model; - ownerProp: RelationProperty; - relatedProp: RelationProperty; - joinModelClass: typeof Model; - joinTable: string; - joinTableOwnerProp: RelationProperty; - joinTableRelatedProp: RelationProperty; - isOneToOne(): boolean; - } +export interface ValidatorArgs { + ctx: ValidatorContext; + model: Model; + json: Pojo; + options: ModelOptions; +} - export interface RelationProperty { - size: number; - modelClass: typeof Model; - props: string[]; - cols: string[]; - } +export class Validator { + beforeValidate(args: ValidatorArgs): void; + validate(args: ValidatorArgs): Pojo; + afterValidate(args: ValidatorArgs): void; +} - export interface Relations { - [name: string]: Relation; - } +export interface AjvConfig { + onCreateAjv(ajv: Ajv): void; + options?: AjvOptions; +} - export interface QueryContext { - transaction: Transaction; - [key: string]: any; - } +export class AjvValidator extends Validator { + constructor(config: AjvConfig); +} - export interface ModelOptions { - patch?: boolean; - skipValidation?: boolean; - old?: object; - } +/** + * The options shared by `snakeCaseMappers` and `knexSnakeCaseMappers`. + */ +export interface SnakeCaseOptions { + upperCase?: boolean; + underscoreBeforeDigits?: boolean; + underscoreBetweenUppercaseLetters?: boolean; + noDoubleUnderscores?: boolean; +} - export interface CloneOptions { - shallow?: boolean; - } +export interface SnakeCaseMappersOptions extends SnakeCaseOptions { + /** + * Only convert the column part of field expressions like + * `jsonColumn:someKey` and keep their JSON keys as written. Defaults to + * `true`. Set to `false` to convert the JSON keys too. + */ + preserveJsonKeys?: boolean; +} - export interface ToJsonOptions extends CloneOptions { - virtuals?: boolean | string[]; - format?: Pojo; - } +export interface KnexSnakeCaseMappersOptions extends SnakeCaseOptions {} - export interface ValidatorContext { - [key: string]: any; - } +export type JsonColumnFormat = 'auto' | 'text' | 'parsed'; - export interface ValidatorArgs { - ctx: ValidatorContext; - model: Model; - json: Pojo; - options: ModelOptions; - } +// Passed by queries to `fromDatabaseJson()`, `$setDatabaseJson()` and +// `$parseDatabaseJson()`. Overrides pass them on to `super`. +export interface DatabaseJsonOptions { + knex?: Knex; +} - export class Validator { - beforeValidate(args: ValidatorArgs): void; - validate(args: ValidatorArgs): Pojo; - afterValidate(args: ValidatorArgs): void; - } +export interface ColumnNameMappers { + parse(json: Pojo): Pojo; + format(json: Pojo, isFieldExpression?: (key: string) => boolean): Pojo; +} - export interface AjvConfig { - onCreateAjv(ajv: Ajv): void; - options?: AjvOptions; - } +export interface SnakeCaseMappersFactory { + (options?: SnakeCaseMappersOptions): ColumnNameMappers; +} - export class AjvValidator extends Validator { - constructor(config: AjvConfig); - } +export interface KnexMappers { + wrapIdentifier(identifier: string, origWrap: Identity): string; + postProcessResponse(response: any): any; +} - /** - * The options shared by `snakeCaseMappers` and `knexSnakeCaseMappers`. - */ - export interface SnakeCaseOptions { - upperCase?: boolean; - underscoreBeforeDigits?: boolean; - underscoreBetweenUppercaseLetters?: boolean; - noDoubleUnderscores?: boolean; - } +export interface KnexSnakeCaseMappersFactory { + (options?: KnexSnakeCaseMappersOptions): KnexMappers; +} - export interface SnakeCaseMappersOptions extends SnakeCaseOptions { - /** - * Only convert the column part of field expressions like - * `jsonColumn:someKey` and keep their JSON keys as written. - */ - preserveJsonKeys?: boolean; - } +export interface KnexIdentifierMappingFactory { + (colToProp: Record): KnexMappers; +} - export interface KnexSnakeCaseMappersOptions extends SnakeCaseOptions {} +export type ValidationErrorType = + 'ModelValidation' | 'RelationExpression' | 'UnallowedRelation' | 'InvalidGraph'; - export interface ColumnNameMappers { - parse(json: Pojo): Pojo; - format(json: Pojo): Pojo; - } +export class ValidationError extends Error { + constructor(args: CreateValidationErrorArgs & { modelClass?: ModelClass }); - export interface SnakeCaseMappersFactory { - (options?: SnakeCaseMappersOptions): ColumnNameMappers; - } + statusCode: number; + message: string; + data?: ErrorHash | any; + type: ValidationErrorType | string; + modelClass: ModelClass; +} - export interface KnexMappers { - wrapIdentifier(identifier: string, origWrap: Identity): string; - postProcessResponse(response: any): any; - } +export interface ValidationErrorItem { + message: string; + keyword: string; + params: Pojo; +} - export interface KnexSnakeCaseMappersFactory { - (options?: KnexSnakeCaseMappersOptions): KnexMappers; - } +export interface ErrorHash { + [columnName: string]: ValidationErrorItem[]; +} - export interface KnexIdentifierMappingFactory { - (colToProp: Record): KnexMappers; - } +export interface CreateValidationErrorArgs { + statusCode?: number; + message?: string; + data?: ErrorHash | any; + // This can be any string for custom errors. ValidationErrorType is there + // only to document the default values objection uses internally. + type: ValidationErrorType | string; +} - export type ValidationErrorType = - 'ModelValidation' | 'RelationExpression' | 'UnallowedRelation' | 'InvalidGraph'; +export class NotFoundError extends Error { + constructor(args: CreateNotFoundErrorArgs & { modelClass?: ModelClass }); - export class ValidationError extends Error { - constructor(args: CreateValidationErrorArgs & { modelClass?: ModelClass }); + statusCode: number; + data?: any; + type: 'NotFound'; + modelClass: ModelClass; +} - statusCode: number; - message: string; - data?: ErrorHash | any; - type: ValidationErrorType | string; - modelClass: ModelClass; - } +export interface CreateNotFoundErrorArgs { + statusCode?: number; + message?: string; + data?: any; + [key: string]: any; +} - export interface ValidationErrorItem { - message: string; - keyword: string; - params: Pojo; - } +export interface TableMetadata { + columns: Array; +} - export interface ErrorHash { - [columnName: string]: ValidationErrorItem[]; - } +export interface TableMetadataOptions { + table: string; +} - export interface CreateValidationErrorArgs { - statusCode?: number; - message?: string; - data?: ErrorHash | any; - // This can be any string for custom errors. ValidationErrorType is there - // only to document the default values objection uses internally. - type: ValidationErrorType | string; - } +export interface FetchTableMetadataOptions { + knex?: Knex; + force?: boolean; + table?: string; +} - export class NotFoundError extends Error { - constructor(args: CreateNotFoundErrorArgs & { modelClass?: ModelClass }); +export interface Constructor { + new (): T; +} - statusCode: number; - data?: any; - type: 'NotFound'; - modelClass: ModelClass; - } +export interface PrototypeType extends Function { + prototype: T; +} - export interface CreateNotFoundErrorArgs { - statusCode?: number; - message?: string; - data?: any; - [key: string]: any; - } +export interface ConstructorFunctionType extends PrototypeType { + new (...args: any[]): T; +} - export interface TableMetadata { - columns: Array; - } +// for internal use on generic static this deduction, copied from https://github.com/microsoft/TypeScript/issues/5863#issuecomment-1483978415 +export type ConstructorType = PrototypeType> = ( + ConstructorFunctionType | PrototypeType +) & { + [Key in keyof Static]: Static[Key]; +}; + +export interface ModelConstructor extends Constructor {} + +export interface ModelClass extends ModelConstructor { + QueryBuilder: typeof QueryBuilder; + + tableName: string; + idColumn: null | string | string[] | readonly string[]; + jsonSchema: ReadonlyJSONSchema; + relationMappings: RelationMappings | RelationMappingsThunk; + modelPaths: string[]; + jsonAttributes: string[] | readonly string[]; + virtualAttributes: string[]; + uidProp: string; + uidRefProp: string; + dbRefProp: string; + propRefRegex: RegExp; + graphUnrelateProp: string; + graphDeleteProp: string; + pickJsonSchemaProperties: boolean; + relatedFindQueryMutates: boolean; + relatedInsertQueryMutates: boolean; + useLimitInFirst: boolean; + jsonColumnFormat: JsonColumnFormat; + modifiers: Modifiers; + columnNameMappers: ColumnNameMappers; + + raw: RawFunction; + ref: ReferenceFunction; + fn: FunctionFunction; + + BelongsToOneRelation: BelongsToOneRelationType; + HasOneRelation: HasOneRelationType; + HasManyRelation: HasManyRelationType; + ManyToManyRelation: ManyToManyRelationType; + HasOneThroughRelation: HasOneThroughRelationType; + BelongsToManyRelation: BelongsToManyRelationType; + + defaultGraphOptions?: GraphOptions; + + query(this: Constructor, trxOrKnex?: TransactionOrKnex): QueryBuilderType; + + relatedQuery( + relationName: K, + trxOrKnex?: TransactionOrKnex, + ): ArrayRelatedQueryBuilder; + + relatedQuery( + relationName: string, + trxOrKnex?: TransactionOrKnex, + ): QueryBuilderType; + + fromJson(json: object, opt?: ModelOptions): M; + fromDatabaseJson(json: object, options?: DatabaseJsonOptions): M; + + columnNameToPropertyName(columnName: string): string; + propertyNameToColumnName(propertyName: string): string; + + createValidator(): Validator; + createValidationError(args: CreateValidationErrorArgs): Error; + createNotFoundError(queryContext: QueryContext, args: CreateNotFoundErrorArgs): Error; + + tableMetadata(opt?: TableMetadataOptions): TableMetadata; + fetchTableMetadata(opt?: FetchTableMetadataOptions): Promise; + + knex(knex?: Knex): Knex; + knexQuery(): Knex.QueryBuilder; + startTransaction(knexOrTransaction?: TransactionOrKnex): Promise; + + transaction(callback: (trx: Transaction) => Promise): Promise; + transaction( + trxOrKnex: TransactionOrKnex, + callback: (trx: Transaction) => Promise, + ): Promise; + + bindKnex(trxOrKnex: TransactionOrKnex): this; + bindTransaction(trxOrKnex: TransactionOrKnex): this; + + fetchGraph>( + modelOrObject: PartialModelObject, + expression: E, + options?: FetchGraphOptions, + ): Rebind, WithGraphModel, WithGraphModel>; + + fetchGraph>( + modelOrObject: PartialModelObject[], + expression: E, + options?: FetchGraphOptions, + ): Rebind, WithGraphModel, WithGraphModel[]>; + + getRelations(): Relations; + getRelation(name: string): Relation; + + traverse(models: Model | Model[], traverser: TraverserFunction): void; + traverse( + filterConstructor: ModelConstructor, + models: Model | Model[], + traverser: TraverserFunction, + ): void; + traverseAsync(models: Model | Model[], traverser: TraverserFunction): Promise; + traverseAsync( + filterConstructor: ModelConstructor, + models: Model | Model[], + traverser: TraverserFunction, + ): Promise; + + beforeFind(args: StaticHookArguments): any; + afterFind(args: StaticHookArguments): any; + beforeInsert(args: StaticHookArguments): any; + afterInsert(args: StaticHookArguments): any; + beforeUpdate(args: StaticHookArguments): any; + afterUpdate(args: StaticHookArguments): any; + beforeDelete(args: StaticHookArguments): any; + afterDelete(args: StaticHookArguments): any; +} - export interface TableMetadataOptions { - table: string; - } +export class Model { + static QueryBuilder: typeof QueryBuilder; + + static tableName: string; + static idColumn: null | string | string[] | readonly string[]; + static jsonSchema: ReadonlyJSONSchema; + static relationMappings: RelationMappings | RelationMappingsThunk; + static modelPaths: string[]; + static jsonAttributes: string[] | readonly string[]; + static virtualAttributes: string[]; + static uidProp: string; + static uidRefProp: string; + static dbRefProp: string; + static propRefRegex: RegExp; + static graphUnrelateProp: string; + static graphDeleteProp: string; + static pickJsonSchemaProperties: boolean; + static relatedFindQueryMutates: boolean; + static relatedInsertQueryMutates: boolean; + static useLimitInFirst: boolean; + static jsonColumnFormat: JsonColumnFormat; + static modifiers: Modifiers; + static columnNameMappers: ColumnNameMappers; + + static raw: RawFunction; + static ref: ReferenceFunction; + static fn: FunctionFunction; + + static BelongsToOneRelation: BelongsToOneRelationType; + static HasOneRelation: HasOneRelationType; + static HasManyRelation: HasManyRelationType; + static ManyToManyRelation: ManyToManyRelationType; + static HasOneThroughRelation: HasOneThroughRelationType; + static BelongsToManyRelation: BelongsToManyRelationType; + + static defaultGraphOptions?: GraphOptions; + + static query( + this: ConstructorType, + trxOrKnex?: TransactionOrKnex, + ): QueryBuilderType; + + static relatedQuery( + this: ConstructorType, + relationName: K, + trxOrKnex?: TransactionOrKnex, + ): ArrayRelatedQueryBuilder; + + static relatedQuery( + relationName: string, + trxOrKnex?: TransactionOrKnex, + ): QueryBuilderType; + + static fromJson(this: ConstructorType, json: object, opt?: ModelOptions): M; + static fromDatabaseJson( + this: ConstructorType, + json: object, + options?: DatabaseJsonOptions, + ): M; + + static columnNameToPropertyName(columnName: string): string; + static propertyNameToColumnName(propertyName: string): string; + + static createValidator(): Validator; + static createValidationError(args: CreateValidationErrorArgs): Error; + static createNotFoundError(queryContext: QueryContext, args: CreateNotFoundErrorArgs): Error; + + static tableMetadata(opt?: TableMetadataOptions): TableMetadata; + static fetchTableMetadata(opt?: FetchTableMetadataOptions): Promise; + + static knex(knex?: Knex): Knex; + static knexQuery(): Knex.QueryBuilder; + static startTransaction(knexOrTransaction?: TransactionOrKnex): Promise; + + static transaction(callback: (trx: Transaction) => Promise): Promise; + static transaction( + trxOrKnex: TransactionOrKnex | undefined, + callback: (trx: Transaction) => Promise, + ): Promise; + + static bindKnex(this: M, trxOrKnex: TransactionOrKnex): M; + static bindTransaction(this: M, trxOrKnex: TransactionOrKnex): M; + + // E defaults to `string` so that `fetchGraph(...)` still compiles, + // without narrowing, as TypeScript doesn't infer the remaining arguments. + static fetchGraph = string>( + this: ConstructorType, + modelOrObject: PartialModelObject, + expression: E, + options?: FetchGraphOptions, + ): Rebind, WithGraphModel, WithGraphModel>; + + static fetchGraph = string>( + this: ConstructorType, + modelOrObject: PartialModelObject[], + expression: E, + options?: FetchGraphOptions, + ): Rebind, WithGraphModel, WithGraphModel[]>; + + static getRelations(): Relations; + static getRelation(name: string): Relation; + + static traverse(models: Model | Model[], traverser: TraverserFunction): void; + static traverse( + filterConstructor: typeof Model, + models: Model | Model[], + traverser: TraverserFunction, + ): void; + static traverseAsync(models: Model | Model[], traverser: TraverserFunction): Promise; + static traverseAsync( + filterConstructor: typeof Model, + models: Model | Model[], + traverser: TraverserFunction, + ): Promise; + + static beforeFind(args: StaticHookArguments): any; + static afterFind(args: StaticHookArguments): any; + static beforeInsert(args: StaticHookArguments): any; + static afterInsert(args: StaticHookArguments): any; + static beforeUpdate(args: StaticHookArguments): any; + static afterUpdate(args: StaticHookArguments): any; + static beforeDelete(args: StaticHookArguments): any; + static afterDelete(args: StaticHookArguments): any; + + $modelClass: ModelClass; + + $relatedQuery( + relationName: K, + trxOrKnex?: TransactionOrKnex, + ): RelatedQueryBuilder; + + $relatedQuery( + relationName: string, + trxOrKnex?: TransactionOrKnex, + ): QueryBuilderType; + + $query(trxOrKnex?: TransactionOrKnex): SingleQueryBuilder>; + + $id(id: any): void; + $id(): any; + + $fetchGraph>( + expression: E, + options?: FetchGraphOptions, + ): Rebind, WithGraphModel, WithGraphModel>; + + $formatDatabaseJson(json: Pojo): Pojo; + $parseDatabaseJson(json: Pojo, options?: DatabaseJsonOptions): Pojo; + + $formatJson(json: Pojo, opt?: Pojo): Pojo; + $parseJson(json: Pojo, opt?: ModelOptions): Pojo; + + $beforeValidate(jsonSchema: JSONSchema, json: Pojo, opt: ModelOptions): JSONSchema; + $validate(json?: Pojo, opt?: ModelOptions): Pojo; // may throw ValidationError if validation fails + $afterValidate(json: Pojo, opt: ModelOptions): void; // may throw ValidationError if validation fails + + $beforeInsert(queryContext: QueryContext): Promise | void; + $afterInsert(queryContext: QueryContext): Promise | void; + $afterUpdate(opt: ModelOptions, queryContext: QueryContext): Promise | void; + $beforeUpdate(opt: ModelOptions, queryContext: QueryContext): Promise | void; + $afterFind(queryContext: QueryContext): Promise | void; + $beforeDelete(queryContext: QueryContext): Promise | void; + $afterDelete(queryContext: QueryContext): Promise | void; + + $toDatabaseJson(): Pojo; + $toJson(opt?: ToJsonOptions): ModelJson; + toJSON(opt?: ToJsonOptions): ModelJson; + + $setJson(json: object, opt?: ModelOptions): this; + $setDatabaseJson(json: object, options?: DatabaseJsonOptions): this; + + $setRelated( + relation: String | Relation, + related: RM | RM[] | null | undefined, + ): this; + + $appendRelated( + relation: String | Relation, + related: RM | RM[] | null | undefined, + ): this; + + $set(obj: Pojo): this; + $clone(opt?: CloneOptions): this; + $traverse(filterConstructor: typeof Model, traverser: TraverserFunction): this; + $traverse(traverser: TraverserFunction): this; + $traverseAsync(filterConstructor: typeof Model, traverser: TraverserFunction): Promise; + $traverseAsync(traverser: TraverserFunction): Promise; + $omitFromJson(keys: string | string[] | { [key: string]: boolean }): this; + $omitFromDatabaseJson(keys: string | string[] | { [key: string]: boolean }): this; + + $knex(): Knex; + $transaction(): Knex; + + QueryBuilderType: QueryBuilder; - export interface FetchTableMetadataOptions { - knex?: Knex; - force?: boolean; - table?: string; - } + /** + * How `static columnNameMappers` maps the column names in queries, for the + * column checks of strict mode: 'snakeCase' for `snakeCaseMappers()`, + * 'upperSnakeCase' with its `upperCase` option, and 'open' for other mappers, + * which turns the checks off. + */ + ColumnNameMapping?: 'snakeCase' | 'upperSnakeCase' | 'open'; +} - export interface Constructor { - new (): T; - } +/** + * Overloading is required here until the following issues (at least) are resolved: + * + * - https://github.com/microsoft/TypeScript/issues/1360 + * - https://github.com/Microsoft/TypeScript/issues/5453 + * + * @tutorial https://ditojs.github.io/objection/guide/transactions.html#creating-a-transaction + */ +export interface transaction { + start(knexOrModel: Knex | AnyModelConstructor): Promise; + + ( + modelClass1: MC1, + callback: (boundModelClass: MC1, trx?: Transaction) => Promise, + ): Promise; + + ( + modelClass1: MC1, + modelClass2: MC2, + callback: ( + boundModelClass1: MC1, + boundModelClass2: MC2, + trx?: Transaction, + ) => Promise, + ): Promise; + + < + MC1 extends AnyModelConstructor, + MC2 extends AnyModelConstructor, + MC3 extends AnyModelConstructor, + ReturnValue, + >( + modelClass1: MC1, + modelClass2: MC2, + modelClass3: MC3, + callback: ( + boundModelClass1: MC1, + boundModelClass2: MC2, + boundModelClass3: MC3, + trx?: Transaction, + ) => Promise, + ): Promise; + + < + MC1 extends AnyModelConstructor, + MC2 extends AnyModelConstructor, + MC3 extends AnyModelConstructor, + MC4 extends AnyModelConstructor, + ReturnValue, + >( + modelClass1: MC1, + modelClass2: MC2, + modelClass3: MC3, + modelClass4: MC4, + callback: ( + boundModelClass1: MC1, + boundModelClass2: MC2, + boundModelClass3: MC3, + boundModelClass4: MC4, + trx?: Transaction, + ) => Promise, + ): Promise; + + < + MC1 extends AnyModelConstructor, + MC2 extends AnyModelConstructor, + MC3 extends AnyModelConstructor, + MC4 extends AnyModelConstructor, + MC5 extends AnyModelConstructor, + ReturnValue, + >( + modelClass1: MC1, + modelClass2: MC2, + modelClass3: MC3, + modelClass4: MC4, + modelClass5: MC5, + callback: ( + boundModelClass1: MC1, + boundModelClass2: MC2, + boundModelClass3: MC3, + boundModelClass4: MC4, + boundModelClass5: MC5, + trx?: Transaction, + ) => Promise, + ): Promise; + + ( + knex: Knex, + callback: (trx: Transaction) => Promise, + ): Promise; +} - interface PrototypeType extends Function { - prototype: T; - } +export interface initialize { + (knex: Knex, modelClasses: AnyModelConstructor[]): Promise; + (modelClasses: AnyModelConstructor[]): Promise; +} - interface ConstructorFunctionType extends PrototypeType { - new (...args: any[]): T; - } +/** + * The type of the values that a JSON schema declared with `as const` + * describes, e.g. the properties of a model from its `jsonSchema`: + * + * class Person extends Model { + * static jsonSchema = { + * type: 'object', + * required: ['firstName'], + * properties: { + * id: { type: 'integer' }, + * firstName: { type: 'string' }, + * age: { type: ['integer', 'null'] }, + * }, + * } as const; + * + * pets?: Animal[]; + * } + * + * interface Person extends FromSchema {} + * + * Supports `type` (also as an array), `required`, `nullable`, `enum`, + * `const`, `items` (also as a tuple), nested `properties`, `anyOf` and + * `oneOf`. Formats like 'date-time' stay strings, as in JSON. Properties + * that aren't required are optional. Next to `type` or `properties`, `anyOf` + * and `oneOf` only add constraints, e.g. alternative `required` lists, and + * don't change the type. + */ +export type FromSchema = S extends boolean + ? unknown + : S extends { const: infer C } + ? Extract> + : S extends { enum: readonly (infer E)[] } + ? Extract> + : S extends { type: unknown } + ? SchemaType + : S extends { properties: object } + ? SchemaObject + : S extends { anyOf: readonly (infer A)[] } | { oneOf: readonly (infer A)[] } + ? FromSchema | SchemaNullable + : unknown; + +// The type that `type` allows, which `enum` and `const` values are narrowed +// by, as validation checks both. +type SchemaType = S extends { type: infer T } + ? | (T extends readonly (infer N)[] ? SchemaTypeName : SchemaTypeName) + | SchemaNullable + : unknown; + +type SchemaNullable = S extends { nullable: true } ? null : never; + +type SchemaTypeName = N extends 'string' + ? string + : N extends 'number' | 'integer' + ? number + : N extends 'boolean' + ? boolean + : N extends 'null' + ? null + : N extends 'array' + ? SchemaArray + : N extends 'object' + ? SchemaObject + : unknown; + +type SchemaArray = S extends { items: infer I } + ? I extends readonly unknown[] + ? SchemaTuple + : FromSchema[] + : unknown[]; + +// Tuple `items` only check the items that are present: they are required +// with a `minItems` of the tuple's length, and more items are allowed unless +// `additionalItems` restricts them. +type SchemaTuple = [ + ...(S extends { minItems: I['length'] } + ? { -readonly [K in keyof I]: FromSchema } + : { -readonly [K in keyof I]?: FromSchema }), + ...(S extends { additionalItems: false } + ? [] + : S extends { additionalItems: infer A } + ? FromSchema[] + : unknown[]), +]; + +type SchemaObject = S extends { properties: infer P } + ? [keyof P] extends [never] + ? { [key: string]: unknown } + : SchemaProperties + : { [key: string]: unknown }; + +// The intersection is flattened into one object type for readable hovers. +type SchemaProperties = { + -readonly [K in keyof P as K extends R ? K : never]: FromSchema; +} & { + -readonly [K in keyof P as K extends R ? never : K]?: FromSchema; +} extends infer O + ? { [K in keyof O]: O[K] } + : never; + +/** + * A JSON schema with readonly arrays, the type of `static jsonSchema`, so that + * it takes schemas declared `as const`, for `FromSchema`. A `JSONSchema` is + * assignable to it. + */ +export type ReadonlyJSONSchema = ReadonlyDeep; + +type ReadonlyDeep = T extends (infer I)[] + ? readonly ReadonlyDeep[] + : T extends object + ? { readonly [K in keyof T]: ReadonlyDeep } + : T; + +/** + * JSON Schema 7 + * Draft 07 + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01 + * + * These definitions were written by + * + * Boris Cherny https://github.com/bcherny, + * Cyrille Tuzi https://github.com/cyrilletuzi, + * Lucian Buzzo https://github.com/lucianbuzzo, + * Roland Groza https://github.com/rolandjitsu. + * + * https://www.npmjs.com/package/@types/json-schema + */ + +/** + * Primitive type + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.1.1 + */ +export type JSONSchemaTypeName = + 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null' | string; + +export type JSONSchemaType = JSONSchemaArray[] | boolean | number | null | object | string; + +// Workaround for infinite type recursion +// https://github.com/Microsoft/TypeScript/issues/3496#issuecomment-128553540 +export interface JSONSchemaArray extends Array {} + +/** + * Meta schema + * + * Recommended values: + * - 'http://json-schema.org/schema#' + * - 'http://json-schema.org/hyper-schema#' + * - 'http://json-schema.org/draft-07/schema#' + * - 'http://json-schema.org/draft-07/hyper-schema#' + * + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-5 + */ +export type JSONSchemaVersion = string; + +/** + * JSON Schema v7 + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01 + */ +export type JSONSchemaDefinition = JSONSchema | boolean; +export interface JSONSchema { + $id?: string; + $ref?: string; + $schema?: JSONSchemaVersion; + $comment?: string; - // for internal use on generic static this deduction, copied from https://github.com/microsoft/TypeScript/issues/5863#issuecomment-1483978415 - type ConstructorType = PrototypeType> = ( - ConstructorFunctionType | PrototypeType - ) & { - [Key in keyof Static]: Static[Key]; + /** + * @see https://json-schema.org/draft/2019-09/release-notes + */ + $defs?: { + [key: string]: JSONSchemaDefinition; }; - export interface ModelConstructor extends Constructor {} - - export interface ModelClass extends ModelConstructor { - QueryBuilder: typeof QueryBuilder; - - tableName: string; - idColumn: null | string | string[] | readonly string[]; - jsonSchema: JSONSchema; - relationMappings: RelationMappings | RelationMappingsThunk; - modelPaths: string[]; - jsonAttributes: string[] | readonly string[]; - virtualAttributes: string[]; - uidProp: string; - uidRefProp: string; - dbRefProp: string; - propRefRegex: RegExp; - graphUnrelateProp: string; - graphDeleteProp: string; - pickJsonSchemaProperties: boolean; - relatedFindQueryMutates: boolean; - relatedInsertQueryMutates: boolean; - useLimitInFirst: boolean; - modifiers: Modifiers; - columnNameMappers: ColumnNameMappers; - - raw: RawFunction; - ref: ReferenceFunction; - fn: FunctionFunction; - - BelongsToOneRelation: BelongsToOneRelationType; - HasOneRelation: HasOneRelationType; - HasManyRelation: HasManyRelationType; - ManyToManyRelation: ManyToManyRelationType; - HasOneThroughRelation: HasOneThroughRelationType; - - defaultGraphOptions?: GraphOptions; - - query(this: Constructor, trxOrKnex?: TransactionOrKnex): QueryBuilderType; - - relatedQuery( - relationName: K, - trxOrKnex?: TransactionOrKnex, - ): ArrayRelatedQueryBuilder; - - relatedQuery( - relationName: string, - trxOrKnex?: TransactionOrKnex, - ): QueryBuilderType; - - fromJson(json: object, opt?: ModelOptions): M; - fromDatabaseJson(json: object): M; - - columnNameToPropertyName(columnName: string): string; - propertyNameToColumnName(propertyName: string): string; - - createValidator(): Validator; - createValidationError(args: CreateValidationErrorArgs): Error; - createNotFoundError(queryContext: QueryContext, args: CreateNotFoundErrorArgs): Error; - - tableMetadata(opt?: TableMetadataOptions): TableMetadata; - fetchTableMetadata(opt?: FetchTableMetadataOptions): Promise; - - knex(knex?: Knex): Knex; - knexQuery(): Knex.QueryBuilder; - startTransaction(knexOrTransaction?: TransactionOrKnex): Promise; - - transaction(callback: (trx: Transaction) => Promise): Promise; - transaction( - trxOrKnex: TransactionOrKnex, - callback: (trx: Transaction) => Promise, - ): Promise; - - bindKnex(trxOrKnex: TransactionOrKnex): this; - bindTransaction(trxOrKnex: TransactionOrKnex): this; - - fetchGraph>( - modelOrObject: PartialModelObject, - expression: E, - options?: FetchGraphOptions, - ): SingleQueryBuilder>; - - fetchGraph>( - modelOrObject: PartialModelObject[], - expression: E, - options?: FetchGraphOptions, - ): WithGraphModelQueryBuilder; - - getRelations(): Relations; - getRelation(name: string): Relation; - - traverse(models: Model | Model[], traverser: TraverserFunction): void; - traverse( - filterConstructor: ModelConstructor, - models: Model | Model[], - traverser: TraverserFunction, - ): void; - traverseAsync(models: Model | Model[], traverser: TraverserFunction): Promise; - traverseAsync( - filterConstructor: ModelConstructor, - models: Model | Model[], - traverser: TraverserFunction, - ): Promise; - - beforeFind(args: StaticHookArguments): any; - afterFind(args: StaticHookArguments): any; - beforeInsert(args: StaticHookArguments): any; - afterInsert(args: StaticHookArguments): any; - beforeUpdate(args: StaticHookArguments): any; - afterUpdate(args: StaticHookArguments): any; - beforeDelete(args: StaticHookArguments): any; - afterDelete(args: StaticHookArguments): any; - } - - export class Model { - static QueryBuilder: typeof QueryBuilder; - - static tableName: string; - static idColumn: null | string | string[] | readonly string[]; - static jsonSchema: JSONSchema; - static relationMappings: RelationMappings | RelationMappingsThunk; - static modelPaths: string[]; - static jsonAttributes: string[] | readonly string[]; - static virtualAttributes: string[]; - static uidProp: string; - static uidRefProp: string; - static dbRefProp: string; - static propRefRegex: RegExp; - static graphUnrelateProp: string; - static graphDeleteProp: string; - static pickJsonSchemaProperties: boolean; - static relatedFindQueryMutates: boolean; - static relatedInsertQueryMutates: boolean; - static useLimitInFirst: boolean; - static modifiers: Modifiers; - static columnNameMappers: ColumnNameMappers; - - static raw: RawFunction; - static ref: ReferenceFunction; - static fn: FunctionFunction; - - static BelongsToOneRelation: BelongsToOneRelationType; - static HasOneRelation: HasOneRelationType; - static HasManyRelation: HasManyRelationType; - static ManyToManyRelation: ManyToManyRelationType; - static HasOneThroughRelation: HasOneThroughRelationType; - - static defaultGraphOptions?: GraphOptions; - - static query( - this: ConstructorType, - trxOrKnex?: TransactionOrKnex, - ): QueryBuilderType; - - static relatedQuery( - this: ConstructorType, - relationName: K, - trxOrKnex?: TransactionOrKnex, - ): ArrayRelatedQueryBuilder; - - static relatedQuery( - relationName: string, - trxOrKnex?: TransactionOrKnex, - ): QueryBuilderType; - - static fromJson(this: ConstructorType, json: object, opt?: ModelOptions): M; - static fromDatabaseJson(this: ConstructorType, json: object): M; - - static columnNameToPropertyName(columnName: string): string; - static propertyNameToColumnName(propertyName: string): string; - - static createValidator(): Validator; - static createValidationError(args: CreateValidationErrorArgs): Error; - static createNotFoundError(queryContext: QueryContext, args: CreateNotFoundErrorArgs): Error; - - static tableMetadata(opt?: TableMetadataOptions): TableMetadata; - static fetchTableMetadata(opt?: FetchTableMetadataOptions): Promise; - - static knex(knex?: Knex): Knex; - static knexQuery(): Knex.QueryBuilder; - static startTransaction(knexOrTransaction?: TransactionOrKnex): Promise; - - static transaction(callback: (trx: Transaction) => Promise): Promise; - static transaction( - trxOrKnex: TransactionOrKnex | undefined, - callback: (trx: Transaction) => Promise, - ): Promise; - - static bindKnex(this: M, trxOrKnex: TransactionOrKnex): M; - static bindTransaction(this: M, trxOrKnex: TransactionOrKnex): M; - - // E defaults to `string` so that `fetchGraph(...)` still compiles, - // without narrowing, as TypeScript doesn't infer the remaining arguments. - static fetchGraph = string>( - this: ConstructorType, - modelOrObject: PartialModelObject, - expression: E, - options?: FetchGraphOptions, - ): SingleQueryBuilder>; - - static fetchGraph = string>( - this: ConstructorType, - modelOrObject: PartialModelObject[], - expression: E, - options?: FetchGraphOptions, - ): WithGraphModelQueryBuilder; - - static getRelations(): Relations; - static getRelation(name: string): Relation; - - static traverse(models: Model | Model[], traverser: TraverserFunction): void; - static traverse( - filterConstructor: typeof Model, - models: Model | Model[], - traverser: TraverserFunction, - ): void; - static traverseAsync(models: Model | Model[], traverser: TraverserFunction): Promise; - static traverseAsync( - filterConstructor: typeof Model, - models: Model | Model[], - traverser: TraverserFunction, - ): Promise; - - static beforeFind(args: StaticHookArguments): any; - static afterFind(args: StaticHookArguments): any; - static beforeInsert(args: StaticHookArguments): any; - static afterInsert(args: StaticHookArguments): any; - static beforeUpdate(args: StaticHookArguments): any; - static afterUpdate(args: StaticHookArguments): any; - static beforeDelete(args: StaticHookArguments): any; - static afterDelete(args: StaticHookArguments): any; - - $modelClass: ModelClass; - - $relatedQuery( - relationName: K, - trxOrKnex?: TransactionOrKnex, - ): RelatedQueryBuilder; - - $relatedQuery( - relationName: string, - trxOrKnex?: TransactionOrKnex, - ): QueryBuilderType; - - $query(trxOrKnex?: TransactionOrKnex): SingleQueryBuilder>; - - $id(id: any): void; - $id(): any; - - $fetchGraph>( - expression: E, - options?: FetchGraphOptions, - ): SingleQueryBuilder>; - - $formatDatabaseJson(json: Pojo): Pojo; - $parseDatabaseJson(json: Pojo): Pojo; - - $formatJson(json: Pojo, opt?: Pojo): Pojo; - $parseJson(json: Pojo, opt?: ModelOptions): Pojo; - - $beforeValidate(jsonSchema: JSONSchema, json: Pojo, opt: ModelOptions): JSONSchema; - $validate(json?: Pojo, opt?: ModelOptions): Pojo; // may throw ValidationError if validation fails - $afterValidate(json: Pojo, opt: ModelOptions): void; // may throw ValidationError if validation fails - - $beforeInsert(queryContext: QueryContext): Promise | void; - $afterInsert(queryContext: QueryContext): Promise | void; - $afterUpdate(opt: ModelOptions, queryContext: QueryContext): Promise | void; - $beforeUpdate(opt: ModelOptions, queryContext: QueryContext): Promise | void; - $afterFind(queryContext: QueryContext): Promise | void; - $beforeDelete(queryContext: QueryContext): Promise | void; - $afterDelete(queryContext: QueryContext): Promise | void; - - $toDatabaseJson(): Pojo; - $toJson(opt?: ToJsonOptions): ModelObject; - toJSON(opt?: ToJsonOptions): ModelObject; - - $setJson(json: object, opt?: ModelOptions): this; - $setDatabaseJson(json: object): this; - - $setRelated( - relation: String | Relation, - related: RM | RM[] | null | undefined, - ): this; - - $appendRelated( - relation: String | Relation, - related: RM | RM[] | null | undefined, - ): this; - - $set(obj: Pojo): this; - $clone(opt?: CloneOptions): this; - $traverse(filterConstructor: typeof Model, traverser: TraverserFunction): this; - $traverse(traverser: TraverserFunction): this; - $traverseAsync(filterConstructor: typeof Model, traverser: TraverserFunction): Promise; - $traverseAsync(traverser: TraverserFunction): Promise; - $omitFromJson(keys: string | string[] | { [key: string]: boolean }): this; - $omitFromDatabaseJson(keys: string | string[] | { [key: string]: boolean }): this; - - $knex(): Knex; - $transaction(): Knex; - - QueryBuilderType: QueryBuilder; - } + /** + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.1 + */ + type?: JSONSchemaTypeName | JSONSchemaTypeName[]; + enum?: JSONSchemaType[]; + const?: JSONSchemaType; /** - * Overloading is required here until the following issues (at least) are resolved: - * - * - https://github.com/microsoft/TypeScript/issues/1360 - * - https://github.com/Microsoft/TypeScript/issues/5453 - * - * @tutorial https://ditojs.github.io/objection/guide/transactions.html#creating-a-transaction + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.2 */ - export interface transaction { - start(knexOrModel: Knex | AnyModelConstructor): Promise; - - ( - modelClass1: MC1, - callback: (boundModelClass: MC1, trx?: Transaction) => Promise, - ): Promise; - - ( - modelClass1: MC1, - modelClass2: MC2, - callback: ( - boundModelClass1: MC1, - boundModelClass2: MC2, - trx?: Transaction, - ) => Promise, - ): Promise; - - < - MC1 extends AnyModelConstructor, - MC2 extends AnyModelConstructor, - MC3 extends AnyModelConstructor, - ReturnValue, - >( - modelClass1: MC1, - modelClass2: MC2, - modelClass3: MC3, - callback: ( - boundModelClass1: MC1, - boundModelClass2: MC2, - boundModelClass3: MC3, - trx?: Transaction, - ) => Promise, - ): Promise; - - < - MC1 extends AnyModelConstructor, - MC2 extends AnyModelConstructor, - MC3 extends AnyModelConstructor, - MC4 extends AnyModelConstructor, - ReturnValue, - >( - modelClass1: MC1, - modelClass2: MC2, - modelClass3: MC3, - modelClass4: MC4, - callback: ( - boundModelClass1: MC1, - boundModelClass2: MC2, - boundModelClass3: MC3, - boundModelClass4: MC4, - trx?: Transaction, - ) => Promise, - ): Promise; - - < - MC1 extends AnyModelConstructor, - MC2 extends AnyModelConstructor, - MC3 extends AnyModelConstructor, - MC4 extends AnyModelConstructor, - MC5 extends AnyModelConstructor, - ReturnValue, - >( - modelClass1: MC1, - modelClass2: MC2, - modelClass3: MC3, - modelClass4: MC4, - modelClass5: MC5, - callback: ( - boundModelClass1: MC1, - boundModelClass2: MC2, - boundModelClass3: MC3, - boundModelClass4: MC4, - boundModelClass5: MC5, - trx?: Transaction, - ) => Promise, - ): Promise; - - ( - knex: Knex, - callback: (trx: Transaction) => Promise, - ): Promise; - } + multipleOf?: number; + maximum?: number; + exclusiveMaximum?: number; + minimum?: number; + exclusiveMinimum?: number; - interface initialize { - (knex: Knex, modelClasses: AnyModelConstructor[]): Promise; - (modelClasses: AnyModelConstructor[]): Promise; - } + /** + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.3 + */ + maxLength?: number; + minLength?: number; + pattern?: string; /** - * JSON Schema 7 - * Draft 07 - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01 - * - * These definitions were written by - * - * Boris Cherny https://github.com/bcherny, - * Cyrille Tuzi https://github.com/cyrilletuzi, - * Lucian Buzzo https://github.com/lucianbuzzo, - * Roland Groza https://github.com/rolandjitsu. - * - * https://www.npmjs.com/package/@types/json-schema + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.4 */ + items?: JSONSchemaDefinition | JSONSchemaDefinition[]; + additionalItems?: JSONSchemaDefinition; + maxItems?: number; + minItems?: number; + uniqueItems?: boolean; + contains?: JSONSchema; /** - * Primitive type - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.1.1 + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.5 */ - export type JSONSchemaTypeName = - 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null' | string; + maxProperties?: number; + minProperties?: number; + required?: string[]; + properties?: { + [key: string]: JSONSchemaDefinition; + }; + patternProperties?: { + [key: string]: JSONSchemaDefinition; + }; + additionalProperties?: JSONSchemaDefinition; + dependencies?: { + [key: string]: JSONSchemaDefinition | string[]; + }; + propertyNames?: JSONSchemaDefinition; - export type JSONSchemaType = JSONSchemaArray[] | boolean | number | null | object | string; + /** + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.6 + */ + if?: JSONSchemaDefinition; + then?: JSONSchemaDefinition; + else?: JSONSchemaDefinition; - // Workaround for infinite type recursion - // https://github.com/Microsoft/TypeScript/issues/3496#issuecomment-128553540 - export interface JSONSchemaArray extends Array {} + /** + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.7 + */ + allOf?: JSONSchemaDefinition[]; + anyOf?: JSONSchemaDefinition[]; + oneOf?: JSONSchemaDefinition[]; + not?: JSONSchemaDefinition; /** - * Meta schema - * - * Recommended values: - * - 'http://json-schema.org/schema#' - * - 'http://json-schema.org/hyper-schema#' - * - 'http://json-schema.org/draft-07/schema#' - * - 'http://json-schema.org/draft-07/hyper-schema#' - * - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-5 + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-7 */ - export type JSONSchemaVersion = string; + format?: string; /** - * JSON Schema v7 - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01 + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-8 */ - export type JSONSchemaDefinition = JSONSchema | boolean; - export interface JSONSchema { - $id?: string; - $ref?: string; - $schema?: JSONSchemaVersion; - $comment?: string; - - /** - * @see https://json-schema.org/draft/2019-09/release-notes - */ - $defs?: { - [key: string]: JSONSchemaDefinition; - }; + contentMediaType?: string; + contentEncoding?: string; - /** - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.1 - */ - type?: JSONSchemaTypeName | JSONSchemaTypeName[]; - enum?: JSONSchemaType[]; - const?: JSONSchemaType; - - /** - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.2 - */ - multipleOf?: number; - maximum?: number; - exclusiveMaximum?: number; - minimum?: number; - exclusiveMinimum?: number; - - /** - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.3 - */ - maxLength?: number; - minLength?: number; - pattern?: string; - - /** - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.4 - */ - items?: JSONSchemaDefinition | JSONSchemaDefinition[]; - additionalItems?: JSONSchemaDefinition; - maxItems?: number; - minItems?: number; - uniqueItems?: boolean; - contains?: JSONSchema; - - /** - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.5 - */ - maxProperties?: number; - minProperties?: number; - required?: string[]; - properties?: { - [key: string]: JSONSchemaDefinition; - }; - patternProperties?: { - [key: string]: JSONSchemaDefinition; - }; - additionalProperties?: JSONSchemaDefinition; - dependencies?: { - [key: string]: JSONSchemaDefinition | string[]; - }; - propertyNames?: JSONSchemaDefinition; - - /** - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.6 - */ - if?: JSONSchemaDefinition; - then?: JSONSchemaDefinition; - else?: JSONSchemaDefinition; - - /** - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.7 - */ - allOf?: JSONSchemaDefinition[]; - anyOf?: JSONSchemaDefinition[]; - oneOf?: JSONSchemaDefinition[]; - not?: JSONSchemaDefinition; - - /** - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-7 - */ - format?: string; - - /** - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-8 - */ - contentMediaType?: string; - contentEncoding?: string; - - /** - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-9 - */ - definitions?: { - [key: string]: JSONSchemaDefinition; - }; + /** + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-9 + */ + definitions?: { + [key: string]: JSONSchemaDefinition; + }; - /** - * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-10 - */ - title?: string; - description?: string; - default?: JSONSchemaType; - readOnly?: boolean; - writeOnly?: boolean; - examples?: JSONSchemaType; - } + /** + * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-10 + */ + title?: string; + description?: string; + default?: JSONSchemaType; + readOnly?: boolean; + writeOnly?: boolean; + examples?: JSONSchemaType; } diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 000000000..3f404aca9 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,22 @@ +import { configDefaults, defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // The integration tests are a single test file that runs all the specs in + // tests/integration against each database, one after the other. + include: ['tests/main.js', 'tests/unit/**/*.js', 'tests/integration/index.js'], + exclude: [...configDefaults.exclude, 'tests/unit/relations/files/**'], + setupFiles: ['testUtils/setup.js'], + testTimeout: 15000, + hookTimeout: 15000, + // Run the hooks in the order they are defined, like mocha. + sequence: { hooks: 'list' }, + server: { + deps: { + // Load objection and the model files of the relation tests natively, so that + // they share their module instances with the ones that `require()` loads. + external: [/\/node_modules\//, /\/lib\//, /\/tests\/unit\/relations\/files\//], + }, + }, + }, +});