diff --git a/api/src/constants.ts b/api/src/constants.ts index 81bd626842..c6eb6bde6c 100644 --- a/api/src/constants.ts +++ b/api/src/constants.ts @@ -41,3 +41,7 @@ export const ASSET_TRANSFORM_QUERY_KEYS = ['key', 'width', 'height', 'fit', 'wit export const FILTER_VARIABLES = ['$NOW', '$CURRENT_USER', '$CURRENT_ROLE']; export const ALIAS_TYPES = ['alias', 'o2m', 'm2m', 'm2a', 'files', 'files', 'translations']; + +export const COLUMN_TRANSFORMS = ['year', 'month', 'day', 'weekday', 'hour', 'minute', 'second']; + +export const REGEX_BETWEEN_PARENS = /\(([^)]+)\)/; diff --git a/api/src/database/functions/base.ts b/api/src/database/functions/base.ts new file mode 100644 index 0000000000..22160987da --- /dev/null +++ b/api/src/database/functions/base.ts @@ -0,0 +1,12 @@ +import { Knex } from 'knex'; + +export interface IBaseHelper { + year(table: string, column: string): Knex.Raw; + month(table: string, column: string): Knex.Raw; + week(table: string, column: string): Knex.Raw; + day(table: string, column: string): Knex.Raw; + weekday(table: string, column: string): Knex.Raw; + hour(table: string, column: string): Knex.Raw; + minute(table: string, column: string): Knex.Raw; + second(table: string, column: string): Knex.Raw; +} diff --git a/api/src/database/functions/dialects/postgres.ts b/api/src/database/functions/dialects/postgres.ts new file mode 100644 index 0000000000..76d057cfb8 --- /dev/null +++ b/api/src/database/functions/dialects/postgres.ts @@ -0,0 +1,42 @@ +import { Knex } from 'knex'; +import { IBaseHelper } from '../base'; + +export class HelperPostgres implements IBaseHelper { + private knex: Knex; + + constructor(knex: Knex) { + this.knex = knex; + } + + year(table: string, column: string): Knex.Raw { + return this.knex.raw('EXTRACT(YEAR FROM ??.??) as ??', [table, column, `${column}_year`]); + } + + month(table: string, column: string): Knex.Raw { + return this.knex.raw('EXTRACT(MONTH FROM ??)', [column]); + } + + week(table: string, column: string): Knex.Raw { + return this.knex.raw('EXTRACT(WEEK FROM ??)', [column]); + } + + day(table: string, column: string): Knex.Raw { + return this.knex.raw('EXTRACT(DAY FROM ??)', [column]); + } + + weekday(table: string, column: string): Knex.Raw { + return this.knex.raw('EXTRACT(DOW FROM ??)', [column]); + } + + hour(table: string, column: string): Knex.Raw { + return this.knex.raw('EXTRACT(HOUR FROM ??)', [column]); + } + + minute(table: string, column: string): Knex.Raw { + return this.knex.raw('EXTRACT(MINUTE FROM ??)', [column]); + } + + second(table: string, column: string): Knex.Raw { + return this.knex.raw('EXTRACT(SECOND FROM ??)', [column]); + } +} diff --git a/api/src/database/helpers/index.ts b/api/src/database/functions/index.ts similarity index 78% rename from api/src/database/helpers/index.ts rename to api/src/database/functions/index.ts index 36457a1e56..4662f7ac54 100644 --- a/api/src/database/helpers/index.ts +++ b/api/src/database/functions/index.ts @@ -1,16 +1,14 @@ import { Knex } from 'knex'; -import { DateTimeHelperPostgres } from './datetime'; +import { HelperPostgres } from './dialects/postgres'; -export function knexTransforms(knex: Knex) { +export function FunctionsHelper(knex: Knex) { switch (knex.client.constructor.name) { // case 'Client_MySQL': // constructor = require('./dialects/mysql').default; // break; case 'Client_PG': - return { - datetime: new DateTimeHelperPostgres(knex), - }; + return new HelperPostgres(knex); // case 'Client_SQLite3': // constructor = require('./dialects/sqlite').default; // break; diff --git a/api/src/database/helpers/datetime.ts b/api/src/database/helpers/datetime.ts deleted file mode 100644 index eec90a6c81..0000000000 --- a/api/src/database/helpers/datetime.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Knex } from 'knex'; - -interface IDateTimeHelper { - year(column: string): Knex.Raw; - month(column: string): Knex.Raw; - week(column: string): Knex.Raw; - day(column: string): Knex.Raw; - weekday(column: string): Knex.Raw; - hour(column: string): Knex.Raw; - minute(column: string): Knex.Raw; - second(column: string): Knex.Raw; -} - -class DateTimeHelper implements IDateTimeHelper { - protected knex: Knex; - - constructor(knex: Knex) { - this.knex = knex; - } - - year(column: string): Knex.Raw { - throw new Error(`Method "year" not implemented for dialect ${this.knex.client.constructor.name}`); - } - - month(column: string): Knex.Raw { - throw new Error(`Method "month" not implemented for dialect ${this.knex.client.constructor.name}`); - } - - week(column: string): Knex.Raw { - throw new Error(`Method "week" not implemented for dialect ${this.knex.client.constructor.name}`); - } - - day(column: string): Knex.Raw { - throw new Error(`Method "date" not implemented for dialect ${this.knex.client.constructor.name}`); - } - - weekday(column: string): Knex.Raw { - throw new Error(`Method "weekday" not implemented for dialect ${this.knex.client.constructor.name}`); - } - - hour(column: string): Knex.Raw { - throw new Error(`Method "hour" not implemented for dialect ${this.knex.client.constructor.name}`); - } - - minute(column: string): Knex.Raw { - throw new Error(`Method "minute" not implemented for dialect ${this.knex.client.constructor.name}`); - } - - second(column: string): Knex.Raw { - throw new Error(`Method "second" not implemented for dialect ${this.knex.client.constructor.name}`); - } -} - -export class DateTimeHelperPostgres extends DateTimeHelper { - constructor(knex: Knex) { - super(knex); - } - - year(column: string): Knex.Raw { - return this.knex.raw('EXTRACT(YEAR FROM ??) as ??', [column, `${column}_year`]); - } - - month(column: string): Knex.Raw { - return this.knex.raw('EXTRACT(MONTH FROM ??)', [column]); - } - - week(column: string): Knex.Raw { - return this.knex.raw('EXTRACT(WEEK FROM ??)', [column]); - } - - day(column: string): Knex.Raw { - return this.knex.raw('EXTRACT(DAY FROM ??)', [column]); - } - - weekday(column: string): Knex.Raw { - return this.knex.raw('EXTRACT(DOW FROM ??)', [column]); - } - - hour(column: string): Knex.Raw { - return this.knex.raw('EXTRACT(HOUR FROM ??)', [column]); - } - - minute(column: string): Knex.Raw { - return this.knex.raw('EXTRACT(MINUTE FROM ??)', [column]); - } - - second(column: string): Knex.Raw { - return this.knex.raw('EXTRACT(SECOND FROM ??)', [column]); - } -} diff --git a/api/src/database/run-ast.ts b/api/src/database/run-ast.ts index 9a03ce331d..19eb4fed3c 100644 --- a/api/src/database/run-ast.ts +++ b/api/src/database/run-ast.ts @@ -4,6 +4,8 @@ import { PayloadService } from '../services/payload'; import { Item, Query, SchemaOverview } from '../types'; import { AST, FieldNode, NestedCollectionNode } from '../types/ast'; import applyQuery from '../utils/apply-query'; +import { getColumn } from '../utils/get-column'; +import { stripFunction } from '../utils/strip-function'; import { toArray } from '../utils/to-array'; import getDatabase from './index'; @@ -111,8 +113,9 @@ async function parseCurrentLevel( for (const child of children) { if (child.type === 'field') { - if (columnsInCollection.includes(child.name) || child.name === '*') { - columnsToSelectInternal.push(child.name); + const fieldKey = stripFunction(child.name); + if (columnsInCollection.includes(fieldKey) || fieldKey === '*') { + columnsToSelectInternal.push(child.name); // maintain original name here (includes functions) } continue; @@ -154,7 +157,7 @@ function getDBQuery( query: Query, nested?: boolean ): Knex.QueryBuilder { - const dbQuery = knex.select(columns.map((column) => `${table}.${column}`)).from(table); + const dbQuery = knex.select(columns.map((column) => getColumn(knex, table, column))).from(table); const queryCopy = clone(query); diff --git a/api/src/extensions.ts b/api/src/extensions.ts index 4e5b971306..c3c8819189 100644 --- a/api/src/extensions.ts +++ b/api/src/extensions.ts @@ -12,6 +12,7 @@ import { EndpointRegisterFunction, HookRegisterFunction } from './types'; import { getSchema } from './utils/get-schema'; import listFolders from './utils/list-folders'; import { schedule, validate } from 'node-cron'; +import { REGEX_BETWEEN_PARENS } from './constants'; export async function ensureFoldersExist(): Promise { const folders = ['endpoints', 'hooks', 'interfaces', 'modules', 'layouts', 'displays']; @@ -98,7 +99,7 @@ function registerHooks(hooks: string[]) { for (const [event, handler] of Object.entries(events)) { if (event.startsWith('cron(')) { - const cron = event.match(/\(([^)]+)\)/)?.[1]; + const cron = event.match(REGEX_BETWEEN_PARENS)?.[1]; if (!cron || validate(cron) === false) { logger.warn(`Couldn't register cron hook. Provided cron is invalid: ${cron}`); diff --git a/api/src/utils/get-ast-from-query.ts b/api/src/utils/get-ast-from-query.ts index a2b6d01706..06e13e35d6 100644 --- a/api/src/utils/get-ast-from-query.ts +++ b/api/src/utils/get-ast-from-query.ts @@ -58,7 +58,7 @@ export default async function getASTFromQuery( /** * When using aggregate functions, you can't have any other regular fields - * selected. This makes sure you never end up in a nonaggregate fields selection error + * selected. This makes sure you never end up in a non-aggregate fields selection error */ if (Object.keys(query.aggregate || {}).length > 0) { fields = []; diff --git a/api/src/utils/get-column.ts b/api/src/utils/get-column.ts new file mode 100644 index 0000000000..1d85a95655 --- /dev/null +++ b/api/src/utils/get-column.ts @@ -0,0 +1,29 @@ +import { Knex } from 'knex'; +import { FunctionsHelper } from '../database/functions'; +import { REGEX_BETWEEN_PARENS } from '../constants'; + +/** + * Return column prefixed by table. If column includes functions (like `year(date_created)`, the + * column is replaced with the appropriate SQL) + * + * @param knex Current knex / transaction instance + * @param collection Collection or alias in which column resides + * @param field name of the column + * @returns Knex raw instance + */ +export function getColumn(knex: Knex, table: string, column: string): Knex.Raw { + const fn = FunctionsHelper(knex); + + if (column.includes('(') && column.includes(')')) { + const functionName = column.split('(')[0]; + const columnName = column.match(REGEX_BETWEEN_PARENS)![1]; + + if (functionName in fn) { + return fn[functionName as keyof typeof fn](table, columnName); + } else { + throw new Error(`Invalid function specified "${functionName}"`); + } + } + + return knex.raw('??.??', [table, column]); +} diff --git a/api/src/utils/parse-filter.ts b/api/src/utils/parse-filter.ts index 0ad716c122..ecb6d66e52 100644 --- a/api/src/utils/parse-filter.ts +++ b/api/src/utils/parse-filter.ts @@ -1,3 +1,4 @@ +import { REGEX_BETWEEN_PARENS } from '../constants'; import { Accountability, Filter } from '../types'; import { toArray } from '../utils/to-array'; import { adjustDate } from './adjust-date'; @@ -16,7 +17,7 @@ export function parseFilter(filter: Filter, accountability: Accountability | nul if (val && typeof val === 'string' && val.startsWith('$NOW')) { if (val.includes('(') && val.includes(')')) { - const adjustment = val.match(/\(([^)]+)\)/)?.[1]; + const adjustment = val.match(REGEX_BETWEEN_PARENS)?.[1]; if (!adjustment) return new Date(); return adjustDate(new Date(), adjustment); } diff --git a/api/src/utils/strip-function.ts b/api/src/utils/strip-function.ts new file mode 100644 index 0000000000..9d4364257f --- /dev/null +++ b/api/src/utils/strip-function.ts @@ -0,0 +1,13 @@ +import { REGEX_BETWEEN_PARENS } from '../constants'; + +/** + * Strip the function declarations from a list of fields + */ +export function stripFunction(field: string): string { + if (field.includes('(') && field.includes(')')) { + console.log(field.match(REGEX_BETWEEN_PARENS)); + return field.match(REGEX_BETWEEN_PARENS)![1].trim(); + } else { + return field; + } +}