@wbce-d9/api 12.0.6 → 12.1.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/database/migrations/20251204A-add-collections-check-filter.d.ts +3 -0
- package/dist/database/migrations/20251204A-add-collections-check-filter.js +10 -0
- package/dist/database/system-data/fields/collections.yaml +22 -0
- package/dist/env.js +4 -0
- package/dist/services/collections.js +18 -4
- package/dist/types/collection.d.ts +1 -0
- package/dist/utils/apply-query.d.ts +1 -0
- package/dist/utils/apply-query.js +246 -245
- package/dist/utils/check-constraints.d.ts +4 -0
- package/dist/utils/check-constraints.js +49 -0
- package/dist/utils/get-schema.js +2 -1
- package/dist/utils/sanitize-query.js +6 -1
- package/dist/utils/validate-query.js +9 -0
- package/package.json +11 -11
|
@@ -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,8 @@ const allowedEnvironmentVars = [
|
|
|
26
26
|
'MAX_BATCH_MUTATION',
|
|
27
27
|
'LOGGER_.+',
|
|
28
28
|
'ROBOTS_TXT',
|
|
29
|
+
'MAX_RELATIONAL_DEPTH',
|
|
30
|
+
'MAX_ITEMS_PER_QUERY',
|
|
29
31
|
// server
|
|
30
32
|
'SERVER_.+',
|
|
31
33
|
// database
|
|
@@ -204,6 +206,7 @@ const defaults = {
|
|
|
204
206
|
PUBLIC_URL: '/',
|
|
205
207
|
MAX_PAYLOAD_SIZE: '1mb',
|
|
206
208
|
MAX_RELATIONAL_DEPTH: 10,
|
|
209
|
+
MAX_ITEMS_PER_QUERY: -1,
|
|
207
210
|
MAX_BATCH_MUTATION: Infinity,
|
|
208
211
|
ROBOTS_TXT: 'User-agent: *\nDisallow: /',
|
|
209
212
|
DB_EXCLUDE_TABLES: 'spatial_ref_sys,sysdiagrams',
|
|
@@ -294,6 +297,7 @@ const typeMap = {
|
|
|
294
297
|
GRAPHQL_INTROSPECTION: 'boolean',
|
|
295
298
|
MAX_BATCH_MUTATION: 'number',
|
|
296
299
|
SERVER_SHUTDOWN_TIMEOUT: 'number',
|
|
300
|
+
MAX_ITEMS_PER_QUERY: 'number',
|
|
297
301
|
};
|
|
298
302
|
let env = {
|
|
299
303
|
...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.
|
|
304
|
-
|
|
305
|
-
|
|
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 {
|
|
@@ -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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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 (
|
|
265
|
+
else if (childKey === '_some') {
|
|
266
|
+
dbQuery[logical].whereIn(pkField, subQueryBuilder(Object.values(value)[0]));
|
|
282
267
|
continue;
|
|
283
|
-
|
|
284
|
-
validateFilterOperator(type, filterOperator, special);
|
|
285
|
-
applyFilterToQuery(columnPath, filterOperator, filterValue, logical, targetCollection);
|
|
268
|
+
}
|
|
286
269
|
}
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
+
}
|
package/dist/utils/get-schema.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
3
|
+
"version": "12.1.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": [
|
|
@@ -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.
|
|
132
|
+
"qs": "6.14.0",
|
|
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
|
-
"@wbce-d9/constants": "10.0.0",
|
|
148
146
|
"@wbce-d9/exceptions": "10.0.0",
|
|
149
|
-
"@wbce-d9/
|
|
147
|
+
"@wbce-d9/app": "9.35.0",
|
|
148
|
+
"@wbce-d9/extensions-sdk": "10.0.1",
|
|
149
|
+
"@wbce-d9/constants": "10.0.0",
|
|
150
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",
|
|
154
151
|
"@wbce-d9/specs": "9.26.5",
|
|
152
|
+
"@wbce-d9/storage": "10.0.0",
|
|
155
153
|
"@wbce-d9/storage-driver-gcs": "10.0.0",
|
|
154
|
+
"@wbce-d9/storage-driver-azure": "10.0.0",
|
|
156
155
|
"@wbce-d9/storage-driver-local": "10.0.0",
|
|
157
156
|
"@wbce-d9/storage-driver-s3": "10.0.0",
|
|
158
|
-
"@wbce-d9/update-check": "10.0.
|
|
159
|
-
"@wbce-d9/utils": "10.0.0"
|
|
157
|
+
"@wbce-d9/update-check": "10.0.1",
|
|
158
|
+
"@wbce-d9/utils": "10.0.0",
|
|
159
|
+
"@wbce-d9/storage-driver-cloudinary": "10.0.0"
|
|
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.
|
|
207
|
+
"@wbce-d9/types": "10.1.0"
|
|
208
208
|
},
|
|
209
209
|
"optionalDependencies": {
|
|
210
210
|
"@keyv/redis": "2.5.7",
|