json-rest-api 3.0.0-rc.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/docs/API.md +60 -27
- package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
- package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
- package/docs/GUIDE/13-hooks-and-lifecycle.md +6 -1
- package/docs/GUIDE/16-autofiltering.md +1 -1
- package/docs/GUIDE/17-row-policies.md +8 -6
- package/docs/GUIDE/21-schema-and-migrations.md +7 -1
- package/docs/GUIDE/27-many-row-writes.md +195 -28
- package/docs/GUIDE/30-backend-capabilities.md +16 -2
- package/docs/GUIDE/33-migrating-to-v2.md +2 -2
- package/docs/GUIDE/34-scoped-reads-and-locks.md +165 -26
- package/docs/GUIDE/35-migrating-to-v3.md +141 -10
- package/lib/rest-api-errors.js +6 -5
- package/package.json +2 -2
- package/plugins/core/connectors/lib/transport-http-helpers.js +31 -3
- package/plugins/core/file-handling-plugin.js +2 -1
- package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
- package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
- package/plugins/core/lib/dbIntrospection.js +106 -6
- package/plugins/core/lib/querying/aggregate-query.js +12 -6
- package/plugins/core/lib/querying/include-query-helpers.js +11 -13
- package/plugins/core/lib/querying/include-to-one.js +54 -59
- package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
- package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
- package/plugins/core/lib/querying/query-constraint.js +45 -0
- package/plugins/core/lib/querying/read-only-query.js +50 -13
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
- package/plugins/core/lib/storage/canonical-data-helpers.js +72 -41
- package/plugins/core/lib/storage/many-row-data-helpers.js +409 -124
- package/plugins/core/lib/storage/ordinary-data-helpers.js +47 -79
- package/plugins/core/lib/storage/single-row-writes.js +71 -0
- package/plugins/core/lib/storage/storage-adapter.js +18 -8
- package/plugins/core/lib/storage/storage-mapping.js +4 -1
- package/plugins/core/lib/storage/storage-types.d.ts +8 -1
- package/plugins/core/lib/writing/many-to-many-manipulations.js +5 -2
- package/plugins/core/lib/writing/relationship-processor.js +33 -37
- package/plugins/core/rest-api-autofilter-plugin.js +30 -8
- package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
- package/plugins/core/rest-api-plugin-methods/common.js +117 -77
- package/plugins/core/rest-api-plugin-methods/delete-many.js +54 -28
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
- package/plugins/core/rest-api-plugin-methods/get.js +48 -42
- package/plugins/core/rest-api-plugin-methods/many-row-common.js +47 -5
- package/plugins/core/rest-api-plugin-methods/patch-many.js +81 -34
- package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
- package/plugins/core/rest-api-plugin-methods/post-many.js +139 -21
- package/plugins/core/rest-api-plugin-methods/put.js +5 -1
- package/plugins/core/rest-api-plugin-methods/scoped-reads.js +72 -27
- package/plugins/core/rest-api-plugin.js +2 -1
- package/plugins/core/rest-api-positioning-plugin.js +1 -1
- package/types/data-access-methods.d.ts +32 -11
- package/types/errors.d.ts +5 -4
- package/types/hook-context.d.ts +2 -0
package/README.md
CHANGED
|
@@ -75,8 +75,8 @@ app.listen(3000, () => {
|
|
|
75
75
|
|
|
76
76
|
Version 3 adds many-row writes (`postMany`, `patchMany`, `patchWhereIds`,
|
|
77
77
|
`patchWhere`, `deleteMany`, `deleteWhere`), scoped reads (`scopedQuery`,
|
|
78
|
-
`queryAll`), row locks (`lockByIds`, `claim`), lock versions, shared
|
|
79
|
-
system contexts, and replaces the 2.x bulk methods. The
|
|
78
|
+
`queryAll`), row locks (`lockByIds`, `lockWhere`, `claim`), lock versions, shared
|
|
79
|
+
rows and system contexts, and replaces the 2.x bulk methods. The
|
|
80
80
|
[v3 migration guide](docs/GUIDE/35-migrating-to-v3.md) lists the breaking changes
|
|
81
81
|
with old → new tables; the [v2 migration guide](docs/GUIDE/33-migrating-to-v2.md)
|
|
82
82
|
covers upgrades from 1.x.
|
package/docs/API.md
CHANGED
|
@@ -86,7 +86,7 @@ a separate context for each enlisted operation. See [transaction support](#trans
|
|
|
86
86
|
- [scopedQuery - Scoped Read-Only Builder](#scopedquery---scoped-read-only-builder)
|
|
87
87
|
- [queryAll - Every Matching Record](#queryall---every-matching-record)
|
|
88
88
|
- [Many-Row Writes](#many-row-writes)
|
|
89
|
-
- [lockByIds and claim - Row Locks](#lockbyids-and-claim---row-locks)
|
|
89
|
+
- [lockByIds, lockWhere and claim - Row Locks](#lockbyids-lockwhere-and-claim---row-locks)
|
|
90
90
|
- [System Contexts](#system-contexts)
|
|
91
91
|
4. [Relationship Methods](#relationship-methods)
|
|
92
92
|
- [getRelated - Retrieve Related Resources](#getrelated---retrieve-related-resources)
|
|
@@ -1177,6 +1177,11 @@ foreign keys. Fields that are `hidden`, virtual, computed, or that have a getter
|
|
|
1177
1177
|
the ones clients read. The exception is a `date` or `dateTime` field with
|
|
1178
1178
|
`storage.serialize`: it may be bucketed and take `min` and `max`, whose results
|
|
1179
1179
|
are read back as that field's values. `normallyHidden` fields are allowed.
|
|
1180
|
+
In a [system context](GUIDE/34-scoped-reads-and-locks.md#system-contexts), a
|
|
1181
|
+
hidden ownership (autofilter) field may also be grouped and take
|
|
1182
|
+
`countDistinct`, so a job can find which owners have rows; other callers are
|
|
1183
|
+
refused it, since one whose optional owner filter does not resolve would see
|
|
1184
|
+
every owner.
|
|
1180
1185
|
|
|
1181
1186
|
#### Conditional measures
|
|
1182
1187
|
|
|
@@ -1273,13 +1278,16 @@ const { query } = await api.resources.entries.scopedQuery(
|
|
|
1273
1278
|
{ filters, alias, purpose, transaction }, context)
|
|
1274
1279
|
```
|
|
1275
1280
|
|
|
1276
|
-
Resolves to `{ query }`, a Knex builder
|
|
1277
|
-
filters, row policy and the search `filters` applied
|
|
1278
|
-
|
|
1279
|
-
|
|
1281
|
+
Resolves to `{ query }`, a Knex builder over a derived table of the resource's
|
|
1282
|
+
rows with ownership filters, row policy and the search `filters` applied, so
|
|
1283
|
+
conditions the caller adds (`orWhere` included) only narrow them. It is wrapped
|
|
1284
|
+
because a builder is thenable. `purpose` is `'report'` (default; includes
|
|
1285
|
+
shared rows), `'bulk-write'`, `'lock'` or `'claim'`; row policies receive it as
|
|
1280
1286
|
`queryPurpose`. The caller needs `query` permission. The builder refuses
|
|
1281
|
-
writes, locking reads, table changes
|
|
1282
|
-
|
|
1287
|
+
writes, locking reads, table changes, clearing predicates, set operations,
|
|
1288
|
+
common table expressions and access to its Knex client, on clones too
|
|
1289
|
+
(`RestApiScopedQueryReadOnlyError`); joined tables are not scoped. It guards
|
|
1290
|
+
against mistakes, not hostile code.
|
|
1283
1291
|
|
|
1284
1292
|
### queryAll - Every Matching Record
|
|
1285
1293
|
|
|
@@ -1299,20 +1307,20 @@ it throws `RestApiRowLimitError`. Works on both storages.
|
|
|
1299
1307
|
await resource.postMany({ data | document, onConflict, returning, format, queryParams, chunkSize, transaction }, context)
|
|
1300
1308
|
await resource.patchMany({ data | document, onlyIf, missing, returning, format, queryParams, chunkSize, transaction }, context)
|
|
1301
1309
|
await resource.patchWhereIds({ ids, data | document, onlyIf, missing, returning, format, queryParams, chunkSize, transaction }, context)
|
|
1302
|
-
await resource.patchWhere({ filters, data | document, onlyIf, returning, format, queryParams, chunkSize, transaction }, context)
|
|
1310
|
+
await resource.patchWhere({ filters | all: true, data | document, onlyIf, returning, format, queryParams, chunkSize, transaction }, context)
|
|
1303
1311
|
await resource.deleteMany({ ids, missing, returning, chunkSize, transaction }, context)
|
|
1304
|
-
await resource.deleteWhere({ filters, returning, chunkSize, transaction }, context)
|
|
1312
|
+
await resource.deleteWhere({ filters | all: true, returning, chunkSize, transaction }, context)
|
|
1305
1313
|
```
|
|
1306
1314
|
|
|
1307
1315
|
| Parameter | Description |
|
|
1308
1316
|
| --- | --- |
|
|
1309
1317
|
| `data` / `document` | `postMany`, `patchMany`: an array of plain records, or `{ data: [resource objects] }`; `patchMany` rows carry `id`. `patchWhereIds`, `patchWhere`: one record of changes. |
|
|
1310
|
-
| `ids` / `filters` | The records to change: listed ids, or search filters validated as `query` validates them. |
|
|
1311
|
-
| `returning` | `'none'` (default), `'minimal'` (`{ data: [{ type, id }] }`)
|
|
1318
|
+
| `ids` / `filters` / `all` | The records to change: listed ids, or non-empty search filters validated as `query` validates them, or `all: true` for every row the caller can see. |
|
|
1319
|
+
| `returning` | `'none'` (default), `'minimal'` (`{ data: [{ type, id }] }`), `'full'` (`{ data: [records] }`, not for deletes) or `'count'` (`{ meta: { count } }`, the number of rows written, with no row data). |
|
|
1312
1320
|
| `format`, `queryParams` | Output format, `fields` and `include` of full records. |
|
|
1313
|
-
| `onConflict` | Upsert: `{ keys, action: 'ignore' \| 'merge', merge? }`. `keys` must include every ownership field; `merge` is refused under a row policy. |
|
|
1321
|
+
| `onConflict` | Upsert: `{ keys, action: 'ignore' \| 'merge', merge? }`. `keys` must include every ownership field, and a unique index must cover them; rows may not carry an `id` or repeat keys. On a resource with no ownership fields and no row policy, `keys: ['id']` (or the `idProperty`) keys the upsert on the id, and every row carries one; keys compare exactly (a match only by collation fails with 409); only a match on the keys counts, and a clash on another unique index fails the batch. `merge` updates only the fields each row supplies (never `defaultTo` values), runs the patch checks (`checkPermissions` with `method: 'patch'`) on the records it updates and is refused under a row policy. |
|
|
1314
1322
|
| `onlyIf` | Compare-and-set: only rows whose stored values equal these change and are returned. |
|
|
1315
|
-
| `missing` | `'throw'` (default; `RestApiRecordsNotFoundError`, 404) or `'skip'` for ids the caller cannot see. |
|
|
1323
|
+
| `missing` | Id lists only: `'throw'` (default; `RestApiRecordsNotFoundError`, 404) or `'skip'` for ids the caller cannot see. `patchWhere` and `deleteWhere` refuse it: they write the rows their selection finds, with the scope and filters inside each write, and return only the rows written. |
|
|
1316
1324
|
| `chunkSize` | Rows per statement (default 500), lowered to the database's parameter limit. |
|
|
1317
1325
|
| `transaction` | A transaction from `api.transaction` to join; otherwise the call owns one. |
|
|
1318
1326
|
|
|
@@ -1320,9 +1328,12 @@ Each call is all-or-nothing. Every row is validated, stamped, passed through
|
|
|
1320
1328
|
setters and authorized with `checkPermissions` (`method` `post`, `patch` or
|
|
1321
1329
|
`delete`, `originalContext.bulkIndex` the row's position); belongs-to targets
|
|
1322
1330
|
are checked with one read per related type; then one statement runs per chunk.
|
|
1323
|
-
Patches refuse the id, ownership fields
|
|
1324
|
-
`knex.raw()` values
|
|
1325
|
-
|
|
1331
|
+
Patches refuse the id, ownership fields (also through a polymorphic
|
|
1332
|
+
relationship) and the `lockVersionField`, and accept real `knex.raw()` values
|
|
1333
|
+
for stored, non-relationship fields. The UPDATE, DELETE and lock statements
|
|
1334
|
+
repeat the caller's ownership conditions, so a row that moved to another owner
|
|
1335
|
+
after the visibility read is left alone and counts as missing. Deletes remove
|
|
1336
|
+
the records' pivot rows. Batch hooks run once per call:
|
|
1326
1337
|
`beforeProcessingPostMany`, `beforeDataCallPostMany`, `afterDataCallPostMany`,
|
|
1327
1338
|
the `PatchMany` trio and `beforeDataCallDeleteMany` / `afterDataCallDeleteMany`.
|
|
1328
1339
|
|
|
@@ -1333,23 +1344,37 @@ many-to-many or reverse relationships, file fields, or positions.
|
|
|
1333
1344
|
`BulkOperationsPlugin` exposes `postMany`, `patchMany` and `deleteMany` as
|
|
1334
1345
|
`POST`, `PATCH` and `DELETE /RESOURCE/bulk`.
|
|
1335
1346
|
|
|
1336
|
-
### lockByIds and claim - Row Locks
|
|
1347
|
+
### lockByIds, lockWhere and claim - Row Locks
|
|
1337
1348
|
|
|
1338
1349
|
```javascript
|
|
1339
1350
|
await api.transaction(async transaction => {
|
|
1340
1351
|
const { data: locked } = await resource.lockByIds({ ids, transaction, missing, format, queryParams: { fields } }, context)
|
|
1352
|
+
const { data: matching } = await resource.lockWhere({ filters, transaction, format, queryParams: { fields } }, context)
|
|
1341
1353
|
const { data: taken } = await resource.claim({ filters, sort, limit, transaction, format, queryParams: { fields } }, context)
|
|
1342
1354
|
})
|
|
1343
1355
|
```
|
|
1344
1356
|
|
|
1345
|
-
|
|
1346
|
-
return the records as the locking read saw them
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1357
|
+
All three need a transaction (`RestApiLockOutsideTransactionError` otherwise)
|
|
1358
|
+
and return the records as the locking read saw them, through the read hooks of
|
|
1359
|
+
`query` (`enrichRecord`, `finish`, `finishQuery`) and `checkDataPermissions`
|
|
1360
|
+
for each row. The locking read is the first statement and applies the scope
|
|
1361
|
+
itself, so plain reads later in the transaction see what a previous lock holder
|
|
1362
|
+
committed. `lockByIds` locks the visible rows among `ids` in id order
|
|
1363
|
+
(`FOR UPDATE`), sorting lists longer than its 1,000-id statements first; hidden
|
|
1364
|
+
or missing ids throw `RestApiRecordsNotFoundError` unless `missing: 'skip'`. On
|
|
1365
|
+
MySQL and MariaDB it locks every listed id, hidden ones too, until commit, so
|
|
1366
|
+
pass ids from a scoped read, never a raw request list. `lockWhere` locks the
|
|
1367
|
+
visible rows the filters select in id order, waiting for other transactions and
|
|
1368
|
+
including rows that start to match meanwhile; more than 1,000 throw
|
|
1369
|
+
`RestApiRowLimitError`. On MySQL and MariaDB it has no `ORDER BY` and forces an
|
|
1370
|
+
index that starts with the whole ownership columns (or, without ownership
|
|
1371
|
+
conditions, a filtered column), and throws `RestApiBulkWriteUnsupportedError`
|
|
1372
|
+
when the table has none.
|
|
1373
|
+
`claim` takes up to `limit` (default 1, at most 1,000) matching rows that no
|
|
1374
|
+
other transaction holds (`SKIP LOCKED`), in `sort` order (stored fields, default
|
|
1375
|
+
`['id']`). A row policy that joins tables is refused for all three. On SQLite,
|
|
1376
|
+
which has one writer, they take the database's write lock with a write that
|
|
1377
|
+
changes nothing, then read.
|
|
1353
1378
|
|
|
1354
1379
|
A resource option `lockVersionField: 'lock_version'` names a stored integer
|
|
1355
1380
|
that every update (single, many-row and upsert merge) sets to `column + 1`.
|
|
@@ -1992,8 +2017,9 @@ const result = await api.resources.articles.query({
|
|
|
1992
2017
|
```
|
|
1993
2018
|
|
|
1994
2019
|
Pagination metadata is under `meta.pagination`. With counts disabled, it includes
|
|
1995
|
-
`page` and `
|
|
1996
|
-
|
|
2020
|
+
`page`, `pageSize` and `hasMore`, without totals: the page reads one extra row
|
|
2021
|
+
instead of running a count query, so it is one statement and links to `first`,
|
|
2022
|
+
`prev` and `next` but not `last`. The library supplies pagination links appropriate
|
|
1997
2023
|
to the available count/boundary information.
|
|
1998
2024
|
|
|
1999
2025
|
#### Cursor Pagination
|
|
@@ -2377,6 +2403,13 @@ For example, a resource not-found error maps to:
|
|
|
2377
2403
|
| `REST_API_SYSTEM_CONTEXT_FORGED` | 500 |
|
|
2378
2404
|
| Unexpected or temporal output failure | 500 |
|
|
2379
2405
|
|
|
2406
|
+
A 500 answer carries the error's message only for the library's own
|
|
2407
|
+
`RestApi…Error` classes. Any other error, a database error or a plain `Error`
|
|
2408
|
+
thrown by a hook, answers with the detail `An unexpected error occurred`: a
|
|
2409
|
+
database error's message repeats the SQL and its values. The connector's
|
|
2410
|
+
`HTTP request error` or `Fastify request error` log entry records only the
|
|
2411
|
+
`name` and `code` of a database error.
|
|
2412
|
+
|
|
2380
2413
|
Validation violations produce individual HTTP errors with their message and
|
|
2381
2414
|
field location; generic validation errors retain their available details.
|
|
2382
2415
|
Relationship validation/access failures follow their actual typed classification,
|
|
@@ -98,8 +98,10 @@ const authorReviewDocument = await api.resources.reviews.post({
|
|
|
98
98
|
|
|
99
99
|
Plain polymorphic input uses `{ _type, id }` under the relationship alias.
|
|
100
100
|
JSON:API uses `{ type, id }` under `data.relationships.reviewable.data`.
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
Writes refuse `reviewable_type` and `reviewable_id` in attributes (rule
|
|
102
|
+
`foreign_key_in_attributes`), as they refuse a belongs-to foreign key: those
|
|
103
|
+
logical fields describe storage, and the public relationship sets them together,
|
|
104
|
+
after checking that the caller can see the target.
|
|
103
105
|
Plain output uses `_type` to identify the related resource type as well.
|
|
104
106
|
|
|
105
107
|
## Include either direction
|
|
@@ -65,8 +65,10 @@ enabled, metadata is:
|
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
Page 10 returns an empty array. `enablePaginationCounts: false` suppresses the
|
|
68
|
-
count query and count-derived metadata
|
|
69
|
-
contains totals.
|
|
68
|
+
count query and count-derived metadata (`total`, `pageCount` and the `last`
|
|
69
|
+
link); do not assume every collection response contains totals. The page then
|
|
70
|
+
reads one extra row, so `hasMore` and the `next` link remain, and a numbered
|
|
71
|
+
page costs one statement. Every mode respects `queryMaxLimit`.
|
|
70
72
|
|
|
71
73
|
Server code that reads every row, for example an export or a nightly job, can
|
|
72
74
|
raise the cap for one call with `pageSizeLimit`, a parameter beside
|
|
@@ -393,7 +393,12 @@ a handler to these resource write stages.
|
|
|
393
393
|
## Read order and output enrichment
|
|
394
394
|
|
|
395
395
|
GET authorizes through `checkPermissions`, then runs `beforeData` and
|
|
396
|
-
`beforeDataGet`.
|
|
396
|
+
`beforeDataGet`. These see `context.minimalRecord`, the stored row read before
|
|
397
|
+
the full read. A resource with none of these three hooks and no
|
|
398
|
+
`checkDataPermissions` or `checkDataPermissionsGet` hook (registered for it or
|
|
399
|
+
globally) reads its row once, with its scope and row policy in that read, and
|
|
400
|
+
leaves `minimalRecord` unset; a missing or hidden row is still `not_found`, and
|
|
401
|
+
answers before an invalid fieldset does. After fetching, it runs `checkDataPermissions`,
|
|
397
402
|
`checkDataPermissionsGet` and `enrichRecord`. Field getters and computed output
|
|
398
403
|
run during attribute enrichment, followed by `enrichAttributes`.
|
|
399
404
|
`enrichRecordWithRelationships`, `finish` and `finishGet` follow. Final
|
|
@@ -300,7 +300,7 @@ await api.addResource('categories', {
|
|
|
300
300
|
|
|
301
301
|
- Reads (`query`, `get`, includes, `aggregate`, `scopedQuery`) see the owner's
|
|
302
302
|
rows and the shared rows.
|
|
303
|
-
- Many-row writes, `lockByIds`, `claim` and `scopedQuery` with purpose
|
|
303
|
+
- Many-row writes, `lockByIds`, `lockWhere`, `claim` and `scopedQuery` with purpose
|
|
304
304
|
`'bulk-write'`, `'lock'` or `'claim'` see only the owner's rows.
|
|
305
305
|
- Single `patch`, `put` and `delete` of a shared row fail as `forbidden`, and so
|
|
306
306
|
does a create whose owner value resolves to NULL.
|
|
@@ -296,7 +296,7 @@ Policies are reused for the resource selections that define externally visible d
|
|
|
296
296
|
| `relationship-parent` | The parent of a relationship route such as `getRelated`. |
|
|
297
297
|
| `report` | A `scopedQuery()` builder with the default purpose. |
|
|
298
298
|
| `bulk-write` | The visible ids a many-row patch or delete may change, and `scopedQuery({ purpose: 'bulk-write' })`. |
|
|
299
|
-
| `lock` | The
|
|
299
|
+
| `lock` | The rows `lockByIds()` and `lockWhere()` lock, in the locking read itself. A policy that joins other tables is refused here, as for `claim`. |
|
|
300
300
|
| `claim` | The candidate rows `claim()` takes. A policy that joins other tables is refused here, because the database would lock the joined rows too. |
|
|
301
301
|
| `unspecified` | A custom call to the lower-level query-filtering seam did not provide a purpose. |
|
|
302
302
|
|
|
@@ -316,11 +316,13 @@ and include limits in their operation/resource settings. Visibility hooks add
|
|
|
316
316
|
row predicates and retain the query's selected ID and mandatory constraints.
|
|
317
317
|
Their invocation count is not a per-request or per-record contract.
|
|
318
318
|
|
|
319
|
-
Regular and polymorphic belongs-to includes
|
|
320
|
-
|
|
321
|
-
under `queryPurpose: 'include'`.
|
|
322
|
-
|
|
323
|
-
|
|
319
|
+
Regular and polymorphic belongs-to includes load each target type's records
|
|
320
|
+
in one query, however many there are, with target permission and filtering
|
|
321
|
+
hooks applied under `queryPurpose: 'include'`. A long id list is bound as one
|
|
322
|
+
parameter on PostgreSQL and SQLite, so it stays within their parameter limits;
|
|
323
|
+
MySQL and MariaDB have none. SQL projection callbacks run for each query.
|
|
324
|
+
Nested collection includes run after all targets have been collected, so a
|
|
325
|
+
global collection limit applies to the complete set.
|
|
324
326
|
|
|
325
327
|
Relationship validation uses the same batch bound. Its query context has no
|
|
326
328
|
individual `id` and no parent client filters. Retain the selected ID and scope
|
|
@@ -596,7 +596,13 @@ Current live introspection support:
|
|
|
596
596
|
|
|
597
597
|
- SQLite
|
|
598
598
|
- PostgreSQL via `pg` (verified on PostgreSQL 16)
|
|
599
|
-
- MySQL via `mysql2` (verified on MySQL 8
|
|
599
|
+
- MySQL via `mysql2` (verified on MySQL 8)
|
|
600
|
+
- MariaDB via `mysql2` (verified on MariaDB 11.4)
|
|
601
|
+
|
|
602
|
+
MariaDB reports column defaults as SQL (a quoted literal, the word `NULL` for no
|
|
603
|
+
default) and stores a `json` column as `longtext` with a `json_valid` column
|
|
604
|
+
check; the snapshot reads both as MySQL's forms, so the JSON check is part of
|
|
605
|
+
the column type and not a check constraint of its own.
|
|
600
606
|
|
|
601
607
|
PostgreSQL inspection reads the current schema's
|
|
602
608
|
[columns](https://www.postgresql.org/docs/16/infoschema-columns.html),
|
|
@@ -13,9 +13,9 @@ Every table-storage resource has six methods that write many rows at once:
|
|
|
13
13
|
| `postMany({ data })` | one new record per entry |
|
|
14
14
|
| `patchMany({ data: [{ id, ... }] })` | each entry's own changes to the record with that id |
|
|
15
15
|
| `patchWhereIds({ ids, data })` | the same changes to every listed record |
|
|
16
|
-
| `patchWhere({ filters, data })` | the same changes to every record the filters select |
|
|
16
|
+
| `patchWhere({ filters, data })` | the same changes to every record the filters select (or `all: true`) |
|
|
17
17
|
| `deleteMany({ ids })` | the listed records |
|
|
18
|
-
| `deleteWhere({ filters })` | every record the filters select |
|
|
18
|
+
| `deleteWhere({ filters })` | every record the filters select (or `all: true`) |
|
|
19
19
|
|
|
20
20
|
Each call is all-or-nothing. Every row still goes through validation,
|
|
21
21
|
ownership stamping (autofilter), setters, relationship checks and
|
|
@@ -61,9 +61,11 @@ console.log(created.data, restocked.data)
|
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
Rows come back in input order. Many-row writes return nothing unless you ask:
|
|
64
|
-
`returning: 'minimal'` gives identifiers
|
|
65
|
-
read back in the write's transaction
|
|
66
|
-
|
|
64
|
+
`returning: 'minimal'` gives identifiers, `returning: 'full'` the records,
|
|
65
|
+
read back in the write's transaction, and `returning: 'count'` only
|
|
66
|
+
`{ meta: { count } }`, the number of rows written, without reading any back.
|
|
67
|
+
The second call is an upsert: Alpha's ISBN already exists, so its stock
|
|
68
|
+
becomes 9, and Gamma is inserted.
|
|
67
69
|
|
|
68
70
|
```javascript
|
|
69
71
|
const [alpha, beta] = created.data
|
|
@@ -109,10 +111,10 @@ await api.resources.books.postMany({
|
|
|
109
111
|
```
|
|
110
112
|
|
|
111
113
|
Rows may set belongs-to relationships. The library checks every target the
|
|
112
|
-
batch names with one scoped read per related type and locks
|
|
113
|
-
|
|
114
|
-
are refused; change those
|
|
115
|
-
methods.
|
|
114
|
+
batch names with one scoped read per related type, and locks them once for the
|
|
115
|
+
batch with one statement per type, however many rows name them. Rows that set
|
|
116
|
+
many-to-many or reverse (has-many) relationships are refused; change those
|
|
117
|
+
links with single writes or the relationship methods.
|
|
116
118
|
|
|
117
119
|
`patchMany`, `patchWhereIds` and `patchWhere` refuse:
|
|
118
120
|
|
|
@@ -120,6 +122,10 @@ methods.
|
|
|
120
122
|
- autofilter (ownership) fields and the relationships that set them;
|
|
121
123
|
- the resource's `lockVersionField`, which every update advances by itself.
|
|
122
124
|
|
|
125
|
+
A row of a resource with a lock version may change no field at all: its UPDATE
|
|
126
|
+
then only advances the version, which tells other writers' `onlyIf` the
|
|
127
|
+
record has changed.
|
|
128
|
+
|
|
123
129
|
They accept a `knex.raw(...)` value for a stored, non-relationship field, so a
|
|
124
130
|
counter can move inside the statement:
|
|
125
131
|
|
|
@@ -130,6 +136,12 @@ await api.resources.books.patchWhereIds({
|
|
|
130
136
|
})
|
|
131
137
|
```
|
|
132
138
|
|
|
139
|
+
Only a real Knex raw value counts. An object that merely carries an
|
|
140
|
+
`isRawInstance` flag, as JSON input can, is refused with a validation error, so
|
|
141
|
+
the HTTP routes never pass SQL through. Batch hooks find the fields that raw
|
|
142
|
+
values set in `context.rawFields`, and the file-field and positioning refusals
|
|
143
|
+
below apply to them as well.
|
|
144
|
+
|
|
133
145
|
Every row also goes through the resource's setters, and creates through
|
|
134
146
|
`defaultTo` values and autofilter stamping, as single writes do. Rows may omit
|
|
135
147
|
different fields. On SQLite, a field that some rows set and others omit is
|
|
@@ -140,13 +152,14 @@ does not declare with `defaultTo` does not apply to them.
|
|
|
140
152
|
|
|
141
153
|
| Option | Methods | Default | Meaning |
|
|
142
154
|
| --- | --- | --- | --- |
|
|
143
|
-
| `returning` | all | `'none'` | `'minimal'` returns `{ data: [{ type, id }] }`; `'full'` (not for deletes) returns the records |
|
|
155
|
+
| `returning` | all | `'none'` | `'minimal'` returns `{ data: [{ type, id }] }`; `'full'` (not for deletes) returns the records; `'count'` returns `{ meta: { count } }` |
|
|
144
156
|
| `format` | writes | resource default | Output format of `'full'` records |
|
|
145
157
|
| `queryParams` | writes | | `fields` and `include` of `'full'` records |
|
|
146
158
|
| `transaction` | all | | A transaction from `api.transaction` to join |
|
|
147
159
|
| `chunkSize` | all | 500 | Rows per statement, lowered further to the database's parameter limit |
|
|
148
160
|
| `missing` | `patchMany`, `patchWhereIds`, `deleteMany` | `'throw'` | `'skip'` ignores ids the caller cannot see |
|
|
149
161
|
| `onlyIf` | patches | | Compare-and-set: change only rows whose stored values equal these |
|
|
162
|
+
| `all` | `patchWhere`, `deleteWhere` | | `true` acts on every row the caller can see; needed when there are no `filters` |
|
|
150
163
|
| `onConflict` | `postMany` | | Upsert, below |
|
|
151
164
|
|
|
152
165
|
Ids that do not exist or that the caller cannot see (another owner's rows,
|
|
@@ -155,6 +168,95 @@ rows a row policy hides) throw `RestApiRecordsNotFoundError` (subtype
|
|
|
155
168
|
count, so it does not reveal which ids exist. With `missing: 'skip'` they are
|
|
156
169
|
left out. A batch may not name the same id twice.
|
|
157
170
|
|
|
171
|
+
With `missing: 'skip'`, `patchMany`, `patchWhereIds` and `deleteMany` read
|
|
172
|
+
nothing first when the resource has no `checkPermissions` hook and no
|
|
173
|
+
`beforeDataCallPatchMany` or `beforeDataCallDeleteMany` hook (those need the
|
|
174
|
+
visible rows before the write), except on MySQL and MariaDB. The UPDATE or
|
|
175
|
+
DELETE carries the caller's scope, row policy included, as a subquery, and its
|
|
176
|
+
changed rows are the answer, returned with `RETURNING` on PostgreSQL and
|
|
177
|
+
SQLite. MySQL and MariaDB keep the visibility read for ids from the caller:
|
|
178
|
+
InnoDB would lock every listed row before checking the scope, rows the caller
|
|
179
|
+
cannot see included, and hold those locks until commit. After that read, a
|
|
180
|
+
statement of one row there runs the UPDATE with its ownership conditions and
|
|
181
|
+
`onlyIf`, and its row count is the answer. A longer statement locks the rows
|
|
182
|
+
that still match and then updates exactly those, and so does a one-row
|
|
183
|
+
statement on a connection that disables the drivers' default `FOUND_ROWS`
|
|
184
|
+
flag, where MySQL counts the rows an UPDATE changed rather than those it
|
|
185
|
+
matched.
|
|
186
|
+
|
|
187
|
+
`patchWhere` and `deleteWhere` are set operations. They read the ids of the
|
|
188
|
+
rows their selection finds, then write those rows with the caller's scope and
|
|
189
|
+
the filters inside every UPDATE or DELETE (on MySQL and MariaDB inside a locking
|
|
190
|
+
read of the rows, followed by the write of exactly those). A row that another
|
|
191
|
+
transaction deletes, moves out of the caller's scope or changes so that it no
|
|
192
|
+
longer matches in the meantime is left out. The result lists the rows actually
|
|
193
|
+
written, and the methods never throw `RestApiRecordsNotFoundError`; they take no
|
|
194
|
+
`missing` option.
|
|
195
|
+
|
|
196
|
+
The filters are checked on each row as it is once locked, so a selection write
|
|
197
|
+
is a compare-and-set on them: of two transactions whose filters matched the
|
|
198
|
+
same row, the one that waits for the other's lock finds the row the other
|
|
199
|
+
committed, and leaves it out if it no longer matches. PostgreSQL locks the rows
|
|
200
|
+
first, so the statement that writes them starts after the wait; MySQL and
|
|
201
|
+
MariaDB check the scope and the filters in the locking read itself, which
|
|
202
|
+
therefore also locks the rows it joins from other tables. With MariaDB's
|
|
203
|
+
`innodb_snapshot_isolation` on, the waiting transaction fails instead ("Record
|
|
204
|
+
has changed since last read", errno 1020); retry it. SQLite has one writer:
|
|
205
|
+
the second transaction fails with `SQLITE_BUSY` rather than write over the
|
|
206
|
+
first, or, if it starts after the first commits, finds the row the first
|
|
207
|
+
committed.
|
|
208
|
+
|
|
209
|
+
A filter on another table's field (a `searchSchema` field whose `actualField`
|
|
210
|
+
names another table) is read through a derived table. PostgreSQL reads it
|
|
211
|
+
again after the wait, so it too is a compare-and-set; InnoDB would read it from
|
|
212
|
+
the snapshot without locking it, so on MySQL and MariaDB `patchWhere` and
|
|
213
|
+
`deleteWhere` refuse filters whose scope joins a derived table (rule
|
|
214
|
+
`selection_cross_table`), whatever rows they match. Filter on the row's own
|
|
215
|
+
columns instead, for example on the link itself after locking its targets with
|
|
216
|
+
`lockByIds` on their resource. A custom `applyFilter` that reads another table
|
|
217
|
+
through its own subquery is not detected: MySQL and MariaDB read that subquery
|
|
218
|
+
from the snapshot too, so it is no compare-and-set there.
|
|
219
|
+
|
|
220
|
+
The UPDATE, DELETE and lock statements repeat the caller's ownership
|
|
221
|
+
(autofilter) conditions as plain column predicates. A row that another
|
|
222
|
+
transaction moves to a different owner between the visibility read and the
|
|
223
|
+
write is therefore neither written nor locked, and counts as missing:
|
|
224
|
+
`missing: 'throw'` rolls the batch back, `'skip'` leaves the row out of the
|
|
225
|
+
result, and with `onlyIf` it is reported unchanged. Single `patch`, `put` and
|
|
226
|
+
`delete`, on table and AnyAPI storage, do the same with their one scoped read:
|
|
227
|
+
the UPDATE or DELETE carries the owner that read found, and a row that has gone
|
|
228
|
+
or moved to another owner since answers `not_found`. The statement's own row
|
|
229
|
+
count tells. A DELETE counts the rows it removed, and an UPDATE the rows it
|
|
230
|
+
matched on PostgreSQL, SQLite and default MySQL connections. On a MySQL or
|
|
231
|
+
MariaDB connection without `FOUND_ROWS`, an UPDATE that changes nothing counts
|
|
232
|
+
zero, so a zero count there is checked again with a locking read of the row.
|
|
233
|
+
Under READ COMMITTED that re-check leaves a window: a row deleted and created
|
|
234
|
+
again with the same id and owner between the UPDATE and the re-check passes
|
|
235
|
+
it, and the write is reported done although nothing was written. REPEATABLE
|
|
236
|
+
READ, InnoDB's default, closes it: the UPDATE's gap lock holds the new row off
|
|
237
|
+
until commit. A proxy that does not pass the `FOUND_ROWS` flag on to its
|
|
238
|
+
backend connections makes a no-op `patch` of a resource without a lock version
|
|
239
|
+
answer `not_found`.
|
|
240
|
+
|
|
241
|
+
Row policies are checked by the visibility read only, for single and
|
|
242
|
+
many-row writes alike: a row that another transaction moves out of the policy
|
|
243
|
+
(for example into a group the caller cannot see) between the read and the
|
|
244
|
+
write is still written. The ownership conditions close that window for the
|
|
245
|
+
owner. The policy would need a subquery on the written table, which MySQL
|
|
246
|
+
refuses in an UPDATE or DELETE, so no database checks it there. To keep a
|
|
247
|
+
policy-relevant change from slipping between the read and the write, lock the
|
|
248
|
+
row in the writing transaction first (`lockByIds`, which helps only for a
|
|
249
|
+
policy on the row's own columns: [it refuses](17-row-policies.md) a policy
|
|
250
|
+
that joins other tables), or give a many-row write an `onlyIf` on the policy's
|
|
251
|
+
fields. Without a
|
|
252
|
+
visibility read (`missing: 'skip'`, above) the policy is checked by the
|
|
253
|
+
write's subquery, which reads as of the statement's start.
|
|
254
|
+
|
|
255
|
+
`patchWhere` and `deleteWhere` need non-empty `filters`. To change or delete
|
|
256
|
+
every row the caller can see, pass `all: true` instead (not both); in a
|
|
257
|
+
[system context](34-scoped-reads-and-locks.md#system-contexts) that is the whole
|
|
258
|
+
table.
|
|
259
|
+
|
|
158
260
|
`onlyIf` makes a patch conditional. Only the rows whose stored values still
|
|
159
261
|
equal the given ones change, and with `returning: 'minimal'` or `'full'` the
|
|
160
262
|
result lists only those rows. With a
|
|
@@ -175,21 +277,81 @@ if (changed.data.length === 0) {
|
|
|
175
277
|
## Upserts
|
|
176
278
|
|
|
177
279
|
`onConflict: { keys, action, merge }` turns `postMany` into an upsert.
|
|
178
|
-
`keys` are the stored fields of a unique index
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
280
|
+
`keys` are the stored fields of a unique index. Each row must give them values,
|
|
281
|
+
no two rows may repeat them, and rows may not carry an `id`: the keys identify
|
|
282
|
+
the record. On a resource with no autofilter (ownership) fields and no row
|
|
283
|
+
policy, `keys: ['id']` makes the id itself the key (the `idProperty` name, such
|
|
284
|
+
as `email` for a natural key, means the same); every row then carries its id,
|
|
285
|
+
and the primary key is the unique index. There is no other owner whose row the
|
|
286
|
+
id could match, so this is the atomic create-if-missing for such tables.
|
|
287
|
+
With `action: 'ignore'`, rows that match an existing record are skipped and
|
|
288
|
+
only the inserted rows are returned. With `action: 'merge'`, the matching
|
|
289
|
+
record is updated: the fields in `merge` that the row supplies, or by
|
|
290
|
+
default every stored field the rows supply except the keys, the id, ownership
|
|
291
|
+
fields and the lock version. A row supplies the fields the caller sent, its
|
|
292
|
+
belongs-to links and the fields a setter adds; schema defaults (`defaultTo`)
|
|
293
|
+
apply only to the rows merge inserts, never to the records it updates.
|
|
294
|
+
|
|
295
|
+
Keys compare exactly. A stored value is first read as its field reads it, so
|
|
296
|
+
`true` matches a boolean stored as 1 and `1.5` a decimal stored as `1.50`, but
|
|
297
|
+
strings compare as they are, without the column's collation. When the database
|
|
298
|
+
finds a record that matches the keys only by its own comparison, for example
|
|
299
|
+
`'Urgent'` against `'urgent'` under MySQL's case-insensitive collations, the
|
|
300
|
+
batch fails with a `conflict` error (409) instead of guessing.
|
|
183
301
|
|
|
184
302
|
Upserts cannot reach another owner's rows:
|
|
185
303
|
|
|
186
304
|
- `keys` must include every autofilter (ownership) field of the resource. The
|
|
187
|
-
unique index should include them too.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
`patchMany` the rows the caller may change.
|
|
191
|
-
-
|
|
192
|
-
the
|
|
305
|
+
unique index should include them too. The id is a key only on a resource
|
|
306
|
+
with no ownership fields and no row policy.
|
|
307
|
+
- `merge` is refused on a resource with a row policy. Insert with `ignore`,
|
|
308
|
+
then `patchMany` the rows the caller may change.
|
|
309
|
+
- Only a match on the conflict keys counts. A row that clashes with an existing
|
|
310
|
+
record on any other unique index, the primary key included, fails the batch.
|
|
311
|
+
- `merge`, and `ignore` on MySQL and MariaDB, need a unique index whose
|
|
312
|
+
columns are all among the keys, and refuse the batch
|
|
313
|
+
(`RestApiBulkWriteUnsupportedError`) without one. Without it the keys could
|
|
314
|
+
match several records, and the locking read would examine, and on MySQL
|
|
315
|
+
lock, every row of the table. (`ignore` on PostgreSQL and SQLite needs a
|
|
316
|
+
unique index on exactly the keys, as `ON CONFLICT` does.) The first upsert
|
|
317
|
+
on a table reads its indexes once.
|
|
318
|
+
|
|
319
|
+
`merge` reads the records that match the keys under a row lock, inserts the
|
|
320
|
+
other rows with a plain INSERT and updates the matched records by id, a fixed
|
|
321
|
+
number of statements per chunk. The records it updates go through a patch's
|
|
322
|
+
checks as well as a create's: `checkPermissions` runs with `method: 'patch'`
|
|
323
|
+
and the matched record as `minimalRecord`, the resource is refused if a
|
|
324
|
+
per-record patch hook without `{ batch: true }` runs for it, and a key that
|
|
325
|
+
matches a record the caller cannot see fails the batch as `forbidden`.
|
|
326
|
+
|
|
327
|
+
With `returning: 'count'` an upsert counts the rows it inserted, and with
|
|
328
|
+
`merge` the records it updated as well.
|
|
329
|
+
|
|
330
|
+
`ignore` runs `INSERT … ON CONFLICT DO NOTHING` on PostgreSQL and SQLite. On
|
|
331
|
+
MySQL and MariaDB, whose `ON DUPLICATE KEY` fires on every unique index, it
|
|
332
|
+
reads the matching keys under a row lock and inserts the rest. With `merge`,
|
|
333
|
+
and with `ignore` on MySQL and MariaDB, two transactions that upsert the same
|
|
334
|
+
new key at the same time can fail with a unique-key error (PostgreSQL 23505)
|
|
335
|
+
or a deadlock (MySQL 1213); retry the batch. InnoDB keeps the locks of every
|
|
336
|
+
row a locking read examined until the transaction ends, so keep upserting
|
|
337
|
+
transactions short.
|
|
338
|
+
|
|
339
|
+
A single `put` creates a record at the id the caller chose, and is the one
|
|
340
|
+
write that tells an id taken by another owner or hidden from the caller
|
|
341
|
+
apart from a free one: it
|
|
342
|
+
answers `not_found` for the first and creates the second. Where ids must not
|
|
343
|
+
leak between owners, let the server choose them: create with `post` or
|
|
344
|
+
`postMany` (or upsert on keys that include the owner) and keep `put` for
|
|
345
|
+
records the caller already has.
|
|
346
|
+
|
|
347
|
+
`merge` on every database, and `ignore` on MySQL and MariaDB, find the rows
|
|
348
|
+
that match the conflict keys with a locking read of the bare table, before the
|
|
349
|
+
caller's scope is checked. A matching row the caller cannot see, such as one a
|
|
350
|
+
`knexQueryFiltering` hook hides, is therefore locked until commit, and waited
|
|
351
|
+
for while another transaction holds it, as `lockByIds` does with a hidden id
|
|
352
|
+
(see [scoped reads and locks](34-scoped-reads-and-locks.md)). `merge` then fails
|
|
353
|
+
the batch as `forbidden`, and `ignore` skips the row. With `keys: ['id']` that
|
|
354
|
+
read locks the listed ids by primary key.
|
|
193
355
|
|
|
194
356
|
## Transactions
|
|
195
357
|
|
|
@@ -219,12 +381,15 @@ hooks:
|
|
|
219
381
|
| Method | Batch hooks (in order) | Context |
|
|
220
382
|
| --- | --- | --- |
|
|
221
383
|
| `postMany` | `beforeProcessingPostMany`, `beforeDataCallPostMany`, `afterDataCallPostMany` | `inputRecords`; `ids` after the insert |
|
|
222
|
-
| `patchMany`, `patchWhereIds`, `patchWhere` | `beforeProcessingPatchMany`, `beforeDataCallPatchMany`, `afterDataCallPatchMany` | `inputRecords`, `ids` |
|
|
384
|
+
| `patchMany`, `patchWhereIds`, `patchWhere` | `beforeProcessingPatchMany`, `beforeDataCallPatchMany`, `afterDataCallPatchMany` | `inputRecords`, `rawFields`, `ids` |
|
|
223
385
|
| `deleteMany`, `deleteWhere` | `beforeDataCallDeleteMany`, `afterDataCallDeleteMany` | `ids` |
|
|
224
386
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
`'
|
|
387
|
+
In the `afterDataCall…Many` hooks, `ids` lists only the rows the write
|
|
388
|
+
changed, and for patches `inputRecords` lists their inputs in the same order,
|
|
389
|
+
so a row that `missing: 'skip'`, `onlyIf` or a selection left out appears in
|
|
390
|
+
neither. `afterCommit` and `afterRollback` run once, with `context.method` set
|
|
391
|
+
to the many-row method. `checkPermissions` still runs for every row, with
|
|
392
|
+
`method` `'post'`, `'patch'` or `'delete'` and the row's position as
|
|
228
393
|
`originalContext.bulkIndex`.
|
|
229
394
|
|
|
230
395
|
A resource whose per-record write hooks would be skipped is refused rather than
|
|
@@ -246,10 +411,12 @@ HTTP 422) before writing when:
|
|
|
246
411
|
- the resource has a `versionField`, or its writes advance another resource's
|
|
247
412
|
version (use single writes with `expectedVersion`, or a `lockVersionField`
|
|
248
413
|
with `onlyIf`);
|
|
249
|
-
- a per-record write hook without `{ batch: true }` runs for the resource
|
|
414
|
+
- a per-record write hook without `{ batch: true }` runs for the resource
|
|
415
|
+
(the error's `details.hooks` names them; HTTP responses leave details out);
|
|
250
416
|
- rows set many-to-many or reverse relationships;
|
|
251
|
-
- rows set file fields (`FileHandlingPlugin`);
|
|
252
|
-
- rows create positioned records or move them (`PositioningPlugin`)
|
|
417
|
+
- rows set file fields (`FileHandlingPlugin`), directly or with raw values;
|
|
418
|
+
- rows create positioned records or move them (`PositioningPlugin`), directly
|
|
419
|
+
or with raw values.
|
|
253
420
|
|
|
254
421
|
## HTTP routes
|
|
255
422
|
|
|
@@ -37,6 +37,19 @@ See [contributing](../contributing.md#real-databases-and-redis) for native
|
|
|
37
37
|
database verification commands. A passing SQLite suite is not native PostgreSQL/MySQL
|
|
38
38
|
evidence, even when launched with a different storage-mode environment variable.
|
|
39
39
|
|
|
40
|
+
## Column values
|
|
41
|
+
|
|
42
|
+
Only `object` and `array` fields store a structure, as JSON. Every other stored
|
|
43
|
+
field takes a single value: a string, number, boolean, bigint, `Date`, `Buffer`
|
|
44
|
+
or `Uint8Array`, or null. A write, filter or cursor that gives such a field an
|
|
45
|
+
object or array, which a `none` or `blob` field accepts from JSON, fails with
|
|
46
|
+
`RestApiValidationError` (rule `single_value`) naming the field, before any SQL
|
|
47
|
+
runs. MySQL drivers that format queries on the client (mysql2 before 3.17, and
|
|
48
|
+
mysql) would otherwise expand a plain object into `column = value` pairs, so a
|
|
49
|
+
value for one column could set others. Declare structured fields as `object` or
|
|
50
|
+
`array`, or convert the value with a synchronous `storage.serialize` function,
|
|
51
|
+
whose result is stored as it is.
|
|
52
|
+
|
|
40
53
|
## Temporal values
|
|
41
54
|
|
|
42
55
|
Public date/dateTime inputs are strings; time inputs remain strings too. Built-in
|
|
@@ -88,8 +101,9 @@ storage slots.
|
|
|
88
101
|
| --- | --- | --- | --- |
|
|
89
102
|
| Ids of inserted rows | `RETURNING` | `RETURNING` on MariaDB 10.5+; on MySQL the first `insertId` and `auto_increment_increment`, read back and verified (`RestApiBulkInsertIdsError` on a mismatch) | `RETURNING` |
|
|
90
103
|
| Upsert | `ON CONFLICT` | `ON DUPLICATE KEY UPDATE`; a conflict on another unique index fails the batch | `ON CONFLICT` |
|
|
91
|
-
| `lockByIds` | `FOR UPDATE` | `FOR UPDATE` | scoped read
|
|
92
|
-
| `
|
|
104
|
+
| `lockByIds` | `FOR UPDATE` | `FOR UPDATE` | write lock, then a scoped read |
|
|
105
|
+
| `lockWhere` | `FOR UPDATE`, read again until no new row appears | `FOR UPDATE` | write lock, then a scoped read |
|
|
106
|
+
| `claim` | `FOR UPDATE SKIP LOCKED` | candidates first, then `SKIP LOCKED` windows by primary key (MySQL 8.0.1+, MariaDB 10.6+) | write lock, then a scoped read |
|
|
93
107
|
| Date buckets | named zones and offsets; dateTime columns must be `timestamptz`, as `createKnexTable` creates them | offsets; named zones need loaded time zone tables | offsets only; dates stored as epoch milliseconds or ISO text |
|
|
94
108
|
|
|
95
109
|
Rows per statement are capped by `chunkSize` (default 500) and by the
|
|
@@ -2031,8 +2031,8 @@ empty cursor rejects before SQL. Follow generated links to retain filters,
|
|
|
2031
2031
|
fieldsets and the effective size.
|
|
2032
2032
|
|
|
2033
2033
|
`enablePaginationCounts` controls totals for offset pages. With counts disabled,
|
|
2034
|
-
offset metadata contains `page` and `pageSize
|
|
2035
|
-
|
|
2034
|
+
offset metadata contains `page` and `pageSize` (and, since 3.0, `hasMore`, from
|
|
2035
|
+
one extra row); it does not promise totals. Cursor pages use one extra row for `hasMore` and never issue a count
|
|
2036
2036
|
query, regardless of this setting. Ordinary storage previously issued a count
|
|
2037
2037
|
on the first cursor page and discarded its result; that extra query and its
|
|
2038
2038
|
count-filter hook invocation have been removed. Collection predicates still
|