@wbce-d9/api 12.0.6 → 12.2.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/dist/app.js CHANGED
@@ -83,7 +83,11 @@ export default async function createApp() {
83
83
  const app = express();
84
84
  app.disable('x-powered-by');
85
85
  app.set('trust proxy', env['IP_TRUST_PROXY']);
86
- app.set('query parser', (str) => qs.parse(str, { depth: 10 }));
86
+ app.set('query parser', (str) => qs.parse(str, {
87
+ depth: 10,
88
+ arrayLimit: env['QS_ARRAY_LIMIT'],
89
+ parameterLimit: env['QS_PARAMETER_LIMIT'],
90
+ }));
87
91
  app.use(helmet.contentSecurityPolicy(merge({
88
92
  useDefaults: true,
89
93
  directives: {
@@ -0,0 +1,3 @@
1
+ import type { Knex } from 'knex';
2
+ export declare function up(knex: Knex): Promise<void>;
3
+ export declare function down(knex: Knex): Promise<void>;
@@ -0,0 +1,10 @@
1
+ export async function up(knex) {
2
+ await knex.schema.alterTable('directus_collections', (table) => {
3
+ table.json('check_filter').nullable();
4
+ });
5
+ }
6
+ export async function down(knex) {
7
+ await knex.schema.alterTable('directus_collections', (table) => {
8
+ table.dropColumn('check_filter');
9
+ });
10
+ }
@@ -212,3 +212,25 @@ fields:
212
212
 
213
213
  - field: collapse
214
214
  hidden: true
215
+
216
+ - field: check_filter_divider
217
+ special:
218
+ - alias
219
+ - no-data
220
+ interface: presentation-divider
221
+ options:
222
+ icon: rule
223
+ title: $t:field_options.directus_collections.check_filter_divider
224
+ width: full
225
+
226
+ - field: check_filter
227
+ special:
228
+ - cast-json
229
+ interface: system-filter
230
+ options:
231
+ collectionField: collection
232
+ collectionRequired: true
233
+ allowFieldComparison: true
234
+ includeRelations: false
235
+ relationalFieldSelectable: false
236
+ width: full
package/dist/env.js CHANGED
@@ -26,6 +26,11 @@ const allowedEnvironmentVars = [
26
26
  'MAX_BATCH_MUTATION',
27
27
  'LOGGER_.+',
28
28
  'ROBOTS_TXT',
29
+ 'MAX_RELATIONAL_DEPTH',
30
+ 'MAX_ITEMS_PER_QUERY',
31
+ // query parser
32
+ 'QS_ARRAY_LIMIT',
33
+ 'QS_PARAMETER_LIMIT',
29
34
  // server
30
35
  'SERVER_.+',
31
36
  // database
@@ -204,6 +209,9 @@ const defaults = {
204
209
  PUBLIC_URL: '/',
205
210
  MAX_PAYLOAD_SIZE: '1mb',
206
211
  MAX_RELATIONAL_DEPTH: 10,
212
+ MAX_ITEMS_PER_QUERY: -1,
213
+ QS_ARRAY_LIMIT: 100,
214
+ QS_PARAMETER_LIMIT: 1000,
207
215
  MAX_BATCH_MUTATION: Infinity,
208
216
  ROBOTS_TXT: 'User-agent: *\nDisallow: /',
209
217
  DB_EXCLUDE_TABLES: 'spatial_ref_sys,sysdiagrams',
@@ -294,6 +302,9 @@ const typeMap = {
294
302
  GRAPHQL_INTROSPECTION: 'boolean',
295
303
  MAX_BATCH_MUTATION: 'number',
296
304
  SERVER_SHUTDOWN_TIMEOUT: 'number',
305
+ MAX_ITEMS_PER_QUERY: 'number',
306
+ QS_ARRAY_LIMIT: 'number',
307
+ QS_PARAMETER_LIMIT: 'number',
297
308
  };
298
309
  let env = {
299
310
  ...defaults,
@@ -4,7 +4,7 @@ import { chunk, omit } from 'lodash-es';
4
4
  import { clearSystemCache, getCache } from '../cache.js';
5
5
  import { ALIAS_TYPES } from '../constants.js';
6
6
  import { getHelpers } from '../database/helpers/index.js';
7
- import getDatabase, { getSchemaInspector } from '../database/index.js';
7
+ import getDatabase, { getDatabaseClient, getSchemaInspector } from '../database/index.js';
8
8
  import { systemCollectionRows } from '../database/system-data/collections/index.js';
9
9
  import emitter from '../emitter.js';
10
10
  import env from '../env.js';
@@ -12,6 +12,8 @@ import { ForbiddenException, InvalidPayloadException } from '../exceptions/index
12
12
  import { FieldsService } from '../services/fields.js';
13
13
  import { ItemsService } from '../services/items.js';
14
14
  import { getSchema } from '../utils/get-schema.js';
15
+ import { applyCollectionCheckConstraint } from '../utils/check-constraints.js';
16
+ import logger from '../logger.js';
15
17
  export class CollectionsService {
16
18
  knex;
17
19
  helpers;
@@ -300,9 +302,21 @@ export class CollectionsService {
300
302
  .where({ collection: collectionKey })
301
303
  .first());
302
304
  if (exists) {
303
- await collectionItemsService.updateOne(collectionKey, payload.meta, {
304
- ...opts,
305
- bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
305
+ await collectionItemsService.knex.transaction(async (tsx) => {
306
+ collectionItemsService.knex = tsx;
307
+ if (payload.meta?.check_filter !== undefined) {
308
+ const client = getDatabaseClient();
309
+ if (client === 'postgres') {
310
+ await applyCollectionCheckConstraint(tsx, collectionKey, payload.meta?.check_filter, this.schema);
311
+ }
312
+ else {
313
+ logger.warn(`Check constraints are only enforced for postgres database.`);
314
+ }
315
+ }
316
+ await collectionItemsService.updateOne(collectionKey, payload.meta, {
317
+ ...opts,
318
+ bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
319
+ });
306
320
  });
307
321
  }
308
322
  else {
@@ -10,6 +10,7 @@ export type CollectionMeta = {
10
10
  item_duplication_fields: string[] | null;
11
11
  accountability: 'all' | 'accountability' | null;
12
12
  group: string | null;
13
+ check_filter?: Record<string, any> | null;
13
14
  };
14
15
  export type Collection = {
15
16
  collection: string;
@@ -30,5 +30,6 @@ export declare function applyFilter(knex: Knex, schema: SchemaOverview, rootQuer
30
30
  query: Knex.QueryBuilder<any, any>;
31
31
  hasMultiRelationalFilter: boolean;
32
32
  };
33
+ export declare function addWhereClauses(knex: Knex, schema: SchemaOverview, dbQuery: Knex.QueryBuilder, filter: Filter, collection: string, aliasMap: AliasMap, logical?: 'and' | 'or'): void;
33
34
  export declare function applySearch(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, searchQuery: string, collection: string): Promise<void>;
34
35
  export declare function applyAggregate(dbQuery: Knex.QueryBuilder, aggregate: Aggregate, collection: string): void;
@@ -177,11 +177,10 @@ export function applyOffset(knex, rootQuery, offset) {
177
177
  }
178
178
  }
179
179
  export function applyFilter(knex, schema, rootQuery, rootFilter, collection, aliasMap) {
180
- const helpers = getHelpers(knex);
181
180
  const relations = schema.relations;
182
181
  let hasMultiRelationalFilter = false;
183
182
  addJoins(rootQuery, rootFilter, collection);
184
- addWhereClauses(knex, rootQuery, rootFilter, collection);
183
+ addWhereClauses(knex, schema, rootQuery, rootFilter, collection, aliasMap);
185
184
  return { query: rootQuery, hasMultiRelationalFilter };
186
185
  function addJoins(dbQuery, filter, collection) {
187
186
  for (const [key, value] of Object.entries(filter)) {
@@ -213,265 +212,267 @@ export function applyFilter(knex, schema, rootQuery, rootFilter, collection, ali
213
212
  }
214
213
  }
215
214
  }
216
- function addWhereClauses(knex, dbQuery, filter, collection, logical = 'and') {
217
- for (const [key, value] of Object.entries(filter)) {
218
- if (key === '_or' || key === '_and') {
219
- // If the _or array contains an empty object (full permissions), we should short-circuit and ignore all other
220
- // permission checks, as {} already matches full permissions.
221
- if (key === '_or' && value.some((subFilter) => Object.keys(subFilter).length === 0)) {
222
- continue;
223
- }
224
- /** @NOTE this callback function isn't called until Knex runs the query */
225
- dbQuery[logical].where((subQuery) => {
226
- value.forEach((subFilter) => {
227
- addWhereClauses(knex, subQuery, subFilter, collection, key === '_and' ? 'and' : 'or');
228
- });
229
- });
215
+ }
216
+ export function addWhereClauses(knex, schema, dbQuery, filter, collection, aliasMap, logical = 'and') {
217
+ const helpers = getHelpers(knex);
218
+ const relations = schema.relations;
219
+ for (const [key, value] of Object.entries(filter)) {
220
+ if (key === '_or' || key === '_and') {
221
+ // If the _or array contains an empty object (full permissions), we should short-circuit and ignore all other
222
+ // permission checks, as {} already matches full permissions.
223
+ if (key === '_or' && value.some((subFilter) => Object.keys(subFilter).length === 0)) {
230
224
  continue;
231
225
  }
232
- const filterPath = getFilterPath(key, value);
233
- /**
234
- * For A2M fields, the path can contain an optional collection scope <field>:<scope>
235
- */
236
- const pathRoot = filterPath[0].split(':')[0];
237
- const { relation, relationType } = getRelationInfo(relations, collection, pathRoot);
238
- const { operator: filterOperator, value: filterValue } = getOperation(key, value);
239
- if (filterPath.length > 1 ||
240
- (!(key.includes('(') && key.includes(')')) && schema.collections[collection].fields[key].type === 'alias')) {
241
- if (!relation)
242
- continue;
243
- if (relationType === 'o2m' || relationType === 'o2a') {
244
- let pkField = `${collection}.${schema.collections[relation.related_collection].primary}`;
245
- if (relationType === 'o2a') {
246
- pkField = knex.raw(getHelpers(knex).schema.castA2oPrimaryKey(), [pkField]);
247
- }
248
- const subQueryBuilder = (filter) => (subQueryKnex) => {
249
- const field = relation.field;
250
- const collection = relation.collection;
251
- const column = `${collection}.${field}`;
252
- subQueryKnex
253
- .select({ [field]: column })
254
- .from(collection)
255
- .whereNotNull(column);
256
- applyQuery(knex, relation.collection, subQueryKnex, { filter }, schema);
257
- };
258
- const childKey = Object.keys(value)?.[0];
259
- if (childKey === '_none') {
260
- dbQuery[logical].whereNotIn(pkField, subQueryBuilder(Object.values(value)[0]));
261
- continue;
262
- }
263
- else if (childKey === '_some') {
264
- dbQuery[logical].whereIn(pkField, subQueryBuilder(Object.values(value)[0]));
265
- continue;
266
- }
267
- }
268
- if (filterPath.includes('_none') || filterPath.includes('_some')) {
269
- throw new InvalidQueryException(`"${filterPath.includes('_none') ? '_none' : '_some'}" can only be used with top level relational alias field`);
270
- }
271
- const { columnPath, targetCollection, addNestedPkField } = getColumnPath({
272
- path: filterPath,
273
- collection,
274
- relations,
275
- aliasMap,
276
- schema,
226
+ /** @NOTE this callback function isn't called until Knex runs the query */
227
+ dbQuery[logical].where((subQuery) => {
228
+ value.forEach((subFilter) => {
229
+ addWhereClauses(knex, schema, subQuery, subFilter, collection, aliasMap, key === '_and' ? 'and' : 'or');
277
230
  });
278
- if (addNestedPkField) {
279
- filterPath.push(addNestedPkField);
231
+ });
232
+ continue;
233
+ }
234
+ const filterPath = getFilterPath(key, value);
235
+ /**
236
+ * For A2M fields, the path can contain an optional collection scope <field>:<scope>
237
+ */
238
+ const pathRoot = filterPath[0].split(':')[0];
239
+ const { relation, relationType } = getRelationInfo(relations, collection, pathRoot);
240
+ const { operator: filterOperator, value: filterValue } = getOperation(key, value);
241
+ if (filterPath.length > 1 ||
242
+ (!(key.includes('(') && key.includes(')')) && schema.collections[collection].fields[key].type === 'alias')) {
243
+ if (!relation)
244
+ continue;
245
+ if (relationType === 'o2m' || relationType === 'o2a') {
246
+ let pkField = `${collection}.${schema.collections[relation.related_collection].primary}`;
247
+ if (relationType === 'o2a') {
248
+ pkField = knex.raw(getHelpers(knex).schema.castA2oPrimaryKey(), [pkField]);
249
+ }
250
+ const subQueryBuilder = (filter) => (subQueryKnex) => {
251
+ const field = relation.field;
252
+ const collection = relation.collection;
253
+ const column = `${collection}.${field}`;
254
+ subQueryKnex
255
+ .select({ [field]: column })
256
+ .from(collection)
257
+ .whereNotNull(column);
258
+ applyQuery(knex, relation.collection, subQueryKnex, { filter }, schema);
259
+ };
260
+ const childKey = Object.keys(value)?.[0];
261
+ if (childKey === '_none') {
262
+ dbQuery[logical].whereNotIn(pkField, subQueryBuilder(Object.values(value)[0]));
263
+ continue;
280
264
  }
281
- if (!columnPath)
265
+ else if (childKey === '_some') {
266
+ dbQuery[logical].whereIn(pkField, subQueryBuilder(Object.values(value)[0]));
282
267
  continue;
283
- const { type, special } = validateFilterField(schema.collections[targetCollection].fields, stripFunction(filterPath[filterPath.length - 1]), targetCollection);
284
- validateFilterOperator(type, filterOperator, special);
285
- applyFilterToQuery(columnPath, filterOperator, filterValue, logical, targetCollection);
268
+ }
286
269
  }
287
- else {
288
- const { type, special } = validateFilterField(schema.collections[collection].fields, stripFunction(filterPath[0]), collection);
289
- validateFilterOperator(type, filterOperator, special);
290
- applyFilterToQuery(`${collection}.${filterPath[0]}`, filterOperator, filterValue, logical);
270
+ if (filterPath.includes('_none') || filterPath.includes('_some')) {
271
+ throw new InvalidQueryException(`"${filterPath.includes('_none') ? '_none' : '_some'}" can only be used with top level relational alias field`);
291
272
  }
292
- }
293
- function validateFilterField(fields, key, collection = 'unknown') {
294
- if (fields[key] === undefined) {
295
- throw new InvalidQueryException(`Invalid filter key "${key}" on "${collection}"`);
273
+ const { columnPath, targetCollection, addNestedPkField } = getColumnPath({
274
+ path: filterPath,
275
+ collection,
276
+ relations,
277
+ aliasMap,
278
+ schema,
279
+ });
280
+ if (addNestedPkField) {
281
+ filterPath.push(addNestedPkField);
296
282
  }
297
- return fields[key];
283
+ if (!columnPath)
284
+ continue;
285
+ const { type, special } = validateFilterField(schema.collections[targetCollection].fields, stripFunction(filterPath[filterPath.length - 1]), targetCollection);
286
+ validateFilterOperator(type, filterOperator, special);
287
+ applyFilterToQuery(columnPath, filterOperator, filterValue, logical, targetCollection);
298
288
  }
299
- function validateFilterOperator(type, filterOperator, special) {
300
- if (filterOperator.startsWith('_')) {
301
- filterOperator = filterOperator.slice(1);
302
- }
303
- if (!getFilterOperatorsForType(type).includes(filterOperator)) {
304
- throw new InvalidQueryException(`"${type}" field type does not contain the "_${filterOperator}" filter operator`);
305
- }
306
- if (special.includes('conceal') &&
307
- !getFilterOperatorsForType('hash').includes(filterOperator)) {
308
- throw new InvalidQueryException(`Field with "conceal" special does not allow the "_${filterOperator}" filter operator`);
309
- }
289
+ else {
290
+ const { type, special } = validateFilterField(schema.collections[collection].fields, stripFunction(filterPath[0]), collection);
291
+ validateFilterOperator(type, filterOperator, special);
292
+ applyFilterToQuery(`${collection}.${filterPath[0]}`, filterOperator, filterValue, logical);
310
293
  }
311
- function applyFilterToQuery(key, operator, compareValue, logical = 'and', originalCollectionName) {
312
- const [table, column] = key.split('.');
313
- // Is processed through Knex.Raw, so should be safe to string-inject into these where queries
314
- const selectionRaw = getColumn(knex, table, column, false, schema, { originalCollectionName });
315
- // Knex supports "raw" in the columnName parameter, but isn't typed as such. Too bad..
316
- // See https://github.com/knex/knex/issues/4518 @TODO remove as any once knex is updated
317
- // These operators don't rely on a value, and can thus be used without one (eg `?filter[field][_null]`)
318
- if (operator === '_null' || (operator === '_nnull' && compareValue === false)) {
319
- dbQuery[logical].whereNull(selectionRaw);
320
- }
321
- if (operator === '_nnull' || (operator === '_null' && compareValue === false)) {
322
- dbQuery[logical].whereNotNull(selectionRaw);
323
- }
324
- if (operator === '_empty' || (operator === '_nempty' && compareValue === false)) {
325
- dbQuery[logical].andWhere((query) => {
326
- query.whereNull(key).orWhere(key, '=', '');
327
- });
328
- }
329
- if (operator === '_nempty' || (operator === '_empty' && compareValue === false)) {
330
- dbQuery[logical].andWhere((query) => {
331
- query.whereNotNull(key).andWhere(key, '!=', '');
332
- });
333
- }
334
- // The following fields however, require a value to be run. If no value is passed, we
335
- // ignore them. This allows easier use in GraphQL, where you wouldn't be able to
336
- // conditionally build out your filter structure (#4471)
337
- if (compareValue === undefined)
338
- return;
339
- if (Array.isArray(compareValue)) {
340
- // Tip: when using a `[Type]` type in GraphQL, but don't provide the variable, it'll be
341
- // reported as [undefined].
342
- // We need to remove any undefined values, as they are useless
343
- compareValue = compareValue.filter((val) => val !== undefined);
294
+ }
295
+ function validateFilterField(fields, key, collection = 'unknown') {
296
+ if (fields[key] === undefined) {
297
+ throw new InvalidQueryException(`Invalid filter key "${key}" on "${collection}"`);
298
+ }
299
+ return fields[key];
300
+ }
301
+ function validateFilterOperator(type, filterOperator, special) {
302
+ if (filterOperator.startsWith('_')) {
303
+ filterOperator = filterOperator.slice(1);
304
+ }
305
+ if (!getFilterOperatorsForType(type).includes(filterOperator)) {
306
+ throw new InvalidQueryException(`"${type}" field type does not contain the "_${filterOperator}" filter operator`);
307
+ }
308
+ if (special.includes('conceal') &&
309
+ !getFilterOperatorsForType('hash').includes(filterOperator)) {
310
+ throw new InvalidQueryException(`Field with "conceal" special does not allow the "_${filterOperator}" filter operator`);
311
+ }
312
+ }
313
+ function applyFilterToQuery(key, operator, compareValue, logical = 'and', originalCollectionName) {
314
+ const [table, column] = key.split('.');
315
+ // Is processed through Knex.Raw, so should be safe to string-inject into these where queries
316
+ const selectionRaw = getColumn(knex, table, column, false, schema, { originalCollectionName });
317
+ // Knex supports "raw" in the columnName parameter, but isn't typed as such. Too bad..
318
+ // See https://github.com/knex/knex/issues/4518 @TODO remove as any once knex is updated
319
+ // These operators don't rely on a value, and can thus be used without one (eg `?filter[field][_null]`)
320
+ if (operator === '_null' || (operator === '_nnull' && compareValue === false)) {
321
+ dbQuery[logical].whereNull(selectionRaw);
322
+ }
323
+ if (operator === '_nnull' || (operator === '_null' && compareValue === false)) {
324
+ dbQuery[logical].whereNotNull(selectionRaw);
325
+ }
326
+ if (operator === '_empty' || (operator === '_nempty' && compareValue === false)) {
327
+ dbQuery[logical].andWhere((query) => {
328
+ query.whereNull(key).orWhere(key, '=', '');
329
+ });
330
+ }
331
+ if (operator === '_nempty' || (operator === '_empty' && compareValue === false)) {
332
+ dbQuery[logical].andWhere((query) => {
333
+ query.whereNotNull(key).andWhere(key, '!=', '');
334
+ });
335
+ }
336
+ // The following fields however, require a value to be run. If no value is passed, we
337
+ // ignore them. This allows easier use in GraphQL, where you wouldn't be able to
338
+ // conditionally build out your filter structure (#4471)
339
+ if (compareValue === undefined)
340
+ return;
341
+ if (Array.isArray(compareValue)) {
342
+ // Tip: when using a `[Type]` type in GraphQL, but don't provide the variable, it'll be
343
+ // reported as [undefined].
344
+ // We need to remove any undefined values, as they are useless
345
+ compareValue = compareValue.filter((val) => val !== undefined);
346
+ }
347
+ // Cast filter value (compareValue) based on function used
348
+ if (column.includes('(') && column.includes(')')) {
349
+ const functionName = column.split('(')[0];
350
+ const type = getOutputTypeForFunction(functionName);
351
+ if (['bigInteger', 'integer', 'float', 'decimal'].includes(type)) {
352
+ compareValue = Number(compareValue);
344
353
  }
345
- // Cast filter value (compareValue) based on function used
346
- if (column.includes('(') && column.includes(')')) {
347
- const functionName = column.split('(')[0];
348
- const type = getOutputTypeForFunction(functionName);
349
- if (['bigInteger', 'integer', 'float', 'decimal'].includes(type)) {
350
- compareValue = Number(compareValue);
354
+ }
355
+ // Cast filter value (compareValue) based on type of field being filtered against
356
+ const [collection, field] = key.split('.');
357
+ const mappedCollection = (originalCollectionName || collection);
358
+ if (mappedCollection in schema.collections && field in schema.collections[mappedCollection].fields) {
359
+ const type = schema.collections[mappedCollection].fields[field].type;
360
+ if (['date', 'dateTime', 'time', 'timestamp'].includes(type)) {
361
+ if (Array.isArray(compareValue)) {
362
+ compareValue = compareValue.map((val) => helpers.date.parse(val));
363
+ }
364
+ else {
365
+ compareValue = helpers.date.parse(compareValue);
351
366
  }
352
367
  }
353
- // Cast filter value (compareValue) based on type of field being filtered against
354
- const [collection, field] = key.split('.');
355
- const mappedCollection = (originalCollectionName || collection);
356
- if (mappedCollection in schema.collections && field in schema.collections[mappedCollection].fields) {
357
- const type = schema.collections[mappedCollection].fields[field].type;
358
- if (['date', 'dateTime', 'time', 'timestamp'].includes(type)) {
359
- if (Array.isArray(compareValue)) {
360
- compareValue = compareValue.map((val) => helpers.date.parse(val));
361
- }
362
- else {
363
- compareValue = helpers.date.parse(compareValue);
364
- }
368
+ if (['bigInteger', 'integer', 'float', 'decimal'].includes(type)) {
369
+ if (Array.isArray(compareValue)) {
370
+ compareValue = compareValue.map((val) => Number(val));
365
371
  }
366
- if (['bigInteger', 'integer', 'float', 'decimal'].includes(type)) {
367
- if (Array.isArray(compareValue)) {
368
- compareValue = compareValue.map((val) => Number(val));
369
- }
370
- else {
371
- compareValue = Number(compareValue);
372
- }
372
+ else {
373
+ compareValue = Number(compareValue);
373
374
  }
374
375
  }
375
- if (operator === '_eq') {
376
- dbQuery[logical].where(selectionRaw, '=', compareValue);
377
- }
378
- if (operator === '_neq') {
379
- dbQuery[logical].whereNot(selectionRaw, compareValue);
380
- }
381
- if (operator === '_ieq') {
382
- dbQuery[logical].whereRaw(`LOWER(??) = ?`, [selectionRaw, `${compareValue.toLowerCase()}`]);
383
- }
384
- if (operator === '_nieq') {
385
- dbQuery[logical].whereRaw(`LOWER(??) <> ?`, [selectionRaw, `${compareValue.toLowerCase()}`]);
386
- }
387
- if (operator === '_contains') {
388
- dbQuery[logical].where(selectionRaw, 'like', `%${compareValue}%`);
389
- }
390
- if (operator === '_ncontains') {
391
- dbQuery[logical].whereNot(selectionRaw, 'like', `%${compareValue}%`);
392
- }
393
- if (operator === '_icontains') {
394
- dbQuery[logical].whereRaw(`LOWER(??) LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}%`]);
395
- }
396
- if (operator === '_nicontains') {
397
- dbQuery[logical].whereRaw(`LOWER(??) NOT LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}%`]);
398
- }
399
- if (operator === '_starts_with') {
400
- dbQuery[logical].where(key, 'like', `${compareValue}%`);
401
- }
402
- if (operator === '_nstarts_with') {
403
- dbQuery[logical].whereNot(key, 'like', `${compareValue}%`);
404
- }
405
- if (operator === '_istarts_with') {
406
- dbQuery[logical].whereRaw(`LOWER(??) LIKE ?`, [selectionRaw, `${compareValue.toLowerCase()}%`]);
407
- }
408
- if (operator === '_nistarts_with') {
409
- dbQuery[logical].whereRaw(`LOWER(??) NOT LIKE ?`, [selectionRaw, `${compareValue.toLowerCase()}%`]);
410
- }
411
- if (operator === '_ends_with') {
412
- dbQuery[logical].where(key, 'like', `%${compareValue}`);
413
- }
414
- if (operator === '_nends_with') {
415
- dbQuery[logical].whereNot(key, 'like', `%${compareValue}`);
416
- }
417
- if (operator === '_iends_with') {
418
- dbQuery[logical].whereRaw(`LOWER(??) LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}`]);
419
- }
420
- if (operator === '_niends_with') {
421
- dbQuery[logical].whereRaw(`LOWER(??) NOT LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}`]);
422
- }
423
- if (operator === '_gt') {
424
- dbQuery[logical].where(selectionRaw, '>', compareValue);
425
- }
426
- if (operator === '_gte') {
427
- dbQuery[logical].where(selectionRaw, '>=', compareValue);
428
- }
429
- if (operator === '_lt') {
430
- dbQuery[logical].where(selectionRaw, '<', compareValue);
431
- }
432
- if (operator === '_lte') {
433
- dbQuery[logical].where(selectionRaw, '<=', compareValue);
434
- }
435
- if (operator === '_in') {
436
- let value = compareValue;
437
- if (typeof value === 'string')
438
- value = value.split(',');
439
- dbQuery[logical].whereIn(selectionRaw, value);
440
- }
441
- if (operator === '_nin') {
442
- let value = compareValue;
443
- if (typeof value === 'string')
444
- value = value.split(',');
445
- dbQuery[logical].whereNotIn(selectionRaw, value);
446
- }
447
- if (operator === '_between') {
448
- if (compareValue.length !== 2)
449
- return;
450
- let value = compareValue;
451
- if (typeof value === 'string')
452
- value = value.split(',');
453
- dbQuery[logical].whereBetween(selectionRaw, value);
454
- }
455
- if (operator === '_nbetween') {
456
- if (compareValue.length !== 2)
457
- return;
458
- let value = compareValue;
459
- if (typeof value === 'string')
460
- value = value.split(',');
461
- dbQuery[logical].whereNotBetween(selectionRaw, value);
462
- }
463
- if (operator == '_intersects') {
464
- dbQuery[logical].whereRaw(helpers.st.intersects(key, compareValue));
465
- }
466
- if (operator == '_nintersects') {
467
- dbQuery[logical].whereRaw(helpers.st.nintersects(key, compareValue));
468
- }
469
- if (operator == '_intersects_bbox') {
470
- dbQuery[logical].whereRaw(helpers.st.intersects_bbox(key, compareValue));
471
- }
472
- if (operator == '_nintersects_bbox') {
473
- dbQuery[logical].whereRaw(helpers.st.nintersects_bbox(key, compareValue));
474
- }
376
+ }
377
+ if (operator === '_eq') {
378
+ dbQuery[logical].where(selectionRaw, '=', compareValue);
379
+ }
380
+ if (operator === '_neq') {
381
+ dbQuery[logical].whereNot(selectionRaw, compareValue);
382
+ }
383
+ if (operator === '_ieq') {
384
+ dbQuery[logical].whereRaw(`LOWER(??) = ?`, [selectionRaw, `${compareValue.toLowerCase()}`]);
385
+ }
386
+ if (operator === '_nieq') {
387
+ dbQuery[logical].whereRaw(`LOWER(??) <> ?`, [selectionRaw, `${compareValue.toLowerCase()}`]);
388
+ }
389
+ if (operator === '_contains') {
390
+ dbQuery[logical].where(selectionRaw, 'like', `%${compareValue}%`);
391
+ }
392
+ if (operator === '_ncontains') {
393
+ dbQuery[logical].whereNot(selectionRaw, 'like', `%${compareValue}%`);
394
+ }
395
+ if (operator === '_icontains') {
396
+ dbQuery[logical].whereRaw(`LOWER(??) LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}%`]);
397
+ }
398
+ if (operator === '_nicontains') {
399
+ dbQuery[logical].whereRaw(`LOWER(??) NOT LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}%`]);
400
+ }
401
+ if (operator === '_starts_with') {
402
+ dbQuery[logical].where(key, 'like', `${compareValue}%`);
403
+ }
404
+ if (operator === '_nstarts_with') {
405
+ dbQuery[logical].whereNot(key, 'like', `${compareValue}%`);
406
+ }
407
+ if (operator === '_istarts_with') {
408
+ dbQuery[logical].whereRaw(`LOWER(??) LIKE ?`, [selectionRaw, `${compareValue.toLowerCase()}%`]);
409
+ }
410
+ if (operator === '_nistarts_with') {
411
+ dbQuery[logical].whereRaw(`LOWER(??) NOT LIKE ?`, [selectionRaw, `${compareValue.toLowerCase()}%`]);
412
+ }
413
+ if (operator === '_ends_with') {
414
+ dbQuery[logical].where(key, 'like', `%${compareValue}`);
415
+ }
416
+ if (operator === '_nends_with') {
417
+ dbQuery[logical].whereNot(key, 'like', `%${compareValue}`);
418
+ }
419
+ if (operator === '_iends_with') {
420
+ dbQuery[logical].whereRaw(`LOWER(??) LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}`]);
421
+ }
422
+ if (operator === '_niends_with') {
423
+ dbQuery[logical].whereRaw(`LOWER(??) NOT LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}`]);
424
+ }
425
+ if (operator === '_gt') {
426
+ dbQuery[logical].where(selectionRaw, '>', compareValue);
427
+ }
428
+ if (operator === '_gte') {
429
+ dbQuery[logical].where(selectionRaw, '>=', compareValue);
430
+ }
431
+ if (operator === '_lt') {
432
+ dbQuery[logical].where(selectionRaw, '<', compareValue);
433
+ }
434
+ if (operator === '_lte') {
435
+ dbQuery[logical].where(selectionRaw, '<=', compareValue);
436
+ }
437
+ if (operator === '_in') {
438
+ let value = compareValue;
439
+ if (typeof value === 'string')
440
+ value = value.split(',');
441
+ dbQuery[logical].whereIn(selectionRaw, value);
442
+ }
443
+ if (operator === '_nin') {
444
+ let value = compareValue;
445
+ if (typeof value === 'string')
446
+ value = value.split(',');
447
+ dbQuery[logical].whereNotIn(selectionRaw, value);
448
+ }
449
+ if (operator === '_between') {
450
+ if (compareValue.length !== 2)
451
+ return;
452
+ let value = compareValue;
453
+ if (typeof value === 'string')
454
+ value = value.split(',');
455
+ dbQuery[logical].whereBetween(selectionRaw, value);
456
+ }
457
+ if (operator === '_nbetween') {
458
+ if (compareValue.length !== 2)
459
+ return;
460
+ let value = compareValue;
461
+ if (typeof value === 'string')
462
+ value = value.split(',');
463
+ dbQuery[logical].whereNotBetween(selectionRaw, value);
464
+ }
465
+ if (operator == '_intersects') {
466
+ dbQuery[logical].whereRaw(helpers.st.intersects(key, compareValue));
467
+ }
468
+ if (operator == '_nintersects') {
469
+ dbQuery[logical].whereRaw(helpers.st.nintersects(key, compareValue));
470
+ }
471
+ if (operator == '_intersects_bbox') {
472
+ dbQuery[logical].whereRaw(helpers.st.intersects_bbox(key, compareValue));
473
+ }
474
+ if (operator == '_nintersects_bbox') {
475
+ dbQuery[logical].whereRaw(helpers.st.nintersects_bbox(key, compareValue));
475
476
  }
476
477
  }
477
478
  }
@@ -0,0 +1,4 @@
1
+ import type { Filter, SchemaOverview } from '@wbce-d9/types';
2
+ import type { Knex } from 'knex';
3
+ export declare function formulateCheckClause(knex: Knex, collection: string, filter: Filter, schema: SchemaOverview): Promise<void>;
4
+ export declare function applyCollectionCheckConstraint(knex: Knex, collection: string, filter: Filter | null, schema: SchemaOverview): Promise<void>;
@@ -0,0 +1,49 @@
1
+ import { addWhereClauses } from './apply-query.js';
2
+ import { cloneDeep } from 'lodash-es';
3
+ export async function formulateCheckClause(knex, collection, filter, schema) {
4
+ /**
5
+ * Strategy :
6
+ * With filter, we are able to formulate a where statement but not a check statement.
7
+ * So we formulate the where statement that we will then place in a check statement.
8
+ * Also, with filter, right assignment of operator are always values (simple ?) in the final query
9
+ * With the comparaison syntax of check constraint, we want to be able to have right assignent of field
10
+ * e.g. : filter = {date_end : {_gt : "$FIELD(date_start)" }} should transform into "?? > ??"
11
+ * To ensure this :
12
+ * - we formulate the where clause
13
+ * - we change every simple ? to a double ?? when the "?" is for a binding of form $FIELD(...)
14
+ */
15
+ const cloneFilter = cloneDeep(filter);
16
+ const queryBuilder = knex.queryBuilder();
17
+ addWhereClauses(knex, schema, queryBuilder, cloneFilter, collection, Object.create(null));
18
+ const sqlQuery = queryBuilder.toSQL();
19
+ let bindIndex = 0;
20
+ const checkBindings = [];
21
+ const whereClause = sqlQuery.sql.replace(/\?+/g, function (value) {
22
+ const bind = sqlQuery.bindings[bindIndex];
23
+ const fieldConstraint = bind?.toString().match(/^\$FIELD\((.*)\)$/i)?.[1];
24
+ let result;
25
+ if (fieldConstraint !== undefined) {
26
+ result = '??';
27
+ checkBindings.push(fieldConstraint);
28
+ }
29
+ else {
30
+ result = value;
31
+ checkBindings.push(bind);
32
+ }
33
+ bindIndex++;
34
+ return result;
35
+ });
36
+ const whereString = knex.raw(whereClause, checkBindings).toQuery();
37
+ let checkClause = whereString.match(/where\s+(.+)/i)[1];
38
+ checkClause = checkClause.replaceAll('?', '\\?');
39
+ await knex.schema.table(collection, (table) => {
40
+ table.check(checkClause, undefined, 'directus_constraint');
41
+ });
42
+ }
43
+ export async function applyCollectionCheckConstraint(knex, collection, filter, schema) {
44
+ await knex.raw(`ALTER TABLE ?? DROP CONSTRAINT IF EXISTS "directus_constraint"`, [collection]);
45
+ if (!filter || Object.keys(filter).length === 0) {
46
+ return;
47
+ }
48
+ await formulateCheckClause(knex, collection, filter, schema);
49
+ }
@@ -49,7 +49,7 @@ async function getDatabaseSchema(database, schemaInspector) {
49
49
  const schemaOverview = await schemaInspector.overview();
50
50
  const collections = [
51
51
  ...(await database
52
- .select('collection', 'singleton', 'note', 'sort_field', 'accountability')
52
+ .select('collection', 'singleton', 'note', 'sort_field', 'accountability', 'check_filter')
53
53
  .from('directus_collections')),
54
54
  ...systemCollectionRows,
55
55
  ];
@@ -74,6 +74,7 @@ async function getDatabaseSchema(database, schemaInspector) {
74
74
  note: collectionMeta?.note || null,
75
75
  sortField: collectionMeta?.sort_field || null,
76
76
  accountability: collectionMeta ? collectionMeta.accountability : 'all',
77
+ check_filter: collectionMeta?.check_filter || null,
77
78
  fields: mapValues(schemaOverview[collection]?.columns, (column) => {
78
79
  return {
79
80
  field: column.column_name,
@@ -1,12 +1,17 @@
1
1
  import { parseFilter, parseJSON } from '@wbce-d9/utils';
2
2
  import { flatten, get, isPlainObject, merge, set } from 'lodash-es';
3
+ import env from '../env.js';
3
4
  import logger from '../logger.js';
4
5
  import { Meta } from '../types/index.js';
5
6
  export function sanitizeQuery(rawQuery, accountability) {
6
7
  const query = {};
7
8
  if (rawQuery['limit'] !== undefined) {
8
- const limit = sanitizeLimit(rawQuery['limit']);
9
+ let limit = sanitizeLimit(rawQuery['limit']);
9
10
  if (typeof limit === 'number') {
11
+ const maxItemsPerQuery = env['MAX_ITEMS_PER_QUERY'];
12
+ if (maxItemsPerQuery !== -1 && limit === -1) {
13
+ limit = maxItemsPerQuery;
14
+ }
10
15
  query.limit = limit;
11
16
  }
12
17
  }
@@ -27,6 +27,9 @@ export function validateQuery(query) {
27
27
  if (query.alias) {
28
28
  validateAlias(query.alias);
29
29
  }
30
+ if (query.limit) {
31
+ validateLimit(query.limit);
32
+ }
30
33
  validateRelationalDepth(query);
31
34
  if (error) {
32
35
  throw new InvalidQueryException(error.message);
@@ -196,3 +199,9 @@ function validateRelationalDepth(query) {
196
199
  }
197
200
  }
198
201
  }
202
+ function validateLimit(limit) {
203
+ const maxItemsPerQuery = env['MAX_ITEMS_PER_QUERY'];
204
+ if (maxItemsPerQuery !== -1 && limit > maxItemsPerQuery) {
205
+ throw new InvalidQueryException(`"Limit" can't exceed ${maxItemsPerQuery}`);
206
+ }
207
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbce-d9/api",
3
- "version": "12.0.6",
3
+ "version": "12.2.0",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -111,7 +111,7 @@
111
111
  "knex": "3.1.0",
112
112
  "ldapjs": "2.3.3",
113
113
  "liquidjs": "10.7.0",
114
- "lodash-es": "4.17.21",
114
+ "lodash-es": "4.17.23",
115
115
  "marked": "4.3.0",
116
116
  "micromustache": "8.0.3",
117
117
  "mime-types": "2.1.35",
@@ -129,7 +129,7 @@
129
129
  "pino-http": "8.3.3",
130
130
  "pino-http-print": "3.1.0",
131
131
  "pino-pretty": "10.0.0",
132
- "qs": "6.11.1",
132
+ "qs": "6.14.1",
133
133
  "rate-limiter-flexible": "2.4.1",
134
134
  "rollup": "3.29.5",
135
135
  "samlify": "2.10.0",
@@ -143,20 +143,20 @@
143
143
  "uuid": "9.0.0",
144
144
  "uuid-validate": "0.0.3",
145
145
  "wellknown": "0.5.0",
146
- "@wbce-d9/app": "9.34.0",
147
146
  "@wbce-d9/constants": "10.0.0",
147
+ "@wbce-d9/app": "9.35.1",
148
148
  "@wbce-d9/exceptions": "10.0.0",
149
- "@wbce-d9/extensions-sdk": "10.0.0",
150
- "@wbce-d9/schema": "10.0.0",
151
- "@wbce-d9/storage": "10.0.0",
152
- "@wbce-d9/storage-driver-azure": "10.0.0",
153
- "@wbce-d9/storage-driver-cloudinary": "10.0.0",
149
+ "@wbce-d9/extensions-sdk": "10.0.2",
154
150
  "@wbce-d9/specs": "9.26.5",
155
- "@wbce-d9/storage-driver-gcs": "10.0.0",
156
- "@wbce-d9/storage-driver-local": "10.0.0",
157
- "@wbce-d9/storage-driver-s3": "10.0.0",
158
- "@wbce-d9/update-check": "10.0.0",
159
- "@wbce-d9/utils": "10.0.0"
151
+ "@wbce-d9/storage": "10.0.0",
152
+ "@wbce-d9/storage-driver-azure": "10.0.1",
153
+ "@wbce-d9/schema": "10.0.0",
154
+ "@wbce-d9/storage-driver-cloudinary": "10.0.1",
155
+ "@wbce-d9/storage-driver-local": "10.0.1",
156
+ "@wbce-d9/storage-driver-s3": "10.0.1",
157
+ "@wbce-d9/storage-driver-gcs": "10.0.1",
158
+ "@wbce-d9/update-check": "10.0.1",
159
+ "@wbce-d9/utils": "10.0.1"
160
160
  },
161
161
  "devDependencies": {
162
162
  "@ngneat/falso": "6.4.0",
@@ -204,7 +204,7 @@
204
204
  "supertest": "6.3.3",
205
205
  "typescript": "5.0.4",
206
206
  "vitest": "0.30.1",
207
- "@wbce-d9/types": "10.0.0"
207
+ "@wbce-d9/types": "10.1.0"
208
208
  },
209
209
  "optionalDependencies": {
210
210
  "@keyv/redis": "2.5.7",