From e72108ff28586024c3d8f60e71890575e822f74c Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 14:39:41 +0100 Subject: [PATCH] Added support for expressions in `orderedBy` (#30) --- src/instructions/before-after.ts | 6 +-- src/instructions/ordered-by.ts | 39 ++++++-------- src/utils/model.ts | 7 +-- src/utils/statement.ts | 77 +++++++++++++++++---------- src/validators/query.ts | 32 +++++++---- tests/instructions/ordered-by.test.ts | 45 ++++++++++++++++ 6 files changed, 138 insertions(+), 68 deletions(-) diff --git a/src/instructions/before-after.ts b/src/instructions/before-after.ts index 916a3e7..9d9897d 100644 --- a/src/instructions/before-after.ts +++ b/src/instructions/before-after.ts @@ -77,7 +77,7 @@ export const handleBeforeOrAfter = ( return 'NULL'; } - const { field } = getFieldFromModel(model, key, 'orderedBy'); + const { field } = getFieldFromModel(model, key as string, 'orderedBy'); if (field.type === 'boolean') { return prepareStatementValue(statementParams, value === 'true'); @@ -122,7 +122,7 @@ export const handleBeforeOrAfter = ( const key = keys[j]; const value = values[j]; - let { field, fieldSelector } = getFieldFromModel(model, key, 'orderedBy'); + let { field, fieldSelector } = getFieldFromModel(model, key as string, 'orderedBy'); // If we're at the current field, add the comparison to the condition. if (j === i) { @@ -145,7 +145,7 @@ export const handleBeforeOrAfter = ( if ( value !== 'NULL' && operator === '<' && - !['ronin.createdAt', 'ronin.updatedAt'].includes(key) + !['ronin.createdAt', 'ronin.updatedAt'].includes(key as string) ) { fieldSelector = `IFNULL(${fieldSelector}, -1e999)`; } diff --git a/src/instructions/ordered-by.ts b/src/instructions/ordered-by.ts index 67536d3..ea6470c 100644 --- a/src/instructions/ordered-by.ts +++ b/src/instructions/ordered-by.ts @@ -1,6 +1,7 @@ import type { Model } from '@/src/types/model'; import type { GetInstructions } from '@/src/types/query'; import { getFieldFromModel } from '@/src/utils/model'; +import { getSymbol, parseFieldExpression } from '@/src/utils/statement'; /** * Generates the SQL syntax for the `orderedBy` query instruction, which allows for @@ -17,44 +18,36 @@ export const handleOrderedBy = ( ): string => { let statement = ''; - for (const field of instruction!.ascending || []) { - // Check whether the field exists. - const { field: modelField, fieldSelector } = getFieldFromModel( - model, - field, - 'orderedBy.ascending', - ); + const items = [ + ...(instruction!.ascending || []).map((value) => ({ value, order: 'ASC' })), + ...(instruction!.descending || []).map((value) => ({ value, order: 'DESC' })), + ]; + for (const item of items) { if (statement.length > 0) { statement += ', '; } - const caseInsensitiveStatement = - modelField.type === 'string' ? ' COLLATE NOCASE' : ''; + const symbol = getSymbol(item.value); + const instructionName = + item.order === 'ASC' ? 'orderedBy.ascending' : 'orderedBy.descending'; - statement += `${fieldSelector}${caseInsensitiveStatement} ASC`; - } + if (symbol?.type === 'expression') { + statement += `(${parseFieldExpression(model, instructionName, symbol.value)}) ${item.order}`; + continue; + } - // If multiple records are being retrieved, the `orderedBy.descending` property is - // never undefined, because it is automatically added outside the `handleOrderedBy` - // function. If a single record is being retrieved, however, it will be undefined, so - // we need the empty array fallback. - for (const field of instruction!.descending || []) { // Check whether the field exists. const { field: modelField, fieldSelector } = getFieldFromModel( model, - field, - 'orderedBy.descending', + item.value as string, + instructionName, ); - if (statement.length > 0) { - statement += ', '; - } - const caseInsensitiveStatement = modelField.type === 'string' ? ' COLLATE NOCASE' : ''; - statement += `${fieldSelector}${caseInsensitiveStatement} DESC`; + statement += `${fieldSelector}${caseInsensitiveStatement} ${item.order}`; } return `ORDER BY ${statement}`; diff --git a/src/utils/model.ts b/src/utils/model.ts index 1b37aed..41bbb26 100644 --- a/src/utils/model.ts +++ b/src/utils/model.ts @@ -19,7 +19,6 @@ import type { WithInstruction, } from '@/src/types/query'; import { - RONIN_MODEL_FIELD_REGEX, RONIN_MODEL_SYMBOLS, RoninError, convertToCamelCase, @@ -28,6 +27,7 @@ import { type splitQuery, } from '@/src/utils/helpers'; import { compileQueryInput } from '@/src/utils/index'; +import { parseFieldExpression } from '@/src/utils/statement'; import title from 'title'; /** @@ -814,10 +814,7 @@ export const addModelQueries = ( // insert them into the expression, after which the expression can be used in the // SQL statement. else if ('expression' in field) { - fieldSelector = field.expression.replace(RONIN_MODEL_FIELD_REGEX, (match) => { - const fieldSlug = match.replace(RONIN_MODEL_SYMBOLS.FIELD, ''); - return getFieldFromModel(model, fieldSlug, 'to').fieldSelector; - }); + fieldSelector = parseFieldExpression(model, 'to', field.expression, model); } if (field.collation) fieldSelector += ` COLLATE ${field.collation}`; diff --git a/src/utils/statement.ts b/src/utils/statement.ts index cb7c0e1..73431ed 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -58,6 +58,49 @@ export const prepareStatementValue = ( return `?${index}`; }; +/** + * Parses a RONIN expression and returns the SQL expression. + * + * @param model - The specific model being addressed in the surrounding query. + * @param instructionName - The name of the instruction that is being processed. + * @param expression - The expression that should be parsed. + * @param parentModel - The model of the parent query, if there is one. + * + * @returns An SQL expression. + */ +export const parseFieldExpression = ( + model: Model, + instructionName: QueryInstructionType, + expression: string, + parentModel?: Model, +) => { + return expression.replace(RONIN_MODEL_FIELD_REGEX, (match) => { + let toReplace: string = RONIN_MODEL_SYMBOLS.FIELD; + let rootModel: Model = model; + + // If a parent field is being referenced inside the value of the field, we need to + // obtain the field from the parent model instead of the current model. + if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT)) { + rootModel = parentModel as Model; + toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT; + + // If the old or new value of a field in the parent model is being referenced, we + // can't use the table name directly and instead have to resort to using special + // keywords such as `OLD` and `NEW` as the table names, which SQLite will handle. + if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD)) { + rootModel.tableAlias = toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD; + } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW)) { + rootModel.tableAlias = toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW; + } + } + + const fieldSlug = match.replace(toReplace, ''); + const field = getFieldFromModel(rootModel, fieldSlug, instructionName); + + return field.fieldSelector; + }); +}; + /** * Generates an SQL condition, column name, or column value for the provided field. * @@ -71,7 +114,7 @@ export const prepareStatementValue = ( * @returns An SQL condition for the provided field. Alternatively only its column name * or column value. */ -const composeFieldValues = ( +export const composeFieldValues = ( models: Array, model: Model, statementParams: Array | null, @@ -102,32 +145,12 @@ const composeFieldValues = ( // The value of the field is a RONIN expression, which we need to compile into an SQL // syntax that can be run. if (symbol?.type === 'expression') { - conditionValue = symbol.value.replace(RONIN_MODEL_FIELD_REGEX, (match) => { - let toReplace: string = RONIN_MODEL_SYMBOLS.FIELD; - let rootModel: Model = model; - - // If a parent field is being referenced inside the value of the field, we need - // to obtain the field from the parent model instead of the current model. - if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT)) { - rootModel = options.parentModel as Model; - toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT; - - // If the old or new value of a field in the parent model is being referenced, - // we can't use the table name directly and instead have to resort to using - // special keywords such as `OLD` and `NEW` as the table names, which SQLite - // will parse itself. - if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD)) { - rootModel.tableAlias = toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD; - } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW)) { - rootModel.tableAlias = toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW; - } - } - - const fieldSlug = match.replace(toReplace, ''); - const field = getFieldFromModel(rootModel, fieldSlug, instructionName); - - return field.fieldSelector; - }); + conditionValue = parseFieldExpression( + model, + instructionName, + symbol.value, + options.parentModel, + ); } // The value of the field is a RONIN query, which we need to compile into an SQL diff --git a/src/validators/query.ts b/src/validators/query.ts index ae181e2..4efd36a 100644 --- a/src/validators/query.ts +++ b/src/validators/query.ts @@ -1,3 +1,4 @@ +import { RONIN_MODEL_SYMBOLS } from '@/src/utils/helpers'; import { z } from 'zod'; // Query Types. @@ -13,6 +14,11 @@ export const FieldValue = z.union( ); export const FieldSelector = z.record(FieldValue); +// Expression +export const ExpressionSchema = z.object({ + [RONIN_MODEL_SYMBOLS.EXPRESSION]: z.string(), +}); + // With Instructions. export const WithInstructionRefinementTypes = z.enum([ 'being', @@ -115,25 +121,31 @@ export const OrderedByInstructionSchema = z .object({ ascending: z .array( - z.string({ - invalid_type_error: - 'The `orderedBy.ascending` instruction must be an array of strings.', - }), + z.union([ + z.string({ + invalid_type_error: + 'The `orderedBy.ascending` instruction must be an array of field slugs or expressions.', + }), + ExpressionSchema, + ]), { invalid_type_error: - 'The `orderedBy.ascending` instruction must be an array of strings.', + 'The `orderedBy.ascending` instruction must be an array of field slugs or expressions.', }, ) .optional(), descending: z .array( - z.string({ - invalid_type_error: - 'The `orderedBy.descending` instruction must be an array of strings.', - }), + z.union([ + z.string({ + invalid_type_error: + 'The `orderedBy.descending` instruction must be an array of field slugs or expressions.', + }), + ExpressionSchema, + ]), { invalid_type_error: - 'The `orderedBy.descending` instruction must be an array of strings.', + 'The `orderedBy.descending` instruction must be an array of field slugs or expressions.', }, ) .optional(), diff --git a/tests/instructions/ordered-by.test.ts b/tests/instructions/ordered-by.test.ts index bd3e1b9..9bff6d8 100644 --- a/tests/instructions/ordered-by.test.ts +++ b/tests/instructions/ordered-by.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'bun:test'; import { type Model, compileQueries } from '@/src/index'; import type { Query } from '@/src/types/query'; +import { RONIN_MODEL_SYMBOLS } from '@/src/utils/helpers'; test('get multiple records ordered by field', () => { const queries: Array = [ @@ -38,6 +39,50 @@ test('get multiple records ordered by field', () => { ]); }); +test('get multiple records ordered by expression', () => { + const queries: Array = [ + { + get: { + accounts: { + orderedBy: { + ascending: [ + { + [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD}firstName || ' ' || ${RONIN_MODEL_SYMBOLS.FIELD}lastName`, + }, + ], + }, + }, + }, + }, + ]; + + const models: Array = [ + { + slug: 'account', + fields: [ + { + slug: 'firstName', + type: 'string', + }, + { + slug: 'lastName', + type: 'string', + }, + ], + }, + ]; + + const statements = compileQueries(queries, models); + + expect(statements).toEqual([ + { + statement: `SELECT * FROM "accounts" ORDER BY ("firstName" || ' ' || "lastName") ASC`, + params: [], + returning: true, + }, + ]); +}); + test('get multiple records ordered by multiple fields', () => { const queries: Array = [ {