UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

243 lines (233 loc) 13.9 kB
import { useEnv } from '@directus/env'; import { cloneDeep } from 'lodash-es'; import { applySort } from './apply-query/sort.js'; import { getCollectionFromAlias } from '../../../utils/get-collection-from-alias.js'; import { getColumn } from '../utils/get-column.js'; import { getHelpers } from '../../helpers/index.js'; import { applyCaseWhen } from '../utils/apply-case-when.js'; import { getColumnPreprocessor } from '../utils/get-column-pre-processor.js'; import { getNodeAlias } from '../utils/get-field-alias.js'; import { getInnerQueryColumnPreProcessor } from '../utils/get-inner-query-column-pre-processor.js'; import { withPreprocessBindings } from '../utils/with-preprocess-bindings.js'; import applyQuery, { generateAlias } from './apply-query/index.js'; import { applyLimit } from './apply-query/pagination.js'; export function getDBQuery({ table, fieldNodes, o2mNodes, query, cases, permissions, permissionsOnly }, { knex, schema }) { const aliasMap = Object.create(null); const env = useEnv(); const preProcess = getColumnPreprocessor(knex, schema, table, cases, permissions, aliasMap, permissionsOnly); const queryCopy = cloneDeep(query); const helpers = getHelpers(knex); const hasCaseWhen = o2mNodes.some((node) => node.whenCase && node.whenCase.length > 0) || fieldNodes.some((node) => node.whenCase && node.whenCase.length > 0); queryCopy.limit = typeof queryCopy.limit === 'number' ? queryCopy.limit : Number(env['QUERY_LIMIT_DEFAULT']); // Queries with aggregates and groupBy will not have duplicate result if (queryCopy.aggregate || queryCopy.group) { const flatQuery = knex.from(table); const fieldNodeMap = Object.fromEntries(fieldNodes.map((node, index) => [ node.fieldKey, [node, index], ])); const groupFieldNodes = queryCopy.group?.map((field) => fieldNodeMap[field][0]) ?? []; // Map the group fields to their respective field nodes const groupWhenCases = hasCaseWhen ? groupFieldNodes.map((node) => node.whenCase ?? []) : undefined; // Determine the number of aggregates that will be selected const aggregateCount = Object.entries(queryCopy.aggregate ?? {}).reduce((acc, [_, fields]) => acc + fields.length, 0); // Map the group field to their respective select column positions (1 based, offset by the number of aggregate terms that are applied in applyQuery) // The positions need to be offset by the number of aggregate terms, since the aggregate terms are selected first const groupColumnPositions = queryCopy.group?.map((field) => fieldNodeMap[field][1] + 1 + aggregateCount) ?? []; const dbQuery = applyQuery(knex, table, flatQuery, queryCopy, schema, cases, permissions, { aliasMap, groupWhenCases, groupColumnPositions, }).query; flatQuery.select(fieldNodes.map((node) => preProcess(node))); if (helpers.capabilities.supportsDeduplicationOfParameters() && !helpers.capabilities.supportsColumnPositionInGroupBy()) { withPreprocessBindings(knex, dbQuery); } return dbQuery; } const primaryKey = schema.collections[table].primary; const dbQuery = knex.from(table); let sortRecords; const innerQuerySortRecords = []; let hasMultiRelationalSort; if (queryCopy.sort) { const sortResult = applySort(knex, schema, dbQuery, queryCopy.sort, queryCopy.aggregate, table, aliasMap, true); if (sortResult) { sortRecords = sortResult.sortRecords; hasMultiRelationalSort = sortResult.hasMultiRelationalSort; } } const { hasMultiRelationalFilter } = applyQuery(knex, table, dbQuery, queryCopy, schema, cases, permissions, { aliasMap, isInnerQuery: true, hasMultiRelationalSort, }); const needsInnerQuery = hasMultiRelationalSort || hasMultiRelationalFilter; if (needsInnerQuery) { dbQuery.select(`${table}.${primaryKey}`); // Only add distinct if there are no case/when constructs, since otherwise we rely on group by if (!hasCaseWhen) dbQuery.distinct(); } else { dbQuery.select(fieldNodes.map((node) => preProcess(node))); // Add flags for o2m fields with case/when to the let the DB to the partial item permissions dbQuery.select(o2mNodes .filter((node) => node.whenCase && node.whenCase.length > 0) .map((node) => { const columnCases = node.whenCase.map((index) => cases[index]); return applyCaseWhen({ column: knex.raw(1), columnCases, aliasMap, cases, table, alias: node.fieldKey, permissions, }, { knex, schema }); })); } if (sortRecords) { // Clears the order if any, eg: from MSSQL offset dbQuery.clear('order'); if (needsInnerQuery) { let orderByString = ''; const orderByFields = []; sortRecords.map((sortRecord) => { if (orderByString.length !== 0) { orderByString += ', '; } const sortAlias = `sort_${generateAlias()}`; let orderByColumn; if (sortRecord.column.includes('.')) { const [alias, field] = sortRecord.column.split('.'); const originalCollectionName = getCollectionFromAlias(alias, aliasMap); dbQuery.select(getColumn(knex, alias, field, sortAlias, schema, { originalCollectionName })); orderByString += `?? ${sortRecord.order}`; orderByColumn = getColumn(knex, alias, field, false, schema, { originalCollectionName }); } else { dbQuery.select(getColumn(knex, table, sortRecord.column, sortAlias, schema)); orderByString += `?? ${sortRecord.order}`; orderByColumn = getColumn(knex, table, sortRecord.column, false, schema); } orderByFields.push(orderByColumn); innerQuerySortRecords.push({ alias: sortAlias, order: sortRecord.order, column: orderByColumn }); }); if (hasMultiRelationalSort) { dbQuery.rowNumber(knex.ref('directus_row_number').toQuery(), knex.raw(`partition by ?? order by ${orderByString}`, [`${table}.${primaryKey}`, ...orderByFields])); // Start order by with directus_row_number. The directus_row_number is derived from a window function that // is ordered by the sort fields within every primary key partition. That ensures that the result with the // row number = 1 is the top-most row of every partition, according to the selected sort fields. // Since the only relevant result is the first row of this partition, adding the directus_row_number to the // order by here ensures that all rows with a directus_row_number = 1 show up first in the inner query result, // and are correctly truncated by the limit, but not earlier. orderByString = `?? asc, ${orderByString}`; orderByFields.unshift(knex.ref('directus_row_number')); } dbQuery.orderByRaw(orderByString, orderByFields); } else { sortRecords.map((sortRecord) => { if (sortRecord.column.includes('.')) { const [alias, field] = sortRecord.column.split('.'); sortRecord.column = getColumn(knex, alias, field, false, schema, { originalCollectionName: getCollectionFromAlias(alias, aliasMap), }); } else { sortRecord.column = getColumn(knex, table, sortRecord.column, false, schema); } }); dbQuery.orderBy(sortRecords); } } if (!needsInnerQuery) return dbQuery; const innerCaseWhenAliasPrefix = generateAlias(); if (hasCaseWhen) { /* If there are cases, we need to employ a trick in order to evaluate the case/when structure in the inner query, while passing the result of the evaluation to the outer query. The case/when needs to be evaluated in the inner query since only there all joined in tables, that might be required for the case/when, are available. The problem is, that the resulting columns can not be directly selected in the inner query, as a `SELECT DISTINCT` does not work for all datatypes in all vendors. So instead of having an inner query which might look like this: SELECT DISTINCT ..., CASE WHEN <condition> THEN <actual-column> END AS <alias> a group-by query is generated. Another problem is that all not all rows with the same primary key are guaranteed to have the same value for the columns with the case/when, so we to `or` those together, but counting the number of flags in a group by operation. This way the flag is set to > 0 if any of the rows in the group allows access to the column. The inner query only evaluates the condition and passes up or-ed flag, that is used in the wrapper query to select the actual column: SELECT ..., COUNT (CASE WHEN <condition> THEN 1 END) AS <random-prefix>_<alias> ... GROUP BY <primary-key> Then, in the wrapper query there is no need to evaluate the condition again, but instead rely on the flag: SELECT ..., CASE WHEN `inner`.<random-prefix>_<alias> > 0 THEN <actual-column> END AS <alias> */ const innerPreprocess = getInnerQueryColumnPreProcessor(knex, schema, table, cases, permissions, aliasMap, innerCaseWhenAliasPrefix); // To optimize the query we avoid having unnecessary columns in the inner query, that don't have a caseWhen, since // they are selected in the outer query directly dbQuery.select(fieldNodes.map(innerPreprocess).filter((x) => x !== null)); // In addition to the regular columns select a flag that indicates if a user has access to o2m related field // based on the case/when of that field. dbQuery.select(o2mNodes.map(innerPreprocess).filter((x) => x !== null)); const groupByFields = [knex.raw('??.??', [table, primaryKey])]; // For some DB vendors sort fields need to be included in the group by clause, otherwise this causes problems those DBs // since sort fields are selected in the inner query, and they expect all selected columns to be in // the group by clause or aggregated over. // For some DBs the field needs to be the actual raw column expression, since aliases are not available in the // group by clause. // Since the fields are expected to be the same for a single primary key it is safe to include them in the // group by without influencing the result. // This inclusion depends on the DB vendor, as such it is handled in a dialect specific helper. helpers.schema.addInnerSortFieldsToGroupBy(groupByFields, innerQuerySortRecords, (hasMultiRelationalSort || sortRecords?.some(({ column }) => column.includes('.'))) ?? false); dbQuery.groupBy(groupByFields); } const wrapperQuery = knex .from(table) .innerJoin(knex.raw('??', dbQuery.as('inner')), `${table}.${primaryKey}`, `inner.${primaryKey}`); if (!hasCaseWhen) { // No need for case/when in the wrapper query, just select the preprocessed columns wrapperQuery.select(fieldNodes.map((node) => preProcess(node))); } else { // This applies a simplified case/when construct in the wrapper query, that only looks at flag > 1 // Distinguish between column with and without case/when and handle them differently const plainColumns = fieldNodes.filter((fieldNode) => !fieldNode.whenCase || fieldNode.whenCase.length === 0); const whenCaseColumns = fieldNodes.filter((fieldNode) => fieldNode.whenCase && fieldNode.whenCase.length > 0); // Select the plain columns wrapperQuery.select(plainColumns.map((node) => preProcess(node))); // Select the case/when columns based on the flag from the inner query wrapperQuery.select(whenCaseColumns.map((fieldNode) => { const alias = getNodeAlias(fieldNode); const innerAlias = `${innerCaseWhenAliasPrefix}_${alias}`; // Preprocess the column without the case/when, since that is applied in a simpler fashion in the select const column = preProcess({ ...fieldNode, whenCase: [] }, { noAlias: true }); return knex.raw(`CASE WHEN ??.?? > 0 THEN ?? END as ??`, ['inner', innerAlias, column, alias]); })); // Pass the flags of o2m fields up through the wrapper query wrapperQuery.select(o2mNodes .filter((node) => node.whenCase && node.whenCase.length > 0) .map((node) => { const alias = node.fieldKey; const innerAlias = `${innerCaseWhenAliasPrefix}_${alias}`; return knex.raw(`CASE WHEN ??.?? > 0 THEN 1 END as ??`, ['inner', innerAlias, alias]); })); } if (sortRecords) { innerQuerySortRecords.map((innerQuerySortRecord) => { wrapperQuery.orderBy(`inner.${innerQuerySortRecord.alias}`, innerQuerySortRecord.order); }); if (hasMultiRelationalSort) { wrapperQuery.where('inner.directus_row_number', '=', 1); applyLimit(knex, wrapperQuery, queryCopy.limit); } } return wrapperQuery; }