Skip to content

Commit

Permalink
Added support for expressions in orderedBy (#30)
Browse files Browse the repository at this point in the history
  • Loading branch information
leo authored Nov 14, 2024
1 parent 5560e5d commit e72108f
Show file tree
Hide file tree
Showing 6 changed files with 138 additions and 68 deletions.
6 changes: 3 additions & 3 deletions src/instructions/before-after.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand All @@ -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)`;
}
Expand Down
39 changes: 16 additions & 23 deletions src/instructions/ordered-by.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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}`;
Expand Down
7 changes: 2 additions & 5 deletions src/utils/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import type {
WithInstruction,
} from '@/src/types/query';
import {
RONIN_MODEL_FIELD_REGEX,
RONIN_MODEL_SYMBOLS,
RoninError,
convertToCamelCase,
Expand All @@ -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';

/**
Expand Down Expand Up @@ -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}`;
Expand Down
77 changes: 50 additions & 27 deletions src/utils/statement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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: Model,
statementParams: Array<unknown> | null,
Expand Down Expand Up @@ -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
Expand Down
32 changes: 22 additions & 10 deletions src/validators/query.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { RONIN_MODEL_SYMBOLS } from '@/src/utils/helpers';
import { z } from 'zod';

// Query Types.
Expand All @@ -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',
Expand Down Expand Up @@ -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(),
Expand Down
45 changes: 45 additions & 0 deletions tests/instructions/ordered-by.test.ts
Original file line number Diff line number Diff line change
@@ -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<Query> = [
Expand Down Expand Up @@ -38,6 +39,50 @@ test('get multiple records ordered by field', () => {
]);
});

test('get multiple records ordered by expression', () => {
const queries: Array<Query> = [
{
get: {
accounts: {
orderedBy: {
ascending: [
{
[RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD}firstName || ' ' || ${RONIN_MODEL_SYMBOLS.FIELD}lastName`,
},
],
},
},
},
},
];

const models: Array<Model> = [
{
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<Query> = [
{
Expand Down

0 comments on commit e72108f

Please sign in to comment.