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.
- 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 +201 -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 +67 -81
- 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 +12 -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 +51 -8
- 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 +149 -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
|
@@ -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
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1058
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
192
|
-
//
|
|
193
|
-
const
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
+
}
|