json-rest-api 3.0.0-rc.0 → 3.0.1

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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/docs/API.md +60 -27
  3. package/docs/GUIDE/07-polymorphic-relationships.md +4 -2
  4. package/docs/GUIDE/09-pagination-and-sorting.md +4 -2
  5. package/docs/GUIDE/13-hooks-and-lifecycle.md +6 -1
  6. package/docs/GUIDE/16-autofiltering.md +1 -1
  7. package/docs/GUIDE/17-row-policies.md +8 -6
  8. package/docs/GUIDE/21-schema-and-migrations.md +7 -1
  9. package/docs/GUIDE/27-many-row-writes.md +201 -28
  10. package/docs/GUIDE/30-backend-capabilities.md +16 -2
  11. package/docs/GUIDE/33-migrating-to-v2.md +2 -2
  12. package/docs/GUIDE/34-scoped-reads-and-locks.md +165 -26
  13. package/docs/GUIDE/35-migrating-to-v3.md +141 -10
  14. package/lib/rest-api-errors.js +6 -5
  15. package/package.json +2 -2
  16. package/plugins/core/connectors/lib/transport-http-helpers.js +31 -3
  17. package/plugins/core/file-handling-plugin.js +2 -1
  18. package/plugins/core/lib/anyapi/anyapi-registry.js +3 -2
  19. package/plugins/core/lib/anyapi/canonical-link-store.js +5 -2
  20. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +56 -60
  21. package/plugins/core/lib/dbIntrospection.js +106 -6
  22. package/plugins/core/lib/querying/aggregate-query.js +12 -6
  23. package/plugins/core/lib/querying/include-query-helpers.js +11 -13
  24. package/plugins/core/lib/querying/include-to-one.js +54 -59
  25. package/plugins/core/lib/querying/knex-pagination-helpers.js +9 -2
  26. package/plugins/core/lib/querying/query-constraint.d.ts +11 -0
  27. package/plugins/core/lib/querying/query-constraint.js +45 -0
  28. package/plugins/core/lib/querying/read-only-query.js +50 -13
  29. package/plugins/core/lib/querying-writing/database-value-normalizers.js +40 -3
  30. package/plugins/core/lib/storage/canonical-data-helpers.js +72 -41
  31. package/plugins/core/lib/storage/many-row-data-helpers.js +409 -124
  32. package/plugins/core/lib/storage/ordinary-data-helpers.js +67 -81
  33. package/plugins/core/lib/storage/single-row-writes.js +71 -0
  34. package/plugins/core/lib/storage/storage-adapter.js +18 -8
  35. package/plugins/core/lib/storage/storage-mapping.js +4 -1
  36. package/plugins/core/lib/storage/storage-types.d.ts +12 -1
  37. package/plugins/core/lib/writing/many-to-many-manipulations.js +5 -2
  38. package/plugins/core/lib/writing/relationship-processor.js +33 -37
  39. package/plugins/core/rest-api-autofilter-plugin.js +30 -8
  40. package/plugins/core/rest-api-plugin-methods/aggregate.js +3 -1
  41. package/plugins/core/rest-api-plugin-methods/common.js +117 -77
  42. package/plugins/core/rest-api-plugin-methods/delete-many.js +54 -28
  43. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +22 -0
  44. package/plugins/core/rest-api-plugin-methods/get.js +48 -42
  45. package/plugins/core/rest-api-plugin-methods/many-row-common.js +51 -8
  46. package/plugins/core/rest-api-plugin-methods/patch-many.js +81 -34
  47. package/plugins/core/rest-api-plugin-methods/patch.js +3 -1
  48. package/plugins/core/rest-api-plugin-methods/post-many.js +149 -21
  49. package/plugins/core/rest-api-plugin-methods/put.js +5 -1
  50. package/plugins/core/rest-api-plugin-methods/scoped-reads.js +72 -27
  51. package/plugins/core/rest-api-plugin.js +2 -1
  52. package/plugins/core/rest-api-positioning-plugin.js +1 -1
  53. package/types/data-access-methods.d.ts +32 -11
  54. package/types/errors.d.ts +5 -4
  55. package/types/hook-context.d.ts +2 -0
@@ -1,7 +1,7 @@
1
1
  import { whereInIdentifiers } from '../querying/identifier-query.js'
2
2
  import { loadIncludedParentMap, parseIncludeTree } from '../querying/include-query-helpers.js'
3
3
  import { getResourceFieldset, parseFieldset } from '../querying-writing/field-utils.js'
4
- import { COMPUTED_DEPENDENCIES_KEY, RELATIONSHIP_READ_BATCH_SIZE } from '../querying-writing/knex-constants.js'
4
+ import { COMPUTED_DEPENDENCIES_KEY } from '../querying-writing/knex-constants.js'
5
5
  import { buildResourceUrl, buildRelationshipUrl } from '../querying/url-helpers.js'
6
6
  import { normalizeId } from './utils/descriptor-helpers.js'
7
7
  import { applyFieldSelectionToQuery, buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
@@ -177,36 +177,34 @@ export function createCanonicalRelationshipReader ({ api, knex, getDescriptor, g
177
177
  const targetDescriptor = getDescriptor(belongsToInfo.target)
178
178
 
179
179
  const nestedResources = []
180
- for (let offset = 0; offset < ids.length; offset += RELATIONSHIP_READ_BATCH_SIZE) {
181
- let query = db(targetDescriptor.canonical.tableName)
182
- .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
183
- .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
184
- .whereIn(getCanonicalResourceIdColumn(targetDescriptor), ids.slice(offset, offset + RELATIONSHIP_READ_BATCH_SIZE))
185
- const selectionState = await applyIncludeFieldSelection({
186
- query,
187
- resourceName: targetDescriptor.resource,
188
- tableName: targetDescriptor.canonical.tableName,
189
- context
190
- })
191
- query = selectionState.query
192
- const scopedQueryState = await applyTargetScopeFilters({
193
- query,
194
- resourceName: targetDescriptor.resource,
195
- tableName: targetDescriptor.canonical.tableName,
196
- context
197
- })
198
- query = scopedQueryState.query
199
- const rows = await applyDatabaseReadOptions(query)
180
+ let query = db(targetDescriptor.canonical.tableName)
181
+ .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
182
+ .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
183
+ .modify(whereInIdentifiers, getCanonicalResourceIdColumn(targetDescriptor), ids)
184
+ const selectionState = await applyIncludeFieldSelection({
185
+ query,
186
+ resourceName: targetDescriptor.resource,
187
+ tableName: targetDescriptor.canonical.tableName,
188
+ context
189
+ })
190
+ query = selectionState.query
191
+ const scopedQueryState = await applyTargetScopeFilters({
192
+ query,
193
+ resourceName: targetDescriptor.resource,
194
+ tableName: targetDescriptor.canonical.tableName,
195
+ context
196
+ })
197
+ query = scopedQueryState.query
198
+ const rows = await applyDatabaseReadOptions(query)
200
199
 
201
- for (const row of rows) {
202
- const includeResource = includeRow({
203
- row,
204
- descriptor: targetDescriptor,
205
- context,
206
- fieldSelectionInfo: selectionState.fieldSelectionInfo
207
- }, includes)
208
- nestedResources.push(includeResource)
209
- }
200
+ for (const row of rows) {
201
+ const includeResource = includeRow({
202
+ row,
203
+ descriptor: targetDescriptor,
204
+ context,
205
+ fieldSelectionInfo: selectionState.fieldSelectionInfo
206
+ }, includes)
207
+ nestedResources.push(includeResource)
210
208
  }
211
209
 
212
210
  if (childKeys.length > 0 && nestedResources.length > 0) {
@@ -244,36 +242,34 @@ export function createCanonicalRelationshipReader ({ api, knex, getDescriptor, g
244
242
  const targetDescriptor = getDescriptor(targetType)
245
243
 
246
244
  const ids = [...idSet]
247
- for (let offset = 0; offset < ids.length; offset += RELATIONSHIP_READ_BATCH_SIZE) {
248
- let query = db(targetDescriptor.canonical.tableName)
249
- .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
250
- .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
251
- .whereIn(getCanonicalResourceIdColumn(targetDescriptor), ids.slice(offset, offset + RELATIONSHIP_READ_BATCH_SIZE))
252
- const selectionState = await applyIncludeFieldSelection({
253
- query,
254
- resourceName: targetDescriptor.resource,
255
- tableName: targetDescriptor.canonical.tableName,
256
- context
257
- })
258
- query = selectionState.query
259
- const scopedQueryState = await applyTargetScopeFilters({
260
- query,
261
- resourceName: targetDescriptor.resource,
262
- tableName: targetDescriptor.canonical.tableName,
263
- context
264
- })
265
- query = scopedQueryState.query
266
- const rows = await applyDatabaseReadOptions(query)
267
-
268
- for (const row of rows) {
269
- const includeResource = includeRow({
270
- row,
271
- descriptor: targetDescriptor,
272
- context,
273
- fieldSelectionInfo: selectionState.fieldSelectionInfo
274
- }, includes)
275
- nestedResources.push(includeResource)
276
- }
245
+ let query = db(targetDescriptor.canonical.tableName)
246
+ .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
247
+ .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
248
+ .modify(whereInIdentifiers, getCanonicalResourceIdColumn(targetDescriptor), ids)
249
+ const selectionState = await applyIncludeFieldSelection({
250
+ query,
251
+ resourceName: targetDescriptor.resource,
252
+ tableName: targetDescriptor.canonical.tableName,
253
+ context
254
+ })
255
+ query = selectionState.query
256
+ const scopedQueryState = await applyTargetScopeFilters({
257
+ query,
258
+ resourceName: targetDescriptor.resource,
259
+ tableName: targetDescriptor.canonical.tableName,
260
+ context
261
+ })
262
+ query = scopedQueryState.query
263
+ const rows = await applyDatabaseReadOptions(query)
264
+
265
+ for (const row of rows) {
266
+ const includeResource = includeRow({
267
+ row,
268
+ descriptor: targetDescriptor,
269
+ context,
270
+ fieldSelectionInfo: selectionState.fieldSelectionInfo
271
+ }, includes)
272
+ nestedResources.push(includeResource)
277
273
  }
278
274
  }
279
275
 
@@ -117,8 +117,17 @@ function parseEnumLikeValues (columnType = '', typeName = 'enum', backslashEscap
117
117
  return values
118
118
  }
119
119
 
120
- function normalizeMysqlDefault (row) {
120
+ // MariaDB (10.2.7+) writes COLUMN_DEFAULT as SQL: a literal is quoted, NULL is
121
+ // the word NULL and an expression is bare, where MySQL gives the bare literal.
122
+ function normalizeMariadbDefault (value) {
123
+ if (typeof value !== 'string') return value
124
+ if (value === 'NULL') return null
125
+ return /^'(?:''|\\.|[^'\\])*'$/s.test(value) ? parseEnumLikeValues(`enum(${value})`, 'enum', true)[0] : value
126
+ }
127
+
128
+ function normalizeMysqlDefault (row, mariadb) {
121
129
  const value = Object.hasOwn(row, 'columnDefault') ? row.columnDefault : row.column_default
130
+ if (mariadb) return normalizeMariadbDefault(value)
122
131
  if (row.dataType !== 'json' || typeof value !== 'string') return value
123
132
  let expression = value.trim()
124
133
  if (expression.startsWith('(') && expression.endsWith(')')) expression = expression.slice(1, -1).trim()
@@ -917,8 +926,9 @@ function normalizeDbSchemaName (rows = []) {
917
926
  }
918
927
 
919
928
  async function introspectMysqlTableSnapshot (knex, { tableName, idColumn }) {
920
- const schemaRows = normalizeRows(await knex.raw('SELECT DATABASE() AS schemaName'))
929
+ const schemaRows = normalizeRows(await knex.raw('SELECT DATABASE() AS schemaName, VERSION() AS version'))
921
930
  const schemaName = normalizeDbSchemaName(schemaRows)
931
+ const mariadb = /mariadb/i.test(normalizeText(schemaRows[0]?.version))
922
932
 
923
933
  const tableRows = normalizeRows(
924
934
  await knex.raw(
@@ -1052,12 +1062,17 @@ async function introspectMysqlTableSnapshot (knex, { tableName, idColumn }) {
1052
1062
  )
1053
1063
  )
1054
1064
 
1065
+ // MariaDB stores JSON as LONGTEXT with a column check json_valid(`column`):
1066
+ // that pair is a JSON column, and its check is part of the type.
1067
+ const jsonCheck = (/** @type {any} */ row) => mariadb && /^json_valid\(`((?:``|[^`])+)`\)$/i.exec(normalizeText(row.checkClause))?.[1].replace(/``/g, '`')
1068
+ const jsonColumns = new Set(checkConstraintRows.map(jsonCheck).filter(Boolean))
1055
1069
  const columns = columnRows.map((row) => buildColumnSnapshot({
1056
1070
  name: row.columnName,
1057
- dataType: row.dataType,
1058
- columnType: row.columnType,
1071
+ ...(jsonColumns.has(row.columnName) && normalizeText(row.dataType).toLowerCase() === 'longtext'
1072
+ ? { dataType: 'json', columnType: 'json' }
1073
+ : { dataType: row.dataType, columnType: row.columnType }),
1059
1074
  nullable: normalizeText(row.isNullable).toUpperCase() === 'YES',
1060
- defaultValue: normalizeMysqlDefault(row),
1075
+ defaultValue: normalizeMysqlDefault(row, mariadb),
1061
1076
  defaultIsLiteral: true,
1062
1077
  extra: row.extra,
1063
1078
  autoIncrement: normalizeText(row.extra).toLowerCase().includes('auto_increment'),
@@ -1088,7 +1103,7 @@ async function introspectMysqlTableSnapshot (knex, { tableName, idColumn }) {
1088
1103
  indexes: normalizeIndexes(indexRows),
1089
1104
  foreignKeys: normalizeForeignKeys(foreignKeyRows, [], tableName),
1090
1105
  checkConstraints: normalizeCheckConstraints(
1091
- checkConstraintRows.map((row) => ({
1106
+ checkConstraintRows.filter(row => !jsonColumns.has(jsonCheck(row))).map((row) => ({
1092
1107
  name: row.constraintName,
1093
1108
  clause: row.checkClause
1094
1109
  })),
@@ -1265,6 +1280,91 @@ export async function listKnexTableIndexes (knex, tableName) {
1265
1280
  return new Map(rows.map(row => [row.name, { valid: row.valid !== false, building: row.building === true }]))
1266
1281
  }
1267
1282
 
1283
+ /**
1284
+ * The schema a MySQL or MariaDB connection is using, from `database()`, a
1285
+ * statement that reads no table.
1286
+ * @param {any} knex
1287
+ * @returns {Promise<string>}
1288
+ */
1289
+ export async function currentKnexMysqlSchema (knex) {
1290
+ return normalizeRows(await knex.raw('select database() as name'))[0]?.name
1291
+ }
1292
+
1293
+ /**
1294
+ * The indexes of a MySQL or MariaDB table that the optimizer may use, unique
1295
+ * or not, the primary key included: each with its columns in key order and
1296
+ * the length of a column prefix (`prefix`, null for the whole column). A
1297
+ * column list ends before its first functional key part, which names no
1298
+ * column. Invisible (MySQL) and ignored (MariaDB) indexes are left out.
1299
+ * @param {any} knex
1300
+ * @param {string} schema
1301
+ * @param {string} tableName
1302
+ * @returns {Promise<Array<{ name: string, columns: Array<{ name: string, prefix: number | null }> }>>}
1303
+ */
1304
+ export async function listKnexMysqlIndexes (knex, schema, tableName) {
1305
+ const rows = normalizeRows(await knex.raw(`
1306
+ select * from information_schema.statistics
1307
+ where table_schema = ? and table_name = ?
1308
+ order by index_name, seq_in_index`, [schema, tableName]))
1309
+ const byIndex = new Map()
1310
+ for (const raw of rows) {
1311
+ const row = Object.fromEntries(Object.entries(raw).map(([key, value]) => [key.toLowerCase(), value]))
1312
+ if (row.is_visible === 'NO' || row.ignored === 'YES') continue
1313
+ if (!byIndex.has(row.index_name)) byIndex.set(row.index_name, [])
1314
+ byIndex.get(row.index_name).push(row.column_name == null ? null : { name: row.column_name, prefix: row.sub_part == null ? null : Number(row.sub_part) })
1315
+ }
1316
+ return [...byIndex].map(([name, columns]) => ({ name, columns: columns.includes(null) ? columns.slice(0, columns.indexOf(null)) : columns }))
1317
+ }
1318
+
1319
+ /**
1320
+ * The column lists of a table's unique indexes and constraints, primary key
1321
+ * included, that guarantee uniqueness for every row: partial, expression and
1322
+ * invalid indexes are left out.
1323
+ * @param {any} knex
1324
+ * @param {string} tableName
1325
+ * @returns {Promise<string[][]>}
1326
+ */
1327
+ export async function listKnexUniqueKeyColumns (knex, tableName) {
1328
+ const dialect = detectDialect(knex)
1329
+ if (dialect === 'sqlite') {
1330
+ const keys = []
1331
+ for (const index of normalizeRows(await knex.raw('PRAGMA index_list(??)', [tableName]))) {
1332
+ if (Number(index.unique) !== 1 || Number(index.partial) === 1) continue
1333
+ const columns = normalizeRows(await knex.raw('PRAGMA index_info(??)', [index.name]))
1334
+ if (columns.length && columns.every(column => column.name)) keys.push(columns.sort((a, b) => a.seqno - b.seqno).map(column => column.name))
1335
+ }
1336
+ // An INTEGER PRIMARY KEY is the rowid and has no index entry.
1337
+ const primary = normalizeRows(await knex.raw('PRAGMA table_info(??)', [tableName])).filter(column => Number(column.pk) > 0)
1338
+ if (primary.length) keys.push(primary.sort((a, b) => a.pk - b.pk).map(column => column.name))
1339
+ return keys
1340
+ }
1341
+ const rows = dialect === 'mysql2'
1342
+ ? normalizeRows(await knex.raw(`
1343
+ select index_name as indexName, column_name as columnName, seq_in_index as position
1344
+ from information_schema.statistics
1345
+ where table_schema = database() and table_name = ? and non_unique = 0
1346
+ order by index_name, seq_in_index`, [tableName]))
1347
+ : normalizeRows(await knex.raw(`
1348
+ select c.relname as "indexName", a.attname as "columnName", k.position
1349
+ from pg_index i
1350
+ join pg_class t on t.oid = i.indrelid
1351
+ join pg_namespace n on n.oid = t.relnamespace
1352
+ join pg_class c on c.oid = i.indexrelid
1353
+ cross join lateral unnest(i.indkey) with ordinality as k(num, position)
1354
+ left join pg_attribute a on a.attrelid = t.oid and a.attnum = k.num
1355
+ where t.relname = ? and n.nspname = current_schema() and i.indisunique and i.indisvalid
1356
+ and i.indpred is null and i.indexprs is null and k.position <= i.indnkeyatts
1357
+ order by c.relname, k.position`, [tableName]))
1358
+ const byIndex = new Map()
1359
+ for (const row of rows) {
1360
+ const name = row.indexName ?? row.INDEX_NAME
1361
+ if (!byIndex.has(name)) byIndex.set(name, [])
1362
+ byIndex.get(name).push(row.columnName ?? row.COLUMN_NAME ?? null)
1363
+ }
1364
+ // A functional index (MySQL) has no column name.
1365
+ return [...byIndex.values()].filter(columns => columns.every(Boolean))
1366
+ }
1367
+
1268
1368
  // Column-only ALTER must not impose the resource snapshot's ID or index restrictions.
1269
1369
  export async function introspectKnexColumnConstraints (knex, tableName) {
1270
1370
  requireKnexRaw(knex)
@@ -44,9 +44,13 @@ const positiveInteger = (value, field) => {
44
44
  * Validate an aggregate request against a resource's compiled schema.
45
45
  * Only stored scalar fields whose stored values are their public values qualify:
46
46
  * getters and storage serializers would make SQL group or sum other values than clients see.
47
- * @param {{ params: Record<string, any>, schemaInfo: Record<string, any>, scopeName: string, defaultLimit: number, maxLimit: number }} request
47
+ * A system context may also group by, and count distinct, a hidden ownership
48
+ * field, so a job can find which owners have rows. A scoped caller would see
49
+ * only its own owner, and one whose optional owner filter did not resolve
50
+ * would see every owner's hidden value, so neither may.
51
+ * @param {{ params: Record<string, any>, schemaInfo: Record<string, any>, scopeName: string, defaultLimit: number, maxLimit: number, system?: boolean }} request
48
52
  */
49
- export function compileAggregateRequest ({ params, schemaInfo, scopeName, defaultLimit, maxLimit }) {
53
+ export function compileAggregateRequest ({ params, schemaInfo, scopeName, defaultLimit, maxLimit, system = false }) {
50
54
  if (!isPlainObject(params)) invalid('params', 'aggregate requires a parameter object')
51
55
  for (const key of Object.keys(params)) {
52
56
  if (!PARAMS.has(key)) invalid(key, `Unknown aggregate parameter '${key}'`)
@@ -60,9 +64,11 @@ export function compileAggregateRequest ({ params, schemaInfo, scopeName, defaul
60
64
  const fields = schemaInfo.schemaStructure || {}
61
65
  // A storage serializer changes what SQL sees; date and dateTime fields may still be
62
66
  // bucketed and take min/max, as SQL orders and truncates their stored temporal values.
63
- const fieldDefinition = (name, parameter, { serializedTemporal = false } = {}) => {
67
+ const owners = new Set(system ? schemaInfo.ownershipFields || [] : [])
68
+ const fieldDefinition = (name, parameter, { serializedTemporal = false, owner = false } = {}) => {
64
69
  const definition = typeof name === 'string' && Object.hasOwn(fields, name) ? fields[name] : null
65
- if (!definition || name === 'id' || name === schemaInfo.idProperty || definition.virtual === true || definition.hidden === true) {
70
+ const hidden = definition?.hidden === true && !(owner && owners.has(name))
71
+ if (!definition || name === 'id' || name === schemaInfo.idProperty || definition.virtual === true || hidden) {
66
72
  invalid(parameter, `'${name}' is not an attribute of '${scopeName}' that can be aggregated`)
67
73
  }
68
74
  const serialized = Boolean(definition.storage?.serialize) && !(serializedTemporal && TEMPORAL_TYPES.has(definition.type))
@@ -77,7 +83,7 @@ export function compileAggregateRequest ({ params, schemaInfo, scopeName, defaul
77
83
  const groups = groupBy.map((entry, index) => {
78
84
  const parameter = `groupBy.${index}`
79
85
  if (isPlainObject(entry)) return compileBucket(entry, { parameter, alias: `g${index}`, fieldDefinition, names })
80
- const definition = fieldDefinition(entry, parameter)
86
+ const definition = fieldDefinition(entry, parameter, { owner: true })
81
87
  if (!SCALAR_TYPES.has(definition.type)) invalid(parameter, `Field '${entry}' has type '${definition.type}' and cannot be grouped`)
82
88
  if (names.has(entry)) invalid('groupBy', 'groupBy lists a field twice')
83
89
  names.add(entry)
@@ -101,7 +107,7 @@ export function compileAggregateRequest ({ params, schemaInfo, scopeName, defaul
101
107
  if (fn !== 'count') invalid(parameter, "Only count accepts '*'")
102
108
  return { name, fn, field, definition: null, alias: `m${index}`, where: condition }
103
109
  }
104
- const definition = fieldDefinition(field, parameter, { serializedTemporal: fn === 'min' || fn === 'max' })
110
+ const definition = fieldDefinition(field, parameter, { serializedTemporal: fn === 'min' || fn === 'max', owner: fn === 'countDistinct' })
105
111
  if (!MEASURES[fn].has(definition.type)) invalid(parameter, `${fn} cannot be applied to field '${field}' of type '${definition.type}'`)
106
112
  return { name, fn, field, definition, alias: `m${index}`, where: condition }
107
113
  })
@@ -188,11 +188,9 @@ export const applyScopeFiltersToIncludeQuery = async ({
188
188
  }
189
189
  }
190
190
 
191
- // Linked ids checked per visibility query: a 1,000-row page costs one query and
192
- // one row-policy run per linked type, and any number of ids stays bounded SQL.
193
- const VISIBILITY_BATCH_SIZE = 1000
194
-
195
- export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, context }) => {
191
+ // One query and one row-policy run per linked type, however many ids are linked.
192
+ // With `scoped: false`, the identifiers whose records exist, visible or not.
193
+ export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, context, scoped = true }) => {
196
194
  const idsByType = new Map()
197
195
  for (const { type, id } of identifiers) {
198
196
  if (!idsByType.has(type)) idsByType.set(type, new Set())
@@ -209,12 +207,12 @@ export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, cont
209
207
  const visibleIds = new Set()
210
208
  visible.set(type, visibleIds)
211
209
  const values = [...ids].map(id => adapter.translateFilterValue('id', id))
212
- for (let start = 0; start < values.length; start += VISIBILITY_BATCH_SIZE) {
213
- // whereInIdentifiers binds a long id list as one parameter where the database allows it.
214
- const query = adapter.buildBaseQuery({ transaction: context.transaction })
215
- .modify(whereInIdentifiers, column, values.slice(start, start + VISIBILITY_BATCH_SIZE))
216
- .distinct({ [idColumn]: adapter.isCanonical() ? column : databaseIdentityExpression(knex, column) })
217
- const state = await applyScopeFiltersToIncludeQuery({
210
+ // whereInIdentifiers binds a long id list as one parameter where the database limits parameters.
211
+ const query = adapter.buildBaseQuery({ transaction: context.transaction })
212
+ .modify(whereInIdentifiers, column, values)
213
+ .distinct({ [idColumn]: adapter.isCanonical() ? column : databaseIdentityExpression(knex, column) })
214
+ const state = scoped
215
+ ? await applyScopeFiltersToIncludeQuery({
218
216
  query,
219
217
  scopes,
220
218
  scopeName: type,
@@ -223,8 +221,8 @@ export const filterVisibleIdentifiers = async ({ identifiers, scopes, knex, cont
223
221
  tableName,
224
222
  queryPurpose: 'relationship-identifiers'
225
223
  })
226
- for (const row of await state.query) visibleIds.add(String(row[idColumn]))
227
- }
224
+ : { query: applyDatabaseReadOptions(query) }
225
+ for (const row of await state.query) visibleIds.add(String(row[idColumn]))
228
226
  }
229
227
  return identifiers.filter(({ type, id }) => visible.get(type)?.has(String(id)))
230
228
  }
@@ -6,7 +6,6 @@ import {
6
6
  RELATIONSHIPS_KEY,
7
7
  RELATIONSHIP_METADATA_KEY,
8
8
  COMPUTED_DEPENDENCIES_KEY,
9
- RELATIONSHIP_READ_BATCH_SIZE,
10
9
  } from '../querying-writing/knex-constants.js'
11
10
  import { getOperationDiagnosticContext, wrapUnexpectedError } from '../../../../lib/error-context.js'
12
11
  import { getPolymorphicLinkage } from '../querying-writing/relationship-contracts.js'
@@ -109,36 +108,34 @@ export const loadBelongsTo = async (scope, deps) => {
109
108
 
110
109
  // Load the target records
111
110
  const targetRecords = []
112
- for (let offset = 0; offset < uniqueIds.length; offset += RELATIONSHIP_READ_BATCH_SIZE) {
113
- let query = knex(targetTableName).whereIn(targetIdColumn, uniqueIds.slice(offset, offset + RELATIONSHIP_READ_BATCH_SIZE))
114
- const selectionState = await applyFieldSelectionToQuery({
115
- query,
116
- scope: targetScopeObject,
117
- fieldSelectionInfo,
118
- tableName: targetTableName,
119
- useTablePrefix: false,
120
- storageAdapter,
121
- db: knex,
122
- context: {
123
- ...(requestContext || {}),
124
- scopeName: targetScope,
125
- schemaInfo: targetScopeObject.vars.schemaInfo,
126
- storageAdapter,
127
- },
128
- scopeName: targetScope
129
- })
130
- query = selectionState.query
131
- const scopedQueryState = await applyScopeFiltersToIncludeQuery({
132
- query,
133
- scopes,
111
+ let query = knex(targetTableName).modify(whereInIdentifiers, targetIdColumn, uniqueIds)
112
+ const selectionState = await applyFieldSelectionToQuery({
113
+ query,
114
+ scope: targetScopeObject,
115
+ fieldSelectionInfo,
116
+ tableName: targetTableName,
117
+ useTablePrefix: false,
118
+ storageAdapter,
119
+ db: knex,
120
+ context: {
121
+ ...(requestContext || {}),
134
122
  scopeName: targetScope,
135
- requestContext,
136
- db: knex,
137
- tableName: targetTableName
138
- })
139
- query = scopedQueryState.query
140
- for (const record of await query) targetRecords.push(record)
141
- }
123
+ schemaInfo: targetScopeObject.vars.schemaInfo,
124
+ storageAdapter,
125
+ },
126
+ scopeName: targetScope
127
+ })
128
+ query = selectionState.query
129
+ const scopedQueryState = await applyScopeFiltersToIncludeQuery({
130
+ query,
131
+ scopes,
132
+ scopeName: targetScope,
133
+ requestContext,
134
+ db: knex,
135
+ tableName: targetTableName
136
+ })
137
+ query = scopedQueryState.query
138
+ for (const record of await query) targetRecords.push(record)
142
139
 
143
140
  // Load relationship metadata for all target records
144
141
  await loadRelationshipMetadata(scopes, targetRecords, targetScope)
@@ -450,36 +447,34 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
450
447
  // Query for this type
451
448
  const targetIdColumn = getIdColumnForScope(scopes, targetType)
452
449
  const targetRecords = []
453
- for (let offset = 0; offset < targetIds.length; offset += RELATIONSHIP_READ_BATCH_SIZE) {
454
- let query = knex(targetTable).whereIn(targetIdColumn, targetIds.slice(offset, offset + RELATIONSHIP_READ_BATCH_SIZE))
455
- const selectionState = await applyFieldSelectionToQuery({
456
- query,
457
- scope: targetScopeObject,
458
- fieldSelectionInfo,
459
- tableName: targetTable,
460
- useTablePrefix: false,
461
- storageAdapter,
462
- db: knex,
463
- context: {
464
- ...(requestContext || {}),
465
- scopeName: targetType,
466
- schemaInfo: targetScopeObject.vars.schemaInfo,
467
- storageAdapter,
468
- },
469
- scopeName: targetType
470
- })
471
- query = selectionState.query
472
- const scopedQueryState = await applyScopeFiltersToIncludeQuery({
473
- query,
474
- scopes,
450
+ let query = knex(targetTable).modify(whereInIdentifiers, targetIdColumn, targetIds)
451
+ const selectionState = await applyFieldSelectionToQuery({
452
+ query,
453
+ scope: targetScopeObject,
454
+ fieldSelectionInfo,
455
+ tableName: targetTable,
456
+ useTablePrefix: false,
457
+ storageAdapter,
458
+ db: knex,
459
+ context: {
460
+ ...(requestContext || {}),
475
461
  scopeName: targetType,
476
- requestContext,
477
- db: knex,
478
- tableName: targetTable
479
- })
480
- query = scopedQueryState.query
481
- for (const record of await query) targetRecords.push(record)
482
- }
462
+ schemaInfo: targetScopeObject.vars.schemaInfo,
463
+ storageAdapter,
464
+ },
465
+ scopeName: targetType
466
+ })
467
+ query = selectionState.query
468
+ const scopedQueryState = await applyScopeFiltersToIncludeQuery({
469
+ query,
470
+ scopes,
471
+ scopeName: targetType,
472
+ requestContext,
473
+ db: knex,
474
+ tableName: targetTable
475
+ })
476
+ query = scopedQueryState.query
477
+ for (const record of await query) targetRecords.push(record)
483
478
 
484
479
  // Load relationship metadata for all target records
485
480
  await loadRelationshipMetadata(scopes, targetRecords, targetType)
@@ -41,7 +41,9 @@ export const applyPaginationToQuery = ({ query, page = {}, vars, pageSizeLimit,
41
41
  ))
42
42
  }
43
43
 
44
- query.limit(pageSize + (mode === 'cursor' ? 1 : 0))
44
+ // One extra row tells whether another page follows: always for cursors, and
45
+ // for numbered pages when no count query answers it.
46
+ query.limit(pageSize + (mode === 'cursor' || (mode === 'offset' && !vars.enablePaginationCounts) ? 1 : 0))
45
47
  if (mode === 'offset') query.offset((pageNumber - 1) * pageSize)
46
48
  return { mode, page: pageNumber, pageSize, before }
47
49
  }
@@ -89,7 +91,7 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
89
91
  // Allow empty urlPrefix to generate relative links
90
92
  // if (!urlPrefix) return null;
91
93
 
92
- const { page, pageCount, pageSize } = paginationMeta
94
+ const { page, pageCount, pageSize, hasMore } = paginationMeta
93
95
  const links = {}
94
96
 
95
97
  const baseUrl = `${urlPrefix}/${scopeName}`
@@ -107,6 +109,11 @@ export const generatePaginationLinks = (urlPrefix, scopeName, queryParams, pagin
107
109
  if (page < pageCount) {
108
110
  links.next = buildJsonApiLink(baseUrl, queryParams, { number: page + 1, size: pageSize })
109
111
  }
112
+ } else if (hasMore !== undefined) {
113
+ // Without counts there is no last page, but the extra row tells about the next one.
114
+ links.first = buildJsonApiLink(baseUrl, queryParams, { number: 1, size: pageSize })
115
+ if (page > 1) links.prev = buildJsonApiLink(baseUrl, queryParams, { number: page - 1, size: pageSize })
116
+ if (hasMore) links.next = buildJsonApiLink(baseUrl, queryParams, { number: page + 1, size: pageSize })
110
117
  }
111
118
 
112
119
  return links
@@ -10,3 +10,14 @@ export declare function applyQueryConstraint(options: {
10
10
  storageAdapter: StorageAdapter
11
11
  tableName: string
12
12
  }): void
13
+
14
+ export declare const writeConditions: unique symbol
15
+
16
+ /** Repeats the ownership conditions the scoped reads of `scopeName` recorded on `context`. */
17
+ export declare function applyWriteConditions<Query extends StorageQuery>(options: {
18
+ query: Query
19
+ context: { [writeConditions]?: Record<string, Record<string, unknown>> }
20
+ scopeName: string
21
+ storageAdapter: StorageAdapter
22
+ tableName: string
23
+ }): Query
@@ -25,3 +25,48 @@ export function applyQueryConstraint ({ query, context, scopeName, storageAdapte
25
25
  })
26
26
  }
27
27
  }
28
+
29
+ // Ownership values a scope filter applied to a read, by resource and field: the
30
+ // owner a write of the rows read must still match. The by-id UPDATE, DELETE and
31
+ // lock statements, single or many-row, repeat them as plain column predicates,
32
+ // so a row that moved to another owner after the read is neither written nor
33
+ // locked.
34
+ export const writeConditions = Symbol('writeConditions')
35
+ // A condition value meaning "any owner but not the shared (NULL) rows".
36
+ export const ANY_OWNER = Symbol('anyOwner')
37
+
38
+ /**
39
+ * @param {{ [writeConditions]?: Record<string, Record<string, unknown>> }} context
40
+ * @param {string} scopeName
41
+ * @param {string} field
42
+ * @param {unknown} value
43
+ */
44
+ export function recordWriteCondition (context, scopeName, field, value) {
45
+ const byScope = context[writeConditions] ||= {}
46
+ byScope[scopeName] ||= {}
47
+ byScope[scopeName][field] = value
48
+ }
49
+
50
+ /**
51
+ * @param {{ [writeConditions]?: Record<string, Record<string, unknown>> }} context
52
+ * @param {string} scopeName
53
+ */
54
+ export const hasWriteConditions = (context, scopeName) => Object.keys(context[writeConditions]?.[scopeName] || {}).length > 0
55
+
56
+ /**
57
+ * @param {object} options
58
+ * @param {StorageQuery} options.query
59
+ * @param {{ [writeConditions]?: Record<string, Record<string, unknown>> }} options.context
60
+ * @param {string} options.scopeName
61
+ * @param {StorageAdapter} options.storageAdapter
62
+ * @param {string} options.tableName
63
+ */
64
+ export function applyWriteConditions ({ query, context, scopeName, storageAdapter, tableName }) {
65
+ for (const [field, value] of Object.entries(context[writeConditions]?.[scopeName] || {})) {
66
+ const column = `${tableName}.${storageAdapter.translateColumn(field)}`
67
+ if (value === ANY_OWNER) query.whereNotNull(column)
68
+ else if (value === null) query.whereNull(column)
69
+ else query.where(column, value)
70
+ }
71
+ return query
72
+ }