diff --git a/.changeset/strange-singers-protect.md b/.changeset/strange-singers-protect.md new file mode 100644 index 0000000000..d36af9589f --- /dev/null +++ b/.changeset/strange-singers-protect.md @@ -0,0 +1,8 @@ +--- +'@wbce-d9/types': minor +'@wbce-d9/api': minor +'@wbce-d9/app': minor +'tests-blackbox': patch +--- + +Add a way to define check constraints through api and web app diff --git a/api/src/database/migrations/20251204A-add-collections-check-filter.ts b/api/src/database/migrations/20251204A-add-collections-check-filter.ts new file mode 100644 index 0000000000..bee9112e76 --- /dev/null +++ b/api/src/database/migrations/20251204A-add-collections-check-filter.ts @@ -0,0 +1,13 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('directus_collections', (table) => { + table.json('check_filter').nullable(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('directus_collections', (table) => { + table.dropColumn('check_filter'); + }); +} diff --git a/api/src/database/system-data/fields/collections.yaml b/api/src/database/system-data/fields/collections.yaml index 8302bf0bbb..ad0013cdd7 100644 --- a/api/src/database/system-data/fields/collections.yaml +++ b/api/src/database/system-data/fields/collections.yaml @@ -212,3 +212,25 @@ fields: - field: collapse hidden: true + + - field: check_filter_divider + special: + - alias + - no-data + interface: presentation-divider + options: + icon: rule + title: $t:field_options.directus_collections.check_filter_divider + width: full + + - field: check_filter + special: + - cast-json + interface: system-filter + options: + collectionField: collection + collectionRequired: true + allowFieldComparison: true + includeRelations: false + relationalFieldSelectable: false + width: full diff --git a/api/src/services/collections.ts b/api/src/services/collections.ts index 8207cf269d..c1f3b2b85c 100644 --- a/api/src/services/collections.ts +++ b/api/src/services/collections.ts @@ -9,7 +9,7 @@ import { clearSystemCache, getCache } from '../cache.js'; import { ALIAS_TYPES } from '../constants.js'; import type { Helpers } from '../database/helpers/index.js'; import { getHelpers } from '../database/helpers/index.js'; -import getDatabase, { getSchemaInspector } from '../database/index.js'; +import getDatabase, { getDatabaseClient, getSchemaInspector } from '../database/index.js'; import { systemCollectionRows } from '../database/system-data/collections/index.js'; import emitter from '../emitter.js'; import env from '../env.js'; @@ -24,6 +24,8 @@ import type { MutationOptions, } from '../types/index.js'; import { getSchema } from '../utils/get-schema.js'; +import { applyCollectionCheckConstraint } from '../utils/check-constraints.js'; +import logger from '../logger.js'; export type RawCollection = { collection: string; @@ -389,10 +391,24 @@ export class CollectionsService { .first()); if (exists) { - await collectionItemsService.updateOne(collectionKey, payload.meta, { - ...opts, - bypassEmitAction: (params) => - opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params), + await collectionItemsService.knex.transaction(async (tsx) => { + collectionItemsService.knex = tsx; + + if (payload.meta?.check_filter !== undefined) { + const client = getDatabaseClient(); + + if (client === 'postgres') { + await applyCollectionCheckConstraint(tsx, collectionKey, payload.meta?.check_filter, this.schema); + } else { + logger.warn(`Check constraints are only enforced for postgres database.`); + } + } + + await collectionItemsService.updateOne(collectionKey, payload.meta as CollectionMeta, { + ...opts, + bypassEmitAction: (params) => + opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params), + }); }); } else { await collectionItemsService.createOne( diff --git a/api/src/types/collection.ts b/api/src/types/collection.ts index 69984d897f..b48a6eca73 100644 --- a/api/src/types/collection.ts +++ b/api/src/types/collection.ts @@ -11,6 +11,7 @@ export type CollectionMeta = { item_duplication_fields: string[] | null; accountability: 'all' | 'accountability' | null; group: string | null; + check_filter?: Record | null; }; export type Collection = { diff --git a/api/src/utils/apply-query.ts b/api/src/utils/apply-query.ts index fd251ec2b4..c1696378ed 100644 --- a/api/src/utils/apply-query.ts +++ b/api/src/utils/apply-query.ts @@ -343,12 +343,11 @@ export function applyFilter( collection: string, aliasMap: AliasMap ) { - const helpers = getHelpers(knex); const relations: Relation[] = schema.relations; let hasMultiRelationalFilter = false; addJoins(rootQuery, rootFilter, collection); - addWhereClauses(knex, rootQuery, rootFilter, collection); + addWhereClauses(knex, schema, rootQuery, rootFilter, collection, aliasMap); return { query: rootQuery, hasMultiRelationalFilter }; @@ -389,364 +388,365 @@ export function applyFilter( } } } +} - function addWhereClauses( - knex: Knex, - dbQuery: Knex.QueryBuilder, - filter: Filter, - collection: string, - logical: 'and' | 'or' = 'and' - ) { - for (const [key, value] of Object.entries(filter)) { - if (key === '_or' || key === '_and') { - // If the _or array contains an empty object (full permissions), we should short-circuit and ignore all other - // permission checks, as {} already matches full permissions. - if (key === '_or' && value.some((subFilter: Record) => Object.keys(subFilter).length === 0)) { - continue; - } - - /** @NOTE this callback function isn't called until Knex runs the query */ - dbQuery[logical].where((subQuery) => { - value.forEach((subFilter: Record) => { - addWhereClauses(knex, subQuery, subFilter, collection, key === '_and' ? 'and' : 'or'); - }); - }); +export function addWhereClauses( + knex: Knex, + schema: SchemaOverview, + dbQuery: Knex.QueryBuilder, + filter: Filter, + collection: string, + aliasMap: AliasMap, + logical: 'and' | 'or' = 'and' +) { + const helpers = getHelpers(knex); + const relations: Relation[] = schema.relations; + for (const [key, value] of Object.entries(filter)) { + if (key === '_or' || key === '_and') { + // If the _or array contains an empty object (full permissions), we should short-circuit and ignore all other + // permission checks, as {} already matches full permissions. + if (key === '_or' && value.some((subFilter: Record) => Object.keys(subFilter).length === 0)) { continue; } - const filterPath = getFilterPath(key, value); + /** @NOTE this callback function isn't called until Knex runs the query */ + dbQuery[logical].where((subQuery) => { + value.forEach((subFilter: Record) => { + addWhereClauses(knex, schema, subQuery, subFilter, collection, aliasMap, key === '_and' ? 'and' : 'or'); + }); + }); - /** - * For A2M fields, the path can contain an optional collection scope : - */ - const pathRoot = filterPath[0]!.split(':')[0]!; + continue; + } - const { relation, relationType } = getRelationInfo(relations, collection, pathRoot); + const filterPath = getFilterPath(key, value); - const { operator: filterOperator, value: filterValue } = getOperation(key, value); + /** + * For A2M fields, the path can contain an optional collection scope : + */ + const pathRoot = filterPath[0]!.split(':')[0]!; - if ( - filterPath.length > 1 || - (!(key.includes('(') && key.includes(')')) && schema.collections[collection]!.fields[key]!.type === 'alias') - ) { - if (!relation) continue; - - if (relationType === 'o2m' || relationType === 'o2a') { - let pkField: Knex.Raw | string = `${collection}.${ - schema.collections[relation!.related_collection!]!.primary - }`; - - if (relationType === 'o2a') { - pkField = knex.raw(getHelpers(knex).schema.castA2oPrimaryKey(), [pkField]); - } - - const subQueryBuilder = (filter: Filter) => (subQueryKnex: Knex.QueryBuilder) => { - const field = relation!.field; - const collection = relation!.collection; - const column = `${collection}.${field}`; - - subQueryKnex - .select({ [field]: column }) - .from(collection) - .whereNotNull(column); - - applyQuery(knex, relation!.collection, subQueryKnex, { filter }, schema); - }; - - const childKey = Object.keys(value)?.[0]; - - if (childKey === '_none') { - dbQuery[logical].whereNotIn(pkField as string, subQueryBuilder(Object.values(value)[0] as Filter)); - continue; - } else if (childKey === '_some') { - dbQuery[logical].whereIn(pkField as string, subQueryBuilder(Object.values(value)[0] as Filter)); - continue; - } - } + const { relation, relationType } = getRelationInfo(relations, collection, pathRoot); - if (filterPath.includes('_none') || filterPath.includes('_some')) { - throw new InvalidQueryException( - `"${ - filterPath.includes('_none') ? '_none' : '_some' - }" can only be used with top level relational alias field` - ); - } + const { operator: filterOperator, value: filterValue } = getOperation(key, value); - const { columnPath, targetCollection, addNestedPkField } = getColumnPath({ - path: filterPath, - collection, - relations, - aliasMap, - schema, - }); + if ( + filterPath.length > 1 || + (!(key.includes('(') && key.includes(')')) && schema.collections[collection]!.fields[key]!.type === 'alias') + ) { + if (!relation) continue; + + if (relationType === 'o2m' || relationType === 'o2a') { + let pkField: Knex.Raw | string = `${collection}.${ + schema.collections[relation!.related_collection!]!.primary + }`; - if (addNestedPkField) { - filterPath.push(addNestedPkField); + if (relationType === 'o2a') { + pkField = knex.raw(getHelpers(knex).schema.castA2oPrimaryKey(), [pkField]); } - if (!columnPath) continue; + const subQueryBuilder = (filter: Filter) => (subQueryKnex: Knex.QueryBuilder) => { + const field = relation!.field; + const collection = relation!.collection; + const column = `${collection}.${field}`; - const { type, special } = validateFilterField( - schema.collections[targetCollection]!.fields, - stripFunction(filterPath[filterPath.length - 1]!), - targetCollection - )!; + subQueryKnex + .select({ [field]: column }) + .from(collection) + .whereNotNull(column); - validateFilterOperator(type, filterOperator, special); + applyQuery(knex, relation!.collection, subQueryKnex, { filter }, schema); + }; - applyFilterToQuery(columnPath, filterOperator, filterValue, logical, targetCollection); - } else { - const { type, special } = validateFilterField( - schema.collections[collection]!.fields, - stripFunction(filterPath[0]!), - collection - )!; + const childKey = Object.keys(value)?.[0]; - validateFilterOperator(type, filterOperator, special); + if (childKey === '_none') { + dbQuery[logical].whereNotIn(pkField as string, subQueryBuilder(Object.values(value)[0] as Filter)); + continue; + } else if (childKey === '_some') { + dbQuery[logical].whereIn(pkField as string, subQueryBuilder(Object.values(value)[0] as Filter)); + continue; + } + } - applyFilterToQuery(`${collection}.${filterPath[0]}`, filterOperator, filterValue, logical); + if (filterPath.includes('_none') || filterPath.includes('_some')) { + throw new InvalidQueryException( + `"${filterPath.includes('_none') ? '_none' : '_some'}" can only be used with top level relational alias field` + ); } - } - function validateFilterField(fields: Record, key: string, collection = 'unknown') { - if (fields[key] === undefined) { - throw new InvalidQueryException(`Invalid filter key "${key}" on "${collection}"`); + const { columnPath, targetCollection, addNestedPkField } = getColumnPath({ + path: filterPath, + collection, + relations, + aliasMap, + schema, + }); + + if (addNestedPkField) { + filterPath.push(addNestedPkField); } - return fields[key]; + if (!columnPath) continue; + + const { type, special } = validateFilterField( + schema.collections[targetCollection]!.fields, + stripFunction(filterPath[filterPath.length - 1]!), + targetCollection + )!; + + validateFilterOperator(type, filterOperator, special); + + applyFilterToQuery(columnPath, filterOperator, filterValue, logical, targetCollection); + } else { + const { type, special } = validateFilterField( + schema.collections[collection]!.fields, + stripFunction(filterPath[0]!), + collection + )!; + + validateFilterOperator(type, filterOperator, special); + + applyFilterToQuery(`${collection}.${filterPath[0]}`, filterOperator, filterValue, logical); } + } - function validateFilterOperator(type: Type, filterOperator: string, special: string[]) { - if (filterOperator.startsWith('_')) { - filterOperator = filterOperator.slice(1); - } + function validateFilterField(fields: Record, key: string, collection = 'unknown') { + if (fields[key] === undefined) { + throw new InvalidQueryException(`Invalid filter key "${key}" on "${collection}"`); + } - if (!getFilterOperatorsForType(type).includes(filterOperator as ClientFilterOperator)) { - throw new InvalidQueryException( - `"${type}" field type does not contain the "_${filterOperator}" filter operator` - ); - } + return fields[key]; + } - if ( - special.includes('conceal') && - !getFilterOperatorsForType('hash').includes(filterOperator as ClientFilterOperator) - ) { - throw new InvalidQueryException( - `Field with "conceal" special does not allow the "_${filterOperator}" filter operator` - ); - } + function validateFilterOperator(type: Type, filterOperator: string, special: string[]) { + if (filterOperator.startsWith('_')) { + filterOperator = filterOperator.slice(1); + } + + if (!getFilterOperatorsForType(type).includes(filterOperator as ClientFilterOperator)) { + throw new InvalidQueryException(`"${type}" field type does not contain the "_${filterOperator}" filter operator`); } - function applyFilterToQuery( - key: string, - operator: string, - compareValue: any, - logical: 'and' | 'or' = 'and', - originalCollectionName?: string + if ( + special.includes('conceal') && + !getFilterOperatorsForType('hash').includes(filterOperator as ClientFilterOperator) ) { - const [table, column] = key.split('.'); + throw new InvalidQueryException( + `Field with "conceal" special does not allow the "_${filterOperator}" filter operator` + ); + } + } - // Is processed through Knex.Raw, so should be safe to string-inject into these where queries - const selectionRaw = getColumn(knex, table!, column!, false, schema, { originalCollectionName }) as any; + function applyFilterToQuery( + key: string, + operator: string, + compareValue: any, + logical: 'and' | 'or' = 'and', + originalCollectionName?: string + ) { + const [table, column] = key.split('.'); - // Knex supports "raw" in the columnName parameter, but isn't typed as such. Too bad.. - // See https://github.com/knex/knex/issues/4518 @TODO remove as any once knex is updated + // Is processed through Knex.Raw, so should be safe to string-inject into these where queries + const selectionRaw = getColumn(knex, table!, column!, false, schema, { originalCollectionName }) as any; - // These operators don't rely on a value, and can thus be used without one (eg `?filter[field][_null]`) - if (operator === '_null' || (operator === '_nnull' && compareValue === false)) { - dbQuery[logical].whereNull(selectionRaw); - } + // Knex supports "raw" in the columnName parameter, but isn't typed as such. Too bad.. + // See https://github.com/knex/knex/issues/4518 @TODO remove as any once knex is updated - if (operator === '_nnull' || (operator === '_null' && compareValue === false)) { - dbQuery[logical].whereNotNull(selectionRaw); - } + // These operators don't rely on a value, and can thus be used without one (eg `?filter[field][_null]`) + if (operator === '_null' || (operator === '_nnull' && compareValue === false)) { + dbQuery[logical].whereNull(selectionRaw); + } - if (operator === '_empty' || (operator === '_nempty' && compareValue === false)) { - dbQuery[logical].andWhere((query) => { - query.whereNull(key).orWhere(key, '=', ''); - }); - } + if (operator === '_nnull' || (operator === '_null' && compareValue === false)) { + dbQuery[logical].whereNotNull(selectionRaw); + } - if (operator === '_nempty' || (operator === '_empty' && compareValue === false)) { - dbQuery[logical].andWhere((query) => { - query.whereNotNull(key).andWhere(key, '!=', ''); - }); - } + if (operator === '_empty' || (operator === '_nempty' && compareValue === false)) { + dbQuery[logical].andWhere((query) => { + query.whereNull(key).orWhere(key, '=', ''); + }); + } - // The following fields however, require a value to be run. If no value is passed, we - // ignore them. This allows easier use in GraphQL, where you wouldn't be able to - // conditionally build out your filter structure (#4471) - if (compareValue === undefined) return; + if (operator === '_nempty' || (operator === '_empty' && compareValue === false)) { + dbQuery[logical].andWhere((query) => { + query.whereNotNull(key).andWhere(key, '!=', ''); + }); + } - if (Array.isArray(compareValue)) { - // Tip: when using a `[Type]` type in GraphQL, but don't provide the variable, it'll be - // reported as [undefined]. - // We need to remove any undefined values, as they are useless - compareValue = compareValue.filter((val) => val !== undefined); - } + // The following fields however, require a value to be run. If no value is passed, we + // ignore them. This allows easier use in GraphQL, where you wouldn't be able to + // conditionally build out your filter structure (#4471) + if (compareValue === undefined) return; - // Cast filter value (compareValue) based on function used - if (column!.includes('(') && column!.includes(')')) { - const functionName = column!.split('(')[0] as FieldFunction; - const type = getOutputTypeForFunction(functionName); + if (Array.isArray(compareValue)) { + // Tip: when using a `[Type]` type in GraphQL, but don't provide the variable, it'll be + // reported as [undefined]. + // We need to remove any undefined values, as they are useless + compareValue = compareValue.filter((val) => val !== undefined); + } - if (['bigInteger', 'integer', 'float', 'decimal'].includes(type)) { - compareValue = Number(compareValue); - } + // Cast filter value (compareValue) based on function used + if (column!.includes('(') && column!.includes(')')) { + const functionName = column!.split('(')[0] as FieldFunction; + const type = getOutputTypeForFunction(functionName); + + if (['bigInteger', 'integer', 'float', 'decimal'].includes(type)) { + compareValue = Number(compareValue); } + } - // Cast filter value (compareValue) based on type of field being filtered against - const [collection, field] = key.split('.'); - const mappedCollection = (originalCollectionName || collection)!; + // Cast filter value (compareValue) based on type of field being filtered against + const [collection, field] = key.split('.'); + const mappedCollection = (originalCollectionName || collection)!; - if (mappedCollection! in schema.collections && field! in schema.collections[mappedCollection]!.fields) { - const type = schema.collections[mappedCollection]!.fields[field!]!.type; + if (mappedCollection! in schema.collections && field! in schema.collections[mappedCollection]!.fields) { + const type = schema.collections[mappedCollection]!.fields[field!]!.type; - if (['date', 'dateTime', 'time', 'timestamp'].includes(type)) { - if (Array.isArray(compareValue)) { - compareValue = compareValue.map((val) => helpers.date.parse(val)); - } else { - compareValue = helpers.date.parse(compareValue); - } + if (['date', 'dateTime', 'time', 'timestamp'].includes(type)) { + if (Array.isArray(compareValue)) { + compareValue = compareValue.map((val) => helpers.date.parse(val)); + } else { + compareValue = helpers.date.parse(compareValue); } + } - if (['bigInteger', 'integer', 'float', 'decimal'].includes(type)) { - if (Array.isArray(compareValue)) { - compareValue = compareValue.map((val) => Number(val)); - } else { - compareValue = Number(compareValue); - } + if (['bigInteger', 'integer', 'float', 'decimal'].includes(type)) { + if (Array.isArray(compareValue)) { + compareValue = compareValue.map((val) => Number(val)); + } else { + compareValue = Number(compareValue); } } + } - if (operator === '_eq') { - dbQuery[logical].where(selectionRaw, '=', compareValue); - } + if (operator === '_eq') { + dbQuery[logical].where(selectionRaw, '=', compareValue); + } - if (operator === '_neq') { - dbQuery[logical].whereNot(selectionRaw, compareValue); - } + if (operator === '_neq') { + dbQuery[logical].whereNot(selectionRaw, compareValue); + } - if (operator === '_ieq') { - dbQuery[logical].whereRaw(`LOWER(??) = ?`, [selectionRaw, `${compareValue.toLowerCase()}`]); - } + if (operator === '_ieq') { + dbQuery[logical].whereRaw(`LOWER(??) = ?`, [selectionRaw, `${compareValue.toLowerCase()}`]); + } - if (operator === '_nieq') { - dbQuery[logical].whereRaw(`LOWER(??) <> ?`, [selectionRaw, `${compareValue.toLowerCase()}`]); - } + if (operator === '_nieq') { + dbQuery[logical].whereRaw(`LOWER(??) <> ?`, [selectionRaw, `${compareValue.toLowerCase()}`]); + } - if (operator === '_contains') { - dbQuery[logical].where(selectionRaw, 'like', `%${compareValue}%`); - } + if (operator === '_contains') { + dbQuery[logical].where(selectionRaw, 'like', `%${compareValue}%`); + } - if (operator === '_ncontains') { - dbQuery[logical].whereNot(selectionRaw, 'like', `%${compareValue}%`); - } + if (operator === '_ncontains') { + dbQuery[logical].whereNot(selectionRaw, 'like', `%${compareValue}%`); + } - if (operator === '_icontains') { - dbQuery[logical].whereRaw(`LOWER(??) LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}%`]); - } + if (operator === '_icontains') { + dbQuery[logical].whereRaw(`LOWER(??) LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}%`]); + } - if (operator === '_nicontains') { - dbQuery[logical].whereRaw(`LOWER(??) NOT LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}%`]); - } + if (operator === '_nicontains') { + dbQuery[logical].whereRaw(`LOWER(??) NOT LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}%`]); + } - if (operator === '_starts_with') { - dbQuery[logical].where(key, 'like', `${compareValue}%`); - } + if (operator === '_starts_with') { + dbQuery[logical].where(key, 'like', `${compareValue}%`); + } - if (operator === '_nstarts_with') { - dbQuery[logical].whereNot(key, 'like', `${compareValue}%`); - } + if (operator === '_nstarts_with') { + dbQuery[logical].whereNot(key, 'like', `${compareValue}%`); + } - if (operator === '_istarts_with') { - dbQuery[logical].whereRaw(`LOWER(??) LIKE ?`, [selectionRaw, `${compareValue.toLowerCase()}%`]); - } + if (operator === '_istarts_with') { + dbQuery[logical].whereRaw(`LOWER(??) LIKE ?`, [selectionRaw, `${compareValue.toLowerCase()}%`]); + } - if (operator === '_nistarts_with') { - dbQuery[logical].whereRaw(`LOWER(??) NOT LIKE ?`, [selectionRaw, `${compareValue.toLowerCase()}%`]); - } + if (operator === '_nistarts_with') { + dbQuery[logical].whereRaw(`LOWER(??) NOT LIKE ?`, [selectionRaw, `${compareValue.toLowerCase()}%`]); + } - if (operator === '_ends_with') { - dbQuery[logical].where(key, 'like', `%${compareValue}`); - } + if (operator === '_ends_with') { + dbQuery[logical].where(key, 'like', `%${compareValue}`); + } - if (operator === '_nends_with') { - dbQuery[logical].whereNot(key, 'like', `%${compareValue}`); - } + if (operator === '_nends_with') { + dbQuery[logical].whereNot(key, 'like', `%${compareValue}`); + } - if (operator === '_iends_with') { - dbQuery[logical].whereRaw(`LOWER(??) LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}`]); - } + if (operator === '_iends_with') { + dbQuery[logical].whereRaw(`LOWER(??) LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}`]); + } - if (operator === '_niends_with') { - dbQuery[logical].whereRaw(`LOWER(??) NOT LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}`]); - } + if (operator === '_niends_with') { + dbQuery[logical].whereRaw(`LOWER(??) NOT LIKE ?`, [selectionRaw, `%${compareValue.toLowerCase()}`]); + } - if (operator === '_gt') { - dbQuery[logical].where(selectionRaw, '>', compareValue); - } + if (operator === '_gt') { + dbQuery[logical].where(selectionRaw, '>', compareValue); + } - if (operator === '_gte') { - dbQuery[logical].where(selectionRaw, '>=', compareValue); - } + if (operator === '_gte') { + dbQuery[logical].where(selectionRaw, '>=', compareValue); + } - if (operator === '_lt') { - dbQuery[logical].where(selectionRaw, '<', compareValue); - } + if (operator === '_lt') { + dbQuery[logical].where(selectionRaw, '<', compareValue); + } - if (operator === '_lte') { - dbQuery[logical].where(selectionRaw, '<=', compareValue); - } + if (operator === '_lte') { + dbQuery[logical].where(selectionRaw, '<=', compareValue); + } - if (operator === '_in') { - let value = compareValue; - if (typeof value === 'string') value = value.split(','); + if (operator === '_in') { + let value = compareValue; + if (typeof value === 'string') value = value.split(','); - dbQuery[logical].whereIn(selectionRaw, value as string[]); - } + dbQuery[logical].whereIn(selectionRaw, value as string[]); + } - if (operator === '_nin') { - let value = compareValue; - if (typeof value === 'string') value = value.split(','); + if (operator === '_nin') { + let value = compareValue; + if (typeof value === 'string') value = value.split(','); - dbQuery[logical].whereNotIn(selectionRaw, value as string[]); - } + dbQuery[logical].whereNotIn(selectionRaw, value as string[]); + } - if (operator === '_between') { - if (compareValue.length !== 2) return; + if (operator === '_between') { + if (compareValue.length !== 2) return; - let value = compareValue; - if (typeof value === 'string') value = value.split(','); + let value = compareValue; + if (typeof value === 'string') value = value.split(','); - dbQuery[logical].whereBetween(selectionRaw, value); - } + dbQuery[logical].whereBetween(selectionRaw, value); + } - if (operator === '_nbetween') { - if (compareValue.length !== 2) return; + if (operator === '_nbetween') { + if (compareValue.length !== 2) return; - let value = compareValue; - if (typeof value === 'string') value = value.split(','); + let value = compareValue; + if (typeof value === 'string') value = value.split(','); - dbQuery[logical].whereNotBetween(selectionRaw, value); - } + dbQuery[logical].whereNotBetween(selectionRaw, value); + } - if (operator == '_intersects') { - dbQuery[logical].whereRaw(helpers.st.intersects(key, compareValue)); - } + if (operator == '_intersects') { + dbQuery[logical].whereRaw(helpers.st.intersects(key, compareValue)); + } - if (operator == '_nintersects') { - dbQuery[logical].whereRaw(helpers.st.nintersects(key, compareValue)); - } + if (operator == '_nintersects') { + dbQuery[logical].whereRaw(helpers.st.nintersects(key, compareValue)); + } - if (operator == '_intersects_bbox') { - dbQuery[logical].whereRaw(helpers.st.intersects_bbox(key, compareValue)); - } + if (operator == '_intersects_bbox') { + dbQuery[logical].whereRaw(helpers.st.intersects_bbox(key, compareValue)); + } - if (operator == '_nintersects_bbox') { - dbQuery[logical].whereRaw(helpers.st.nintersects_bbox(key, compareValue)); - } + if (operator == '_nintersects_bbox') { + dbQuery[logical].whereRaw(helpers.st.nintersects_bbox(key, compareValue)); } } } diff --git a/api/src/utils/check-constraints.ts b/api/src/utils/check-constraints.ts new file mode 100644 index 0000000000..657152ac1d --- /dev/null +++ b/api/src/utils/check-constraints.ts @@ -0,0 +1,64 @@ +import type { Filter, SchemaOverview } from '@wbce-d9/types'; +import type { Knex } from 'knex'; +import { addWhereClauses } from './apply-query.js'; +import { cloneDeep } from 'lodash-es'; + +export async function formulateCheckClause(knex: Knex, collection: string, filter: Filter, schema: SchemaOverview) { + /** + * Strategy : + * With filter, we are able to formulate a where statement but not a check statement. + * So we formulate the where statement that we will then place in a check statement. + * Also, with filter, right assignment of operator are always values (simple ?) in the final query + * With the comparaison syntax of check constraint, we want to be able to have right assignent of field + * e.g. : filter = {date_end : {_gt : "$FIELD(date_start)" }} should transform into "?? > ??" + * To ensure this : + * - we formulate the where clause + * - we change every simple ? to a double ?? when the "?" is for a binding of form $FIELD(...) + */ + const cloneFilter = cloneDeep(filter); + const queryBuilder = knex.queryBuilder(); + addWhereClauses(knex, schema, queryBuilder, cloneFilter, collection, Object.create(null)); + const sqlQuery = queryBuilder.toSQL(); + let bindIndex = 0; + const checkBindings: any[] = []; + + const whereClause = sqlQuery.sql.replace(/\?+/g, function (value) { + const bind = sqlQuery.bindings[bindIndex]; + const fieldConstraint = bind?.toString().match(/^\$FIELD\((.*)\)$/i)?.[1]; + let result; + + if (fieldConstraint !== undefined) { + result = '??'; + checkBindings.push(fieldConstraint); + } else { + result = value; + checkBindings.push(bind); + } + + bindIndex++; + return result; + }); + + const whereString = knex.raw(whereClause, checkBindings).toQuery(); + let checkClause = whereString.match(/where\s+(.+)/i)![1]!; + checkClause = checkClause.replaceAll("?", "\\?") + + await knex.schema.table(collection, (table) => { + table.check(checkClause, undefined, 'directus_constraint'); + }); +} + +export async function applyCollectionCheckConstraint( + knex: Knex, + collection: string, + filter: Filter | null, + schema: SchemaOverview +): Promise { + await knex.raw(`ALTER TABLE ?? DROP CONSTRAINT IF EXISTS "directus_constraint"`, [collection]); + + if (!filter || Object.keys(filter).length === 0) { + return; + } + + await formulateCheckClause(knex, collection, filter, schema); +} diff --git a/api/src/utils/get-schema.ts b/api/src/utils/get-schema.ts index 19ac03945e..dcafdece38 100644 --- a/api/src/utils/get-schema.ts +++ b/api/src/utils/get-schema.ts @@ -66,7 +66,7 @@ async function getDatabaseSchema(database: Knex, schemaInspector: SchemaInspecto const collections = [ ...(await database - .select('collection', 'singleton', 'note', 'sort_field', 'accountability') + .select('collection', 'singleton', 'note', 'sort_field', 'accountability', 'check_filter') .from('directus_collections')), ...systemCollectionRows, ]; @@ -97,6 +97,7 @@ async function getDatabaseSchema(database: Knex, schemaInspector: SchemaInspecto note: collectionMeta?.note || null, sortField: collectionMeta?.sort_field || null, accountability: collectionMeta ? collectionMeta.accountability : 'all', + check_filter: (collectionMeta as any)?.check_filter || null, fields: mapValues(schemaOverview[collection]?.columns, (column) => { return { field: column.column_name, diff --git a/app/src/interfaces/_system/system-filter/field-or-value-input.vue b/app/src/interfaces/_system/system-filter/field-or-value-input.vue new file mode 100644 index 0000000000..cdb965317f --- /dev/null +++ b/app/src/interfaces/_system/system-filter/field-or-value-input.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/app/src/interfaces/_system/system-filter/input-group.vue b/app/src/interfaces/_system/system-filter/input-group.vue index f00e11b394..1dc883a9b8 100644 --- a/app/src/interfaces/_system/system-filter/input-group.vue +++ b/app/src/interfaces/_system/system-filter/input-group.vue @@ -1,68 +1,94 @@