From 82e026a1b77d0f71274a56d49ca05f6b2123ec3e Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Wed, 13 Nov 2024 13:28:23 +0100 Subject: [PATCH 01/28] Added symbol for expressions --- src/utils/helpers.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index ae03eaf..f1e556f 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -21,6 +21,9 @@ export const RONIN_MODEL_SYMBOLS = { // Represents a sub query. QUERY: '__RONIN_QUERY', + // Represents an expression that should be evaluated. + EXPRESSION: '__RONIN_EXPRESSION', + // Represents the value of a field in a model. FIELD: '__RONIN_FIELD_', From d21326766e7d9da5127f515dd32be42fc5c6d746 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Wed, 13 Nov 2024 13:41:23 +0100 Subject: [PATCH 02/28] Check for symbols in a single place --- src/instructions/including.ts | 8 +++--- src/instructions/selecting.ts | 6 ++-- src/instructions/to.ts | 10 +++---- src/utils/statement.ts | 52 +++++++++++++++++++++++++++++------ 4 files changed, 57 insertions(+), 19 deletions(-) diff --git a/src/instructions/including.ts b/src/instructions/including.ts index 27435a2..7139446 100644 --- a/src/instructions/including.ts +++ b/src/instructions/including.ts @@ -4,7 +4,7 @@ import type { Instructions } from '@/src/types/query'; import { splitQuery } from '@/src/utils/helpers'; import { compileQueryInput } from '@/src/utils/index'; import { getModelBySlug, getTableForModel } from '@/src/utils/model'; -import { composeConditions, getSubQuery } from '@/src/utils/statement'; +import { composeConditions, getSymbol } from '@/src/utils/statement'; /** * Generates the SQL syntax for the `including` query instruction, which allows for @@ -34,7 +34,7 @@ export const handleIncluding = ( let rootTableName = rootTable; for (const ephemeralFieldSlug in instruction) { - const includingQuery = getSubQuery(instruction[ephemeralFieldSlug]); + const symbol = getSymbol(instruction[ephemeralFieldSlug]); // The `including` instruction might contain values that are not queries, which are // taken care of by the `handleSelecting` function. Specifically, those values are @@ -42,9 +42,9 @@ export const handleIncluding = ( // // Only in the case that the `including` instruction contains a query, we want to // continue with the current function and process the query as an SQL JOIN. - if (!includingQuery) continue; + if (symbol?.type !== 'query') continue; - const { queryType, queryModel, queryInstructions } = splitQuery(includingQuery); + const { queryType, queryModel, queryInstructions } = splitQuery(symbol.value); let modifiableQueryInstructions = queryInstructions; const relatedModel = getModelBySlug(models, queryModel); diff --git a/src/instructions/selecting.ts b/src/instructions/selecting.ts index 6a1103c..edd81ac 100644 --- a/src/instructions/selecting.ts +++ b/src/instructions/selecting.ts @@ -2,7 +2,7 @@ import type { Model } from '@/src/types/model'; import type { Instructions } from '@/src/types/query'; import { flatten } from '@/src/utils/helpers'; import { getFieldFromModel } from '@/src/utils/model'; -import { getSubQuery, prepareStatementValue } from '@/src/utils/statement'; +import { getSymbol, prepareStatementValue } from '@/src/utils/statement'; /** * Generates the SQL syntax for the `selecting` query instruction, which allows for @@ -45,7 +45,9 @@ export const handleSelecting = ( // the case of sub queries resulting in multiple records, it's the only way to // include multiple rows of another table. .filter(([_, value]) => { - const hasQuery = getSubQuery(value); + const symbol = getSymbol(value); + const hasQuery = symbol?.type === 'query'; + if (hasQuery) isJoining = true; return !hasQuery; }); diff --git a/src/instructions/to.ts b/src/instructions/to.ts index 83c050f..13d3012 100644 --- a/src/instructions/to.ts +++ b/src/instructions/to.ts @@ -13,7 +13,7 @@ import { getFieldFromModel, getModelBySlug, } from '@/src/utils/model'; -import { composeConditions, getSubQuery } from '@/src/utils/statement'; +import { composeConditions, getSymbol } from '@/src/utils/statement'; /** * Generates the SQL syntax for the `to` query instruction, which allows for providing @@ -63,14 +63,14 @@ export const handleTo = ( ...toInstruction.ronin, }; - const subQuery = getSubQuery(toInstruction); + const symbol = getSymbol(toInstruction); // If a sub query is provided as the `to` instruction, we don't need to compute a list // of fields and/or values for the SQL query, since the fields and values are all // derived from the sub query. This allows us to keep the SQL statement lean. - if (subQuery) { + if (symbol?.type === 'query') { let { queryModel: subQueryModelSlug, queryInstructions: subQueryInstructions } = - splitQuery(subQuery); + splitQuery(symbol.value); const subQueryModel = getModelBySlug(models, subQueryModelSlug); const subQuerySelectedFields = subQueryInstructions?.selecting; @@ -121,7 +121,7 @@ export const handleTo = ( } as unknown as Array; } - return compileQueryInput(subQuery, models, statementParams).main.statement; + return compileQueryInput(symbol.value, models, statementParams).main.statement; } // Assign default field values to the provided instruction. diff --git a/src/utils/statement.ts b/src/utils/statement.ts index 77a8190..ea6c3d6 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -90,10 +90,12 @@ const composeFieldValues = ( // If only the field selectors are being requested, do not register any values. const collectStatementValue = options.type !== 'fields'; + const symbol = getSymbol(value); + let conditionSelector = selector; let conditionValue = value; - if (getSubQuery(value) && collectStatementValue) { + if (symbol?.type === 'query' && collectStatementValue) { conditionValue = `(${ compileQueryInput( (value as Record)[RONIN_MODEL_SYMBOLS.QUERY], @@ -191,7 +193,13 @@ export const composeConditions = ( // If the `to` instruction is used, JSON should be written as-is. const consumeJSON = modelField.type === 'json' && instructionName === 'to'; - if (!(isObject(value) || Array.isArray(value)) || getSubQuery(value) || consumeJSON) { + const symbol = getSymbol(value); + + if ( + !(isObject(value) || Array.isArray(value)) || + symbol?.type === 'query' || + consumeJSON + ) { return composeFieldValues( models, model, @@ -355,14 +363,42 @@ export const formatIdentifiers = ( }; /** - * Obtains a sub query from a value, if the value contains one. + * Checks if the provided value contains a symbol and returns its type and value. * * @param value - The value that should be checked. * - * @returns A sub query, if the provided value contains one. + * @returns The type and value of the symbol, if the provided value contains one. */ -export const getSubQuery = (value: unknown): Query | null => { - return isObject(value) - ? (value as Record)[RONIN_MODEL_SYMBOLS.QUERY] || null - : null; +export const getSymbol = ( + value: unknown, +): + | { + type: 'query'; + value: Query; + } + | { + type: 'expression'; + value: string; + } + | null => { + if (!isObject(value)) return null; + const objectValue = value as + | Record + | Record; + + if (RONIN_MODEL_SYMBOLS.QUERY in objectValue) { + return { + type: 'query', + value: objectValue[RONIN_MODEL_SYMBOLS.QUERY], + }; + } + + if (RONIN_MODEL_SYMBOLS.EXPRESSION in objectValue) { + return { + type: 'expression', + value: objectValue[RONIN_MODEL_SYMBOLS.EXPRESSION], + }; + } + + return null; }; From 4c74aa8656121cfd22279b03b2df93720e5d7975 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Wed, 13 Nov 2024 15:04:05 +0100 Subject: [PATCH 03/28] Made expressions referencing fields work --- src/instructions/to.ts | 1 + src/utils/statement.ts | 26 +++++++++---------- tests/instructions/to.test.ts | 49 +++++++++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/instructions/to.ts b/src/instructions/to.ts index 13d3012..36c0106 100644 --- a/src/instructions/to.ts +++ b/src/instructions/to.ts @@ -63,6 +63,7 @@ export const handleTo = ( ...toInstruction.ronin, }; + // Check whether a query resides at the root of the `to` instruction. const symbol = getSymbol(toInstruction); // If a sub query is provided as the `to` instruction, we don't need to compute a list diff --git a/src/utils/statement.ts b/src/utils/statement.ts index ea6c3d6..ddb83a6 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -6,7 +6,12 @@ import type { SetInstructions, WithInstruction, } from '@/src/types/query'; -import { RONIN_MODEL_SYMBOLS, RoninError, isObject } from '@/src/utils/helpers'; +import { + RONIN_MODEL_FIELD_REGEX, + RONIN_MODEL_SYMBOLS, + RoninError, + isObject, +} from '@/src/utils/helpers'; import { WITH_CONDITIONS, @@ -97,12 +102,13 @@ const composeFieldValues = ( if (symbol?.type === 'query' && collectStatementValue) { conditionValue = `(${ - compileQueryInput( - (value as Record)[RONIN_MODEL_SYMBOLS.QUERY], - models, - statementParams, - ).main.statement + compileQueryInput(symbol.value, models, statementParams).main.statement })`; + } else if (symbol?.type === 'expression' && collectStatementValue) { + conditionValue = symbol.value.replace(RONIN_MODEL_FIELD_REGEX, (match) => { + const fieldSlug = match.replace(RONIN_MODEL_SYMBOLS.FIELD, ''); + return getFieldFromModel(model, fieldSlug, instructionName).fieldSelector; + }); } else if (typeof value === 'string' && value.startsWith(RONIN_MODEL_SYMBOLS.FIELD)) { let targetTable = `"${options.rootTable}"`; let toReplace: string = RONIN_MODEL_SYMBOLS.FIELD; @@ -193,13 +199,7 @@ export const composeConditions = ( // If the `to` instruction is used, JSON should be written as-is. const consumeJSON = modelField.type === 'json' && instructionName === 'to'; - const symbol = getSymbol(value); - - if ( - !(isObject(value) || Array.isArray(value)) || - symbol?.type === 'query' || - consumeJSON - ) { + if (!(isObject(value) || Array.isArray(value)) || getSymbol(value) || consumeJSON) { return composeFieldValues( models, model, diff --git a/tests/instructions/to.test.ts b/tests/instructions/to.test.ts index 20e62e2..31b374e 100644 --- a/tests/instructions/to.test.ts +++ b/tests/instructions/to.test.ts @@ -48,6 +48,51 @@ test('set single record to new string field', () => { ]); }); +test('set single record to new string field with expression referencing fields', () => { + const queries: Array = [ + { + set: { + account: { + with: { + handle: 'elaine', + }, + to: { + name: { + [RONIN_MODEL_SYMBOLS.EXPRESSION]: `UPPER(substr(${RONIN_MODEL_SYMBOLS.FIELD}handle, 1, 1)) || substr(${RONIN_MODEL_SYMBOLS.FIELD}handle, 2)`, + }, + }, + }, + }, + }, + ]; + + const models: Array = [ + { + slug: 'account', + fields: [ + { + slug: 'handle', + type: 'string', + }, + { + slug: 'name', + type: 'string', + }, + ], + }, + ]; + + const statements = compileQueries(queries, models); + + expect(statements).toEqual([ + { + statement: `UPDATE "accounts" SET "name" = UPPER(substr("handle", 1, 1)) || substr("handle", 2), "ronin.updatedAt" = ?1 WHERE ("handle" = ?2) RETURNING *`, + params: [expect.stringMatching(RECORD_TIMESTAMP_REGEX), 'elaine'], + returning: true, + }, + ]); +}); + test('set single record to new one-cardinality reference field', () => { const queries: Array = [ { @@ -467,7 +512,7 @@ test('set single record to new json field with empty object', () => { expect(statements).toEqual([ { - statement: `UPDATE \"accounts\" SET \"emails\" = ?1, \"ronin.updatedAt\" = ?2 WHERE (\"handle\" = ?3) RETURNING *`, + statement: `UPDATE "accounts" SET "emails" = ?1, "ronin.updatedAt" = ?2 WHERE ("handle" = ?3) RETURNING *`, params: ['{}', expect.stringMatching(RECORD_TIMESTAMP_REGEX), 'elaine'], returning: true, }, @@ -622,7 +667,7 @@ test('set single record to new grouped json field', () => { expect(statements).toEqual([ { - statement: `UPDATE \"teams\" SET \"billing.invoiceRecipients\" = ?1, \"ronin.updatedAt\" = ?2 WHERE (\"id\" = ?3) RETURNING *`, + statement: `UPDATE "teams" SET "billing.invoiceRecipients" = ?1, "ronin.updatedAt" = ?2 WHERE ("id" = ?3) RETURNING *`, params: [ '["receipts@test.co"]', expect.stringMatching(RECORD_TIMESTAMP_REGEX), From b4b3c396b25d6db79b0377a94de43a4424e29473 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Wed, 13 Nov 2024 15:40:08 +0100 Subject: [PATCH 04/28] Correctly use expressions --- src/instructions/with.ts | 6 +++--- src/utils/helpers.ts | 2 +- src/utils/index.ts | 22 +++++++++++++++------- src/utils/model.ts | 15 +++++++-------- src/utils/statement.ts | 18 ++++++++++++++++-- tests/meta.test.ts | 15 ++++++++++----- 6 files changed, 52 insertions(+), 26 deletions(-) diff --git a/src/instructions/with.ts b/src/instructions/with.ts index 0be8924..f47f5a4 100644 --- a/src/instructions/with.ts +++ b/src/instructions/with.ts @@ -58,7 +58,7 @@ export type { WithValue, WithValueOptions, WithFilters, WithCondition }; * @param statementParams - A collection of values that will automatically be * inserted into the query by SQLite. * @param instruction - The `with` instruction included in a query. - * @param rootTable - The table for which the current query is being executed. + * @param options - Additional options for customizing the behavior of the function. * * @returns The SQL syntax for the provided `with` instruction. */ @@ -67,7 +67,7 @@ export const handleWith = ( model: Model, statementParams: Array | null, instruction: GetInstructions['with'], - rootTable?: string, + options?: { rootTable?: string; customTable?: string }, ): string => { const subStatement = composeConditions( models, @@ -75,7 +75,7 @@ export const handleWith = ( statementParams, 'with', instruction as WithFilters, - { rootTable }, + { rootTable: options?.rootTable, customTable: options?.customTable }, ); return `(${subStatement})`; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index f1e556f..772763a 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -41,7 +41,7 @@ export const RONIN_MODEL_SYMBOLS = { * A regular expression for matching the symbol that represents a field of a model. */ export const RONIN_MODEL_FIELD_REGEX = new RegExp( - `${RONIN_MODEL_SYMBOLS.FIELD}[a-zA-Z0-9]+`, + `${RONIN_MODEL_SYMBOLS.FIELD}[_a-zA-Z0-9]+`, 'g', ); diff --git a/src/utils/index.ts b/src/utils/index.ts index f5eb161..bb87ad3 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -37,6 +37,11 @@ export const compileQueryInput = ( * Whether the query should explicitly return records. Defaults to `true`. */ returning?: boolean; + /** + * If the query is the result of a query targeting a different schema, this option + * can contain the original schema for which the original query was processed. + */ + rootModel?: Model; }, ): { dependencies: Array; main: Statement } => { // Split out the individual components of the query. @@ -163,13 +168,16 @@ export const compileQueryInput = ( // Queries of type "get", "set", "drop", or "count" all support filtering records, but // those of type "create" do not. if (queryType !== 'create' && instructions && Object.hasOwn(instructions, 'with')) { - const withStatement = handleWith( - models, - model, - statementParams, - instructions?.with, - isJoining ? table : undefined, - ); + let customTable: string | undefined; + + if (options?.rootModel) { + customTable = getTableForModel(options.rootModel); + } + + const withStatement = handleWith(models, model, statementParams, instructions?.with, { + rootTable: isJoining ? table : undefined, + customTable, + }); if (withStatement.length > 0) conditions.push(withStatement); } diff --git a/src/utils/model.ts b/src/utils/model.ts index 1eace70..db4917a 100644 --- a/src/utils/model.ts +++ b/src/utils/model.ts @@ -864,6 +864,8 @@ export const addModelQueries = ( let statement = `${tableAction} TRIGGER "${triggerName}"`; if (queryType === 'create') { + const currentModel = targetModel as Model; + // When the trigger should fire and what type of query should cause it to fire. const { when, action } = instructionList; @@ -891,7 +893,7 @@ export const addModelQueries = ( } const fieldSelectors = fields.map((field) => { - return getFieldFromModel(targetModel as Model, field.slug, 'to').fieldSelector; + return getFieldFromModel(currentModel, field.slug, 'to').fieldSelector; }); statementParts.push(`OF (${fieldSelectors.join(', ')})`); @@ -918,13 +920,9 @@ export const addModelQueries = ( ? RONIN_MODEL_SYMBOLS.FIELD_OLD : RONIN_MODEL_SYMBOLS.FIELD_NEW; - const withStatement = handleWith( - models, - targetModel as Model, - params, - filterQuery, - tablePlaceholder, - ); + const withStatement = handleWith(models, currentModel, params, filterQuery, { + rootTable: tablePlaceholder, + }); statementParts.push('WHEN', `(${withStatement})`); } @@ -933,6 +931,7 @@ export const addModelQueries = ( const effectStatements = effectQueries.map((effectQuery) => { return compileQueryInput(effectQuery, models, params, { returning: false, + rootModel: currentModel, }).main.statement; }); diff --git a/src/utils/statement.ts b/src/utils/statement.ts index ddb83a6..3da4407 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -106,8 +106,22 @@ const composeFieldValues = ( })`; } else if (symbol?.type === 'expression' && collectStatementValue) { conditionValue = symbol.value.replace(RONIN_MODEL_FIELD_REGEX, (match) => { - const fieldSlug = match.replace(RONIN_MODEL_SYMBOLS.FIELD, ''); - return getFieldFromModel(model, fieldSlug, instructionName).fieldSelector; + let targetTable: string | undefined; + let toReplace: string = RONIN_MODEL_SYMBOLS.FIELD; + + if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_OLD)) { + targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_OLD; + } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_NEW)) { + targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_NEW; + } + + const rootModel = options.customTable + ? getModelBySlug(models, options.customTable) + : model; + + const fieldSlug = match.replace(toReplace, ''); + return getFieldFromModel(rootModel, fieldSlug, instructionName, targetTable) + .fieldSelector; }); } else if (typeof value === 'string' && value.startsWith(RONIN_MODEL_SYMBOLS.FIELD)) { let targetTable = `"${options.rootTable}"`; diff --git a/tests/meta.test.ts b/tests/meta.test.ts index 4f0ade3..02f9383 100644 --- a/tests/meta.test.ts +++ b/tests/meta.test.ts @@ -1234,7 +1234,9 @@ test('create new per-record trigger for deleting records', () => { drop: { members: { with: { - account: `${RONIN_MODEL_SYMBOLS.FIELD_OLD}createdBy`, + account: { + [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD_OLD}createdBy`, + }, }, }, }, @@ -1260,14 +1262,17 @@ test('create new per-record trigger for deleting records', () => { const models: Array = [ { slug: 'team', - }, - { - slug: 'account', + fields: [ + { + slug: 'createdBy', + type: 'string', + }, + ], }, { slug: 'member', fields: [ - { slug: 'account', type: 'reference', target: { slug: 'account' } }, + { slug: 'account', type: 'string' }, { slug: 'role', type: 'string' }, { slug: 'pending', type: 'boolean' }, ], From f6693941bde94286036e9a9f58861933e3824ae5 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Wed, 13 Nov 2024 16:05:28 +0100 Subject: [PATCH 05/28] Enable more expression use cases --- src/instructions/to.ts | 10 ++++++---- src/utils/index.ts | 11 ++++++++++- src/utils/statement.ts | 2 +- tests/meta.test.ts | 26 +++++++++++++++----------- 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/instructions/to.ts b/src/instructions/to.ts index 36c0106..9f1ea2a 100644 --- a/src/instructions/to.ts +++ b/src/instructions/to.ts @@ -27,7 +27,7 @@ import { composeConditions, getSymbol } from '@/src/utils/statement'; * @param dependencyStatements - A list of SQL statements to be executed before the main * SQL statement, in order to prepare for it. * @param instructions - The `to` and `with` instruction included in the query. - * @param rootTable - The table for which the current query is being executed. + * @param options - Additional options for customizing the behavior of the function. * * @returns The SQL syntax for the provided `to` instruction. */ @@ -41,7 +41,7 @@ export const handleTo = ( with: NonNullable | undefined; to: NonNullable; }, - rootTable?: string, + options?: { rootTable?: string; customTable?: string }, ): string => { const currentTime = new Date().toISOString(); const { with: withInstruction, to: toInstruction } = instructions; @@ -187,7 +187,8 @@ export const handleTo = ( } let statement = composeConditions(models, model, statementParams, 'to', toInstruction, { - rootTable, + rootTable: options?.rootTable, + customTable: options?.customTable, type: queryType === 'create' ? 'fields' : undefined, }); @@ -199,7 +200,8 @@ export const handleTo = ( 'to', toInstruction, { - rootTable, + rootTable: options?.rootTable, + customTable: options?.customTable, type: 'values', }, ); diff --git a/src/utils/index.ts b/src/utils/index.ts index bb87ad3..188c85e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -150,6 +150,12 @@ export const compileQueryInput = ( }); } + let customTable: string | undefined; + + if (options?.rootModel) { + customTable = getTableForModel(options.rootModel); + } + const toStatement = handleTo( models, model, @@ -157,7 +163,10 @@ export const compileQueryInput = ( queryType, dependencyStatements, { with: instructions.with, to: instructions.to }, - isJoining ? table : undefined, + { + rootTable: isJoining ? table : undefined, + customTable, + }, ); statement += `${toStatement} `; diff --git a/src/utils/statement.ts b/src/utils/statement.ts index 3da4407..76dad83 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -104,7 +104,7 @@ const composeFieldValues = ( conditionValue = `(${ compileQueryInput(symbol.value, models, statementParams).main.statement })`; - } else if (symbol?.type === 'expression' && collectStatementValue) { + } else if (symbol?.type === 'expression') { conditionValue = symbol.value.replace(RONIN_MODEL_FIELD_REGEX, (match) => { let targetTable: string | undefined; let toReplace: string = RONIN_MODEL_SYMBOLS.FIELD; diff --git a/tests/meta.test.ts b/tests/meta.test.ts index 02f9383..90da87e 100644 --- a/tests/meta.test.ts +++ b/tests/meta.test.ts @@ -1154,7 +1154,9 @@ test('create new per-record trigger for creating records', () => { create: { member: { to: { - account: `${RONIN_MODEL_SYMBOLS.FIELD_NEW}createdBy`, + account: { + [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD_NEW}createdBy`, + }, role: 'owner', pending: false, }, @@ -1182,14 +1184,17 @@ test('create new per-record trigger for creating records', () => { const models: Array = [ { slug: 'team', - }, - { - slug: 'account', + fields: [ + { + slug: 'createdBy', + type: 'string', + }, + ], }, { slug: 'member', fields: [ - { slug: 'account', type: 'reference', target: { slug: 'account' } }, + { slug: 'account', type: 'string' }, { slug: 'role', type: 'string' }, { slug: 'pending', type: 'boolean' }, ], @@ -1311,7 +1316,9 @@ test('create new per-record trigger with filters for creating records', () => { create: { member: { to: { - account: `${RONIN_MODEL_SYMBOLS.FIELD_NEW}createdBy`, + account: { + [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD_NEW}createdBy`, + }, role: 'owner', pending: false, }, @@ -1346,15 +1353,12 @@ test('create new per-record trigger with filters for creating records', () => { const models: Array = [ { slug: 'team', - fields: [{ slug: 'handle', type: 'string' }], - }, - { - slug: 'account', + fields: [{ slug: 'handle', type: 'string' }, { slug: 'createdBy', type: 'string' }], }, { slug: 'member', fields: [ - { slug: 'account', type: 'reference', target: { slug: 'account' } }, + { slug: 'account', type: 'string' }, { slug: 'role', type: 'string' }, { slug: 'pending', type: 'boolean' }, ], From bd7cb67aba0a507a1493491c89542dcb5964487e Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Wed, 13 Nov 2024 18:11:15 +0100 Subject: [PATCH 06/28] Ensure improved expression logic --- src/types/model.ts | 4 +++ src/utils/helpers.ts | 3 +++ src/utils/model.ts | 39 ++++++++++++++-------------- src/utils/statement.ts | 16 +++++++++--- tests/instructions/for.test.ts | 2 +- tests/instructions/including.test.ts | 8 ++++-- tests/instructions/to.test.ts | 8 +++--- tests/meta.test.ts | 5 +++- 8 files changed, 54 insertions(+), 31 deletions(-) diff --git a/src/types/model.ts b/src/types/model.ts index d102102..4e935a6 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -110,6 +110,10 @@ export interface Model { }; idPrefix?: string; + // If the model is used as an associative model, this property should contain the field + // to which the associative model should be mounted. + associationSlug?: string; + fields?: Array; indexes?: Array; triggers?: Array; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 772763a..bac1736 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -24,6 +24,9 @@ export const RONIN_MODEL_SYMBOLS = { // Represents an expression that should be evaluated. EXPRESSION: '__RONIN_EXPRESSION', + // Represents the value of a parent field in a model. + FIELD_PARENT: '__RONIN_FIELD_PARENT_', + // Represents the value of a field in a model. FIELD: '__RONIN_FIELD_', diff --git a/src/utils/model.ts b/src/utils/model.ts index db4917a..9112f1e 100644 --- a/src/utils/model.ts +++ b/src/utils/model.ts @@ -66,15 +66,7 @@ export const getTableForModel = (model: Model): string => { return convertToSnakeCase(model.pluralSlug as string); }; -/** - * Composes a slug for a model that was automatically provided by the system, instead - * of being provided by a developer. - * - * @param suffix - A suffix to append to the generated slug. - * - * @returns A slug for a model that was automatically provided by the system. - */ -const composeMetaModelSlug = (suffix: string) => convertToCamelCase(`ronin_${suffix}`); +const RONIN_MODEL_LINK_PREFIX = 'ronin_link_'; /** * Composes the slug of an associative model that is used to establish a relationship @@ -86,7 +78,7 @@ const composeMetaModelSlug = (suffix: string) => convertToCamelCase(`ronin_${suf * @returns A slug for the associative model. */ export const composeAssociationModelSlug = (model: PublicModel, field: ModelField) => - composeMetaModelSlug(`${model.slug}_${field.slug}`); + convertToCamelCase(`${RONIN_MODEL_LINK_PREFIX}_${model.slug}_${field.slug}`); /** * Constructs the SQL selector for a given field in a model. @@ -523,6 +515,7 @@ export const addSystemModels = (models: Array): Array addedModels.push({ pluralSlug: fieldSlug, slug: fieldSlug, + associationSlug: field.slug, fields: [ { slug: 'source', @@ -565,11 +558,11 @@ export const addDefaultModelPresets = (list: Array, model: Model): Model if (field.type === 'reference' && !field.slug.startsWith('ronin.')) { const relatedModel = getModelBySlug(list, field.target.slug); - let fieldSlug = relatedModel.slug; - - if (field.kind === 'many') { - fieldSlug = composeAssociationModelSlug(model, field); - } + // If a reference field has the cardinality "many", we don't need to add a default + // preset for resolving its records, because we are already adding an associative + // schema in `addSystemModels`, which causes a default preset to get added in the + // original schema anyways. + if (field.kind === 'many') continue; // For every reference field, add a default preset for resolving the referenced // record in the model that contains the reference field. @@ -579,11 +572,13 @@ export const addDefaultModelPresets = (list: Array, model: Model): Model [field.slug]: { [RONIN_MODEL_SYMBOLS.QUERY]: { get: { - [fieldSlug]: { + [relatedModel.slug]: { with: { // Compare the `id` field of the related model to the reference field on // the root model (`field.slug`). - id: `${RONIN_MODEL_SYMBOLS.FIELD}${field.slug}`, + id: { + [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD_PARENT}${field.slug}`, + }, }, }, }, @@ -614,15 +609,19 @@ export const addDefaultModelPresets = (list: Array, model: Model): Model const { model: childModel, field: childField } = childMatch; const pluralSlug = childModel.pluralSlug as string; + const presetSlug = childModel.associationSlug || pluralSlug; + defaultPresets.push({ instructions: { including: { - [pluralSlug]: { + [presetSlug]: { [RONIN_MODEL_SYMBOLS.QUERY]: { get: { [pluralSlug]: { with: { - [childField.slug]: `${RONIN_MODEL_SYMBOLS.FIELD}id`, + [childField.slug]: { + [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD_PARENT}id`, + }, }, }, }, @@ -630,7 +629,7 @@ export const addDefaultModelPresets = (list: Array, model: Model): Model }, }, }, - slug: pluralSlug, + slug: presetSlug, }); } diff --git a/src/utils/statement.ts b/src/utils/statement.ts index 76dad83..560149d 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -109,15 +109,25 @@ const composeFieldValues = ( let targetTable: string | undefined; let toReplace: string = RONIN_MODEL_SYMBOLS.FIELD; + let rootModel = model; + if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_OLD)) { targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_OLD; + + if (options.customTable) rootModel = getModelBySlug(models, options.customTable); } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_NEW)) { targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_NEW; + + if (options.customTable) rootModel = getModelBySlug(models, options.customTable); } - const rootModel = options.customTable - ? getModelBySlug(models, options.customTable) - : model; + if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT)) { + targetTable = options.rootTable; + toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT; + + if (options.rootTable && !collectStatementValue) + rootModel = getModelBySlug(models, options.rootTable); + } const fieldSlug = match.replace(toReplace, ''); return getFieldFromModel(rootModel, fieldSlug, instructionName, targetTable) diff --git a/tests/instructions/for.test.ts b/tests/instructions/for.test.ts index 71ad614..9632856 100644 --- a/tests/instructions/for.test.ts +++ b/tests/instructions/for.test.ts @@ -430,7 +430,7 @@ test('get single record including child records (one-to-many, defined manually)' expect(statements).toEqual([ { - statement: `SELECT * FROM (SELECT * FROM "posts" LIMIT 1) as sub_posts LEFT JOIN "ronin_post_comments" as including_comments ON ("including_comments"."id" = "sub_posts"."comments")`, + statement: `SELECT * FROM (SELECT * FROM "posts" LIMIT 1) as sub_posts LEFT JOIN "ronin_link_post_comments" as including_comments ON ("including_comments"."id" = "sub_posts"."comments")`, params: [], returning: true, }, diff --git a/tests/instructions/including.test.ts b/tests/instructions/including.test.ts index db44a41..dc28a73 100644 --- a/tests/instructions/including.test.ts +++ b/tests/instructions/including.test.ts @@ -54,7 +54,9 @@ test('get single record including unrelated record with filter', () => { get: { team: { with: { - handle: `${RONIN_MODEL_SYMBOLS.FIELD}label`, + handle: { + [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD_PARENT}label`, + }, }, }, }, @@ -148,7 +150,9 @@ test('get single record including unrelated records with filter', () => { get: { teams: { with: { - handle: `${RONIN_MODEL_SYMBOLS.FIELD}label`, + handle: { + [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD_PARENT}label`, + }, }, }, }, diff --git a/tests/instructions/to.test.ts b/tests/instructions/to.test.ts index 31b374e..eb6596b 100644 --- a/tests/instructions/to.test.ts +++ b/tests/instructions/to.test.ts @@ -191,12 +191,12 @@ test('set single record to new many-cardinality reference field', () => { expect(statements).toEqual([ { - statement: 'DELETE FROM "ronin_post_comments" WHERE ("source" = ?1)', + statement: 'DELETE FROM "ronin_link_post_comments" WHERE ("source" = ?1)', params: ['pos_zgoj3xav8tpcte1s'], }, { statement: - 'INSERT INTO "ronin_post_comments" ("source", "target", "id", "ronin.createdAt", "ronin.updatedAt") VALUES (?1, (SELECT "id" FROM "comments" WHERE ("content" = ?2) LIMIT 1), ?3, ?4, ?5)', + 'INSERT INTO "ronin_link_post_comments" ("source", "target", "id", "ronin.createdAt", "ronin.updatedAt") VALUES (?1, (SELECT "id" FROM "comments" WHERE ("content" = ?2) LIMIT 1), ?3, ?4, ?5)', params: [ 'pos_zgoj3xav8tpcte1s', 'Great post!', @@ -260,7 +260,7 @@ test('set single record to new many-cardinality reference field (add)', () => { expect(statements).toEqual([ { statement: - 'INSERT INTO "ronin_post_comments" ("source", "target", "id", "ronin.createdAt", "ronin.updatedAt") VALUES (?1, (SELECT "id" FROM "comments" WHERE ("content" = ?2) LIMIT 1), ?3, ?4, ?5)', + 'INSERT INTO "ronin_link_post_comments" ("source", "target", "id", "ronin.createdAt", "ronin.updatedAt") VALUES (?1, (SELECT "id" FROM "comments" WHERE ("content" = ?2) LIMIT 1), ?3, ?4, ?5)', params: [ 'pos_zgoj3xav8tpcte1s', 'Great post!', @@ -324,7 +324,7 @@ test('set single record to new many-cardinality reference field (delete)', () => expect(statements).toEqual([ { statement: - 'DELETE FROM "ronin_post_comments" WHERE ("source" = ?1 AND "target" = (SELECT "id" FROM "comments" WHERE ("content" = ?2) LIMIT 1))', + 'DELETE FROM "ronin_link_post_comments" WHERE ("source" = ?1 AND "target" = (SELECT "id" FROM "comments" WHERE ("content" = ?2) LIMIT 1))', params: ['pos_zgoj3xav8tpcte1s', 'Great post!'], }, { diff --git a/tests/meta.test.ts b/tests/meta.test.ts index 90da87e..c163e91 100644 --- a/tests/meta.test.ts +++ b/tests/meta.test.ts @@ -1353,7 +1353,10 @@ test('create new per-record trigger with filters for creating records', () => { const models: Array = [ { slug: 'team', - fields: [{ slug: 'handle', type: 'string' }, { slug: 'createdBy', type: 'string' }], + fields: [ + { slug: 'handle', type: 'string' }, + { slug: 'createdBy', type: 'string' }, + ], }, { slug: 'member', From 1171eb38c99891c8c088e501b0fe50d2b31906e3 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Wed, 13 Nov 2024 18:29:04 +0100 Subject: [PATCH 07/28] Made more tests work --- src/utils/statement.ts | 6 +++++- tests/instructions/for.test.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/utils/statement.ts b/src/utils/statement.ts index 560149d..9f987b3 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -105,6 +105,10 @@ const composeFieldValues = ( compileQueryInput(symbol.value, models, statementParams).main.statement })`; } else if (symbol?.type === 'expression') { + if (collectStatementValue) { + conditionSelector = `${options.customTable ? `"${options.customTable}".` : ''}"${modelField.slug}"`; + } + conditionValue = symbol.value.replace(RONIN_MODEL_FIELD_REGEX, (match) => { let targetTable: string | undefined; let toReplace: string = RONIN_MODEL_SYMBOLS.FIELD; @@ -125,7 +129,7 @@ const composeFieldValues = ( targetTable = options.rootTable; toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT; - if (options.rootTable && !collectStatementValue) + if (options.rootTable && !options.rootTable.startsWith('sub_')) rootModel = getModelBySlug(models, options.rootTable); } diff --git a/tests/instructions/for.test.ts b/tests/instructions/for.test.ts index 9632856..a3eeeed 100644 --- a/tests/instructions/for.test.ts +++ b/tests/instructions/for.test.ts @@ -430,7 +430,7 @@ test('get single record including child records (one-to-many, defined manually)' expect(statements).toEqual([ { - statement: `SELECT * FROM (SELECT * FROM "posts" LIMIT 1) as sub_posts LEFT JOIN "ronin_link_post_comments" as including_comments ON ("including_comments"."id" = "sub_posts"."comments")`, + statement: `SELECT * FROM (SELECT * FROM "posts" LIMIT 1) as sub_posts LEFT JOIN "ronin_link_post_comments" as including_comments ON ("including_comments"."source" = "sub_posts"."id")`, params: [], returning: true, }, From 5ba54eeaab4d4cfe71efdbd605ff3d2cc65a0cbd Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 09:15:02 +0100 Subject: [PATCH 08/28] Ensure that all tests are working --- src/utils/index.ts | 3 +-- src/utils/statement.ts | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/utils/index.ts b/src/utils/index.ts index 188c85e..f2911ca 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -184,8 +184,7 @@ export const compileQueryInput = ( } const withStatement = handleWith(models, model, statementParams, instructions?.with, { - rootTable: isJoining ? table : undefined, - customTable, + rootTable: isJoining ? table : customTable, }); if (withStatement.length > 0) conditions.push(withStatement); diff --git a/src/utils/statement.ts b/src/utils/statement.ts index 9f987b3..b2fd87a 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -119,18 +119,30 @@ const composeFieldValues = ( targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_OLD; if (options.customTable) rootModel = getModelBySlug(models, options.customTable); + + if (options.rootTable) { + const cleanModelSlug = options.rootTable.replace('sub_', ''); + rootModel = getModelBySlug(models, cleanModelSlug); + } } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_NEW)) { targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_NEW; if (options.customTable) rootModel = getModelBySlug(models, options.customTable); + + if (options.rootTable) { + const cleanModelSlug = options.rootTable.replace('sub_', ''); + rootModel = getModelBySlug(models, cleanModelSlug); + } } if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT)) { targetTable = options.rootTable; toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT; - if (options.rootTable && !options.rootTable.startsWith('sub_')) - rootModel = getModelBySlug(models, options.rootTable); + if (options.rootTable) { + const cleanModelSlug = options.rootTable.replace('sub_', ''); + rootModel = getModelBySlug(models, cleanModelSlug); + } } const fieldSlug = match.replace(toReplace, ''); From bd0a7ab23e63544aadd408cfc2efc0df94b6520c Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 09:47:24 +0100 Subject: [PATCH 09/28] Removed unused code --- src/utils/statement.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/utils/statement.ts b/src/utils/statement.ts index b2fd87a..f7ee311 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -149,20 +149,6 @@ const composeFieldValues = ( return getFieldFromModel(rootModel, fieldSlug, instructionName, targetTable) .fieldSelector; }); - } else if (typeof value === 'string' && value.startsWith(RONIN_MODEL_SYMBOLS.FIELD)) { - let targetTable = `"${options.rootTable}"`; - let toReplace: string = RONIN_MODEL_SYMBOLS.FIELD; - - if (value.startsWith(RONIN_MODEL_SYMBOLS.FIELD_OLD)) { - targetTable = 'OLD'; - toReplace = RONIN_MODEL_SYMBOLS.FIELD_OLD; - } else if (value.startsWith(RONIN_MODEL_SYMBOLS.FIELD_NEW)) { - targetTable = 'NEW'; - toReplace = RONIN_MODEL_SYMBOLS.FIELD_NEW; - } - - conditionSelector = `${options.customTable ? `"${options.customTable}".` : ''}"${modelField.slug}"`; - conditionValue = `${targetTable}."${value.replace(toReplace, '')}"`; } else if (collectStatementValue) { conditionValue = prepareStatementValue(statementParams, value); } From 4c399d2bb547642f37de78ec7e9b476f72036824 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 09:51:18 +0100 Subject: [PATCH 10/28] Removed more unused code --- src/utils/helpers.ts | 4 ++-- src/utils/model.ts | 4 ++-- src/utils/statement.ts | 30 ++++++++++-------------------- 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index bac1736..b85dd9f 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -31,10 +31,10 @@ export const RONIN_MODEL_SYMBOLS = { FIELD: '__RONIN_FIELD_', // Represents the old value of a field in a model. Used for triggers. - FIELD_OLD: '__RONIN_FIELD_OLD_', + FIELD_OLD: '__RONIN_FIELD_PARENT_OLD_', // Represents the new value of a field in a model. Used for triggers. - FIELD_NEW: '__RONIN_FIELD_NEW_', + FIELD_NEW: '__RONIN_FIELD_PARENT_NEW_', // Represents a value provided to a query preset. VALUE: '__RONIN_VALUE', diff --git a/src/utils/model.ts b/src/utils/model.ts index 9112f1e..4588f51 100644 --- a/src/utils/model.ts +++ b/src/utils/model.ts @@ -97,8 +97,8 @@ const getFieldSelector = ( instructionName: QueryInstructionType, rootTable?: string, ) => { - const symbol = rootTable?.startsWith(RONIN_MODEL_SYMBOLS.FIELD) - ? `${rootTable.replace(RONIN_MODEL_SYMBOLS.FIELD, '').slice(0, -1)}.` + const symbol = rootTable?.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT) + ? `${rootTable.replace(RONIN_MODEL_SYMBOLS.FIELD_PARENT, '').slice(0, -1)}.` : ''; const tablePrefix = symbol || (rootTable ? `"${rootTable}".` : ''); diff --git a/src/utils/statement.ts b/src/utils/statement.ts index f7ee311..b62ae3f 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -115,30 +115,20 @@ const composeFieldValues = ( let rootModel = model; - if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_OLD)) { - targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_OLD; - - if (options.customTable) rootModel = getModelBySlug(models, options.customTable); - - if (options.rootTable) { - const cleanModelSlug = options.rootTable.replace('sub_', ''); - rootModel = getModelBySlug(models, cleanModelSlug); - } - } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_NEW)) { - targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_NEW; - - if (options.customTable) rootModel = getModelBySlug(models, options.customTable); - - if (options.rootTable) { - const cleanModelSlug = options.rootTable.replace('sub_', ''); - rootModel = getModelBySlug(models, cleanModelSlug); - } - } - if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT)) { targetTable = options.rootTable; toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT; + if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_OLD)) { + targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_OLD; + if (options.customTable) + rootModel = getModelBySlug(models, options.customTable); + } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_NEW)) { + targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_NEW; + if (options.customTable) + rootModel = getModelBySlug(models, options.customTable); + } + if (options.rootTable) { const cleanModelSlug = options.rootTable.replace('sub_', ''); rootModel = getModelBySlug(models, cleanModelSlug); From 85f4c80da9989dfce9064eaf3d9537ac0a648946 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 10:42:31 +0100 Subject: [PATCH 11/28] Removed unused variable --- src/utils/model.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/utils/model.ts b/src/utils/model.ts index 4588f51..d980fb7 100644 --- a/src/utils/model.ts +++ b/src/utils/model.ts @@ -66,8 +66,6 @@ export const getTableForModel = (model: Model): string => { return convertToSnakeCase(model.pluralSlug as string); }; -const RONIN_MODEL_LINK_PREFIX = 'ronin_link_'; - /** * Composes the slug of an associative model that is used to establish a relationship * between two models that are not directly related to each other. @@ -78,7 +76,7 @@ const RONIN_MODEL_LINK_PREFIX = 'ronin_link_'; * @returns A slug for the associative model. */ export const composeAssociationModelSlug = (model: PublicModel, field: ModelField) => - convertToCamelCase(`${RONIN_MODEL_LINK_PREFIX}_${model.slug}_${field.slug}`); + convertToCamelCase(`ronin_link_${model.slug}_${field.slug}`); /** * Constructs the SQL selector for a given field in a model. From d96dfb78694c167243985e6c47a43afb0fc0bbc5 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 10:43:31 +0100 Subject: [PATCH 12/28] Use more clear naming --- src/instructions/including.ts | 2 +- src/instructions/to.ts | 6 +++--- src/instructions/with.ts | 4 ++-- src/utils/index.ts | 12 ++++++------ src/utils/statement.ts | 12 ++++++------ 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/instructions/including.ts b/src/instructions/including.ts index 7139446..a192b15 100644 --- a/src/instructions/including.ts +++ b/src/instructions/including.ts @@ -115,7 +115,7 @@ export const handleIncluding = ( queryInstructions?.with as WithFilters, { rootTable: rootTableName, - customTable: tableAlias, + parentTable: tableAlias, }, ); diff --git a/src/instructions/to.ts b/src/instructions/to.ts index 9f1ea2a..5396464 100644 --- a/src/instructions/to.ts +++ b/src/instructions/to.ts @@ -41,7 +41,7 @@ export const handleTo = ( with: NonNullable | undefined; to: NonNullable; }, - options?: { rootTable?: string; customTable?: string }, + options?: { rootTable?: string; parentTable?: string }, ): string => { const currentTime = new Date().toISOString(); const { with: withInstruction, to: toInstruction } = instructions; @@ -188,7 +188,7 @@ export const handleTo = ( let statement = composeConditions(models, model, statementParams, 'to', toInstruction, { rootTable: options?.rootTable, - customTable: options?.customTable, + parentTable: options?.parentTable, type: queryType === 'create' ? 'fields' : undefined, }); @@ -201,7 +201,7 @@ export const handleTo = ( toInstruction, { rootTable: options?.rootTable, - customTable: options?.customTable, + parentTable: options?.parentTable, type: 'values', }, ); diff --git a/src/instructions/with.ts b/src/instructions/with.ts index f47f5a4..c82b9fa 100644 --- a/src/instructions/with.ts +++ b/src/instructions/with.ts @@ -67,7 +67,7 @@ export const handleWith = ( model: Model, statementParams: Array | null, instruction: GetInstructions['with'], - options?: { rootTable?: string; customTable?: string }, + options?: { rootTable?: string; parentTable?: string }, ): string => { const subStatement = composeConditions( models, @@ -75,7 +75,7 @@ export const handleWith = ( statementParams, 'with', instruction as WithFilters, - { rootTable: options?.rootTable, customTable: options?.customTable }, + { rootTable: options?.rootTable, parentTable: options?.parentTable }, ); return `(${subStatement})`; diff --git a/src/utils/index.ts b/src/utils/index.ts index f2911ca..2e53f4c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -150,10 +150,10 @@ export const compileQueryInput = ( }); } - let customTable: string | undefined; + let parentTable: string | undefined; if (options?.rootModel) { - customTable = getTableForModel(options.rootModel); + parentTable = getTableForModel(options.rootModel); } const toStatement = handleTo( @@ -165,7 +165,7 @@ export const compileQueryInput = ( { with: instructions.with, to: instructions.to }, { rootTable: isJoining ? table : undefined, - customTable, + parentTable, }, ); @@ -177,14 +177,14 @@ export const compileQueryInput = ( // Queries of type "get", "set", "drop", or "count" all support filtering records, but // those of type "create" do not. if (queryType !== 'create' && instructions && Object.hasOwn(instructions, 'with')) { - let customTable: string | undefined; + let parentTable: string | undefined; if (options?.rootModel) { - customTable = getTableForModel(options.rootModel); + parentTable = getTableForModel(options.rootModel); } const withStatement = handleWith(models, model, statementParams, instructions?.with, { - rootTable: isJoining ? table : customTable, + rootTable: isJoining ? table : parentTable, }); if (withStatement.length > 0) conditions.push(withStatement); diff --git a/src/utils/statement.ts b/src/utils/statement.ts index b62ae3f..47dc028 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -81,7 +81,7 @@ const composeFieldValues = ( rootTable?: string; fieldSlug: string; type?: 'fields' | 'values'; - customTable?: string; + parentTable?: string; condition?: WithCondition; }, ): string => { @@ -106,7 +106,7 @@ const composeFieldValues = ( })`; } else if (symbol?.type === 'expression') { if (collectStatementValue) { - conditionSelector = `${options.customTable ? `"${options.customTable}".` : ''}"${modelField.slug}"`; + conditionSelector = `${options.parentTable ? `"${options.parentTable}".` : ''}"${modelField.slug}"`; } conditionValue = symbol.value.replace(RONIN_MODEL_FIELD_REGEX, (match) => { @@ -121,12 +121,12 @@ const composeFieldValues = ( if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_OLD)) { targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_OLD; - if (options.customTable) - rootModel = getModelBySlug(models, options.customTable); + if (options.parentTable) + rootModel = getModelBySlug(models, options.parentTable); } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_NEW)) { targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_NEW; - if (options.customTable) - rootModel = getModelBySlug(models, options.customTable); + if (options.parentTable) + rootModel = getModelBySlug(models, options.parentTable); } if (options.rootTable) { From 1b4a165fba0e758efabf5ad7463905033247ce78 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 11:00:31 +0100 Subject: [PATCH 13/28] Ensure cleaner logic --- src/instructions/including.ts | 4 ++-- src/utils/index.ts | 3 ++- src/utils/statement.ts | 12 ++++-------- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/instructions/including.ts b/src/instructions/including.ts index a192b15..f05103d 100644 --- a/src/instructions/including.ts +++ b/src/instructions/including.ts @@ -114,8 +114,8 @@ export const handleIncluding = ( 'including', queryInstructions?.with as WithFilters, { - rootTable: rootTableName, - parentTable: tableAlias, + rootTable: tableAlias, + parentTable: rootTableName, }, ); diff --git a/src/utils/index.ts b/src/utils/index.ts index 2e53f4c..00ed0ef 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -184,7 +184,8 @@ export const compileQueryInput = ( } const withStatement = handleWith(models, model, statementParams, instructions?.with, { - rootTable: isJoining ? table : parentTable, + rootTable: isJoining ? table : undefined, + parentTable, }); if (withStatement.length > 0) conditions.push(withStatement); diff --git a/src/utils/statement.ts b/src/utils/statement.ts index 47dc028..0d7a217 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -106,7 +106,7 @@ const composeFieldValues = ( })`; } else if (symbol?.type === 'expression') { if (collectStatementValue) { - conditionSelector = `${options.parentTable ? `"${options.parentTable}".` : ''}"${modelField.slug}"`; + conditionSelector = `${options.rootTable ? `"${options.rootTable}".` : ''}"${modelField.slug}"`; } conditionValue = symbol.value.replace(RONIN_MODEL_FIELD_REGEX, (match) => { @@ -116,21 +116,17 @@ const composeFieldValues = ( let rootModel = model; if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT)) { - targetTable = options.rootTable; + targetTable = options.parentTable; toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT; if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_OLD)) { targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_OLD; - if (options.parentTable) - rootModel = getModelBySlug(models, options.parentTable); } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_NEW)) { targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_NEW; - if (options.parentTable) - rootModel = getModelBySlug(models, options.parentTable); } - if (options.rootTable) { - const cleanModelSlug = options.rootTable.replace('sub_', ''); + if (options.parentTable) { + const cleanModelSlug = options.parentTable.replace('sub_', ''); rootModel = getModelBySlug(models, cleanModelSlug); } } From 72ba054b68f7ee30350554f2dfa35c334e0ccc2b Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 11:05:05 +0100 Subject: [PATCH 14/28] Use even shorter code --- src/utils/statement.ts | 63 ++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/src/utils/statement.ts b/src/utils/statement.ts index 0d7a217..561a065 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -95,46 +95,55 @@ const composeFieldValues = ( // If only the field selectors are being requested, do not register any values. const collectStatementValue = options.type !== 'fields'; + // Determine if the value of the field is a symbol. const symbol = getSymbol(value); let conditionSelector = selector; let conditionValue = value; - if (symbol?.type === 'query' && collectStatementValue) { - conditionValue = `(${ - compileQueryInput(symbol.value, models, statementParams).main.statement - })`; - } else if (symbol?.type === 'expression') { - if (collectStatementValue) { - conditionSelector = `${options.rootTable ? `"${options.rootTable}".` : ''}"${modelField.slug}"`; + if (symbol) { + if (symbol.type === 'query' && collectStatementValue) { + conditionValue = `(${ + compileQueryInput(symbol.value, models, statementParams).main.statement + })`; } - conditionValue = symbol.value.replace(RONIN_MODEL_FIELD_REGEX, (match) => { - let targetTable: string | undefined; - let toReplace: string = RONIN_MODEL_SYMBOLS.FIELD; + if (symbol?.type === 'expression') { + conditionSelector = `${options.rootTable ? `"${options.rootTable}".` : ''}"${modelField.slug}"`; - let rootModel = model; + conditionValue = symbol.value.replace(RONIN_MODEL_FIELD_REGEX, (match) => { + let targetTable: string | undefined; + let toReplace: string = RONIN_MODEL_SYMBOLS.FIELD; - if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT)) { - targetTable = options.parentTable; - toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT; + let rootModel = model; - if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_OLD)) { - targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_OLD; - } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_NEW)) { - targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_NEW; - } + if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT)) { + targetTable = options.parentTable; + toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT; - if (options.parentTable) { - const cleanModelSlug = options.parentTable.replace('sub_', ''); - rootModel = getModelBySlug(models, cleanModelSlug); + if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_OLD)) { + targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_OLD; + } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_NEW)) { + targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_NEW; + } + + if (options.parentTable) { + const cleanModelSlug = options.parentTable.replace('sub_', ''); + rootModel = getModelBySlug(models, cleanModelSlug); + } } - } - const fieldSlug = match.replace(toReplace, ''); - return getFieldFromModel(rootModel, fieldSlug, instructionName, targetTable) - .fieldSelector; - }); + const fieldSlug = match.replace(toReplace, ''); + const field = getFieldFromModel( + rootModel, + fieldSlug, + instructionName, + targetTable, + ); + + return field.fieldSelector; + }); + } } else if (collectStatementValue) { conditionValue = prepareStatementValue(statementParams, value); } From aa789b3375f23326cad1f90ee9c4cbfc2d42e4cb Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 11:06:53 +0100 Subject: [PATCH 15/28] Added code comments --- src/utils/statement.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/utils/statement.ts b/src/utils/statement.ts index 561a065..9155fad 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -102,12 +102,8 @@ const composeFieldValues = ( let conditionValue = value; if (symbol) { - if (symbol.type === 'query' && collectStatementValue) { - conditionValue = `(${ - compileQueryInput(symbol.value, models, statementParams).main.statement - })`; - } - + // 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') { conditionSelector = `${options.rootTable ? `"${options.rootTable}".` : ''}"${modelField.slug}"`; @@ -144,6 +140,14 @@ const composeFieldValues = ( return field.fieldSelector; }); } + + // The value of the field is a RONIN query, which we need to compile into an SQL + // syntax that can be run. + if (symbol.type === 'query' && collectStatementValue) { + conditionValue = `(${ + compileQueryInput(symbol.value, models, statementParams).main.statement + })`; + } } else if (collectStatementValue) { conditionValue = prepareStatementValue(statementParams, value); } From 7db38ac39408e6930b99cb0f99ac07ab103bfe0f Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 11:17:30 +0100 Subject: [PATCH 16/28] Removed even more code --- src/instructions/to.ts | 6 ++---- src/instructions/with.ts | 2 +- src/utils/statement.ts | 7 ++----- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/instructions/to.ts b/src/instructions/to.ts index 5396464..6c2d876 100644 --- a/src/instructions/to.ts +++ b/src/instructions/to.ts @@ -187,8 +187,7 @@ export const handleTo = ( } let statement = composeConditions(models, model, statementParams, 'to', toInstruction, { - rootTable: options?.rootTable, - parentTable: options?.parentTable, + ...options, type: queryType === 'create' ? 'fields' : undefined, }); @@ -200,8 +199,7 @@ export const handleTo = ( 'to', toInstruction, { - rootTable: options?.rootTable, - parentTable: options?.parentTable, + ...options, type: 'values', }, ); diff --git a/src/instructions/with.ts b/src/instructions/with.ts index c82b9fa..e91fc96 100644 --- a/src/instructions/with.ts +++ b/src/instructions/with.ts @@ -75,7 +75,7 @@ export const handleWith = ( statementParams, 'with', instruction as WithFilters, - { rootTable: options?.rootTable, parentTable: options?.parentTable }, + { ...options }, ); return `(${subStatement})`; diff --git a/src/utils/statement.ts b/src/utils/statement.ts index 9155fad..f8c74ac 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -78,14 +78,14 @@ const composeFieldValues = ( instructionName: QueryInstructionType, value: WithValue | Record, options: { - rootTable?: string; fieldSlug: string; type?: 'fields' | 'values'; + rootTable?: string; parentTable?: string; condition?: WithCondition; }, ): string => { - const { field: modelField, fieldSelector: selector } = getFieldFromModel( + const { fieldSelector: conditionSelector } = getFieldFromModel( model, options.fieldSlug, instructionName, @@ -98,15 +98,12 @@ const composeFieldValues = ( // Determine if the value of the field is a symbol. const symbol = getSymbol(value); - let conditionSelector = selector; let conditionValue = value; if (symbol) { // 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') { - conditionSelector = `${options.rootTable ? `"${options.rootTable}".` : ''}"${modelField.slug}"`; - conditionValue = symbol.value.replace(RONIN_MODEL_FIELD_REGEX, (match) => { let targetTable: string | undefined; let toReplace: string = RONIN_MODEL_SYMBOLS.FIELD; From 8aa7529e62ffc802154f953357d43cddf18dca8c Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 11:26:13 +0100 Subject: [PATCH 17/28] Shorten code even further --- src/types/model.ts | 5 +++-- src/utils/index.ts | 23 ++++++----------------- src/utils/model.ts | 2 +- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/src/types/model.ts b/src/types/model.ts index 4e935a6..c1a3725 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -110,8 +110,9 @@ export interface Model { }; idPrefix?: string; - // If the model is used as an associative model, this property should contain the field - // to which the associative model should be mounted. + // If the model is used to associate two models with each other (in the case of + // many-cardinality reference fields), this property should contain the field to which + // the associative model should be mounted. associationSlug?: string; fields?: Array; diff --git a/src/utils/index.ts b/src/utils/index.ts index 00ed0ef..25592dc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -38,10 +38,11 @@ export const compileQueryInput = ( */ returning?: boolean; /** - * If the query is the result of a query targeting a different schema, this option - * can contain the original schema for which the original query was processed. + * If the query is contained within another query, this property should be set to the + * name of the table of the parent query. Like that, it becomes possible to reference + * fields of the parent model in the nested query (the current query). */ - rootModel?: Model; + parentTable?: string; }, ): { dependencies: Array; main: Statement } => { // Split out the individual components of the query. @@ -150,12 +151,6 @@ export const compileQueryInput = ( }); } - let parentTable: string | undefined; - - if (options?.rootModel) { - parentTable = getTableForModel(options.rootModel); - } - const toStatement = handleTo( models, model, @@ -165,7 +160,7 @@ export const compileQueryInput = ( { with: instructions.with, to: instructions.to }, { rootTable: isJoining ? table : undefined, - parentTable, + parentTable: options?.parentTable, }, ); @@ -177,15 +172,9 @@ export const compileQueryInput = ( // Queries of type "get", "set", "drop", or "count" all support filtering records, but // those of type "create" do not. if (queryType !== 'create' && instructions && Object.hasOwn(instructions, 'with')) { - let parentTable: string | undefined; - - if (options?.rootModel) { - parentTable = getTableForModel(options.rootModel); - } - const withStatement = handleWith(models, model, statementParams, instructions?.with, { rootTable: isJoining ? table : undefined, - parentTable, + parentTable: options?.parentTable, }); if (withStatement.length > 0) conditions.push(withStatement); diff --git a/src/utils/model.ts b/src/utils/model.ts index d980fb7..cfffd51 100644 --- a/src/utils/model.ts +++ b/src/utils/model.ts @@ -928,7 +928,7 @@ export const addModelQueries = ( const effectStatements = effectQueries.map((effectQuery) => { return compileQueryInput(effectQuery, models, params, { returning: false, - rootModel: currentModel, + parentTable: tableName, }).main.statement; }); From 2df0cfaf8968ba70ef33200fc8623ed95a993cbe Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 11:27:23 +0100 Subject: [PATCH 18/28] Removed two unnecessary tests --- tests/instructions/to.test.ts | 86 ----------------------------------- 1 file changed, 86 deletions(-) diff --git a/tests/instructions/to.test.ts b/tests/instructions/to.test.ts index eb6596b..ffc4623 100644 --- a/tests/instructions/to.test.ts +++ b/tests/instructions/to.test.ts @@ -383,49 +383,6 @@ test('set single record to new json field with array', () => { ]); }); -test('set single record to new json field with empty array', () => { - const queries: Array = [ - { - set: { - account: { - with: { - handle: 'elaine', - }, - to: { - emails: [], - }, - }, - }, - }, - ]; - - const models: Array = [ - { - slug: 'account', - fields: [ - { - slug: 'handle', - type: 'string', - }, - { - slug: 'emails', - type: 'json', - }, - ], - }, - ]; - - const statements = compileQueries(queries, models); - - expect(statements).toEqual([ - { - statement: `UPDATE "accounts" SET "emails" = ?1, "ronin.updatedAt" = ?2 WHERE ("handle" = ?3) RETURNING *`, - params: ['[]', expect.stringMatching(RECORD_TIMESTAMP_REGEX), 'elaine'], - returning: true, - }, - ]); -}); - test('set single record to new json field with object', () => { const queries: Array = [ { @@ -476,49 +433,6 @@ test('set single record to new json field with object', () => { ]); }); -test('set single record to new json field with empty object', () => { - const queries: Array = [ - { - set: { - account: { - with: { - handle: 'elaine', - }, - to: { - emails: {}, - }, - }, - }, - }, - ]; - - const models: Array = [ - { - slug: 'account', - fields: [ - { - slug: 'handle', - type: 'string', - }, - { - slug: 'emails', - type: 'json', - }, - ], - }, - ]; - - const statements = compileQueries(queries, models); - - expect(statements).toEqual([ - { - statement: `UPDATE "accounts" SET "emails" = ?1, "ronin.updatedAt" = ?2 WHERE ("handle" = ?3) RETURNING *`, - params: ['{}', expect.stringMatching(RECORD_TIMESTAMP_REGEX), 'elaine'], - returning: true, - }, - ]); -}); - test('set single record to new grouped string field', () => { const queries: Array = [ { From 679cb53c2b39eccc6115c935e4f74f8389cdedf5 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 11:30:52 +0100 Subject: [PATCH 19/28] Use more helpful symbol names --- src/utils/helpers.ts | 16 ++++++++-------- src/utils/model.ts | 4 ++-- src/utils/statement.ts | 8 ++++---- tests/meta.test.ts | 6 +++--- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index b85dd9f..7bf09c6 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -24,17 +24,17 @@ export const RONIN_MODEL_SYMBOLS = { // Represents an expression that should be evaluated. EXPRESSION: '__RONIN_EXPRESSION', - // Represents the value of a parent field in a model. - FIELD_PARENT: '__RONIN_FIELD_PARENT_', - - // Represents the value of a field in a model. + // Represents the value of a field in the model. FIELD: '__RONIN_FIELD_', - // Represents the old value of a field in a model. Used for triggers. - FIELD_OLD: '__RONIN_FIELD_PARENT_OLD_', + // Represents the value of a field in the model of a parent query. + FIELD_PARENT: '__RONIN_FIELD_PARENT_', + + // Represents the old value of a field in the model. Used for triggers. + FIELD_PARENT_OLD: '__RONIN_FIELD_PARENT_OLD_', - // Represents the new value of a field in a model. Used for triggers. - FIELD_NEW: '__RONIN_FIELD_PARENT_NEW_', + // Represents the new value of a field in the model. Used for triggers. + FIELD_PARENT_NEW: '__RONIN_FIELD_PARENT_NEW_', // Represents a value provided to a query preset. VALUE: '__RONIN_VALUE', diff --git a/src/utils/model.ts b/src/utils/model.ts index cfffd51..c25cf5e 100644 --- a/src/utils/model.ts +++ b/src/utils/model.ts @@ -914,8 +914,8 @@ export const addModelQueries = ( if (filterQuery) { const tablePlaceholder = action === 'DELETE' - ? RONIN_MODEL_SYMBOLS.FIELD_OLD - : RONIN_MODEL_SYMBOLS.FIELD_NEW; + ? RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD + : RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW; const withStatement = handleWith(models, currentModel, params, filterQuery, { rootTable: tablePlaceholder, diff --git a/src/utils/statement.ts b/src/utils/statement.ts index f8c74ac..d403f9f 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -114,10 +114,10 @@ const composeFieldValues = ( targetTable = options.parentTable; toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT; - if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_OLD)) { - targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_OLD; - } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_NEW)) { - targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_NEW; + if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD)) { + targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD; + } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW)) { + targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW; } if (options.parentTable) { diff --git a/tests/meta.test.ts b/tests/meta.test.ts index c163e91..f9bb8d6 100644 --- a/tests/meta.test.ts +++ b/tests/meta.test.ts @@ -1155,7 +1155,7 @@ test('create new per-record trigger for creating records', () => { member: { to: { account: { - [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD_NEW}createdBy`, + [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW}createdBy`, }, role: 'owner', pending: false, @@ -1240,7 +1240,7 @@ test('create new per-record trigger for deleting records', () => { members: { with: { account: { - [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD_OLD}createdBy`, + [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD}createdBy`, }, }, }, @@ -1317,7 +1317,7 @@ test('create new per-record trigger with filters for creating records', () => { member: { to: { account: { - [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD_NEW}createdBy`, + [RONIN_MODEL_SYMBOLS.EXPRESSION]: `${RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW}createdBy`, }, role: 'owner', pending: false, From c99ea2cfa956d26b585a844b2a78ea3c5da71d0a Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 11:49:58 +0100 Subject: [PATCH 20/28] Do not expose internal types --- src/types/model.ts | 27 +++++++++++++++++++-------- src/utils/model.ts | 7 ++++--- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/types/model.ts b/src/types/model.ts index c1a3725..3248aae 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -99,20 +99,22 @@ export type ModelPreset = { }; export interface Model { - name?: string; - pluralName?: string; + name: string; + pluralName: string; slug: string; - pluralSlug?: string; + pluralSlug: string; identifiers: { name: string; slug: string; }; - idPrefix?: string; + idPrefix: string; - // If the model is used to associate two models with each other (in the case of - // many-cardinality reference fields), this property should contain the field to which - // the associative model should be mounted. + /** + * If the model is used to associate two models with each other (in the case of + * many-cardinality reference fields), this property should contain the field to which + * the associative model should be mounted. + */ associationSlug?: string; fields?: Array; @@ -121,9 +123,18 @@ export interface Model { presets?: Array; } +type RecursivePartial = { + [P in keyof T]?: T[P] extends object ? RecursivePartial : T[P]; +}; + +export type PartialModel = RecursivePartial; + // In models provided to the compiler, all settings are optional, except for the `slug`, // which is the required bare minimum. -export type PublicModel = Omit, 'slug' | 'identifiers'> & { +export type PublicModel = Omit< + Partial, + 'slug' | 'identifiers' | 'associationSlug' +> & { slug: Required; // It should also be possible for models to only define one of the two identifiers, diff --git a/src/utils/model.ts b/src/utils/model.ts index c25cf5e..05ca662 100644 --- a/src/utils/model.ts +++ b/src/utils/model.ts @@ -6,6 +6,7 @@ import type { ModelIndexField, ModelPreset, ModelTriggerField, + PartialModel, PublicModel, } from '@/src/types/model'; import type { @@ -264,7 +265,7 @@ const modelSettings: Array< * * @returns The updated model. */ -export const addDefaultModelFields = (model: PublicModel, isNew: boolean): Model => { +export const addDefaultModelFields = (model: PartialModel, isNew: boolean): Model => { const copiedModel = { ...model }; for (const [setting, base, generator] of modelSettings) { @@ -493,9 +494,9 @@ const SYSTEM_MODEL_SLUGS = SYSTEM_MODELS.flatMap(({ slug, pluralSlug }) => [ * * @returns The extended list of models. */ -export const addSystemModels = (models: Array): Array => { +export const addSystemModels = (models: Array): Array => { const associativeModels = models.flatMap((model) => { - const addedModels: Array = []; + const addedModels: Array = []; for (const field of model.fields || []) { if (field.type === 'reference' && !field.slug.startsWith('ronin.')) { From 328919640a48cbbd2e398b648a1a7d7047379ce7 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 12:00:15 +0100 Subject: [PATCH 21/28] Removed more unused code --- src/instructions/including.ts | 4 ++-- src/types/model.ts | 3 +++ src/utils/index.ts | 4 ++-- src/utils/model.ts | 21 +++++++++------------ tests/meta.test.ts | 10 ++++++---- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/instructions/including.ts b/src/instructions/including.ts index f05103d..591b606 100644 --- a/src/instructions/including.ts +++ b/src/instructions/including.ts @@ -3,7 +3,7 @@ import type { Model } from '@/src/types/model'; import type { Instructions } from '@/src/types/query'; import { splitQuery } from '@/src/utils/helpers'; import { compileQueryInput } from '@/src/utils/index'; -import { getModelBySlug, getTableForModel } from '@/src/utils/model'; +import { getModelBySlug } from '@/src/utils/model'; import { composeConditions, getSymbol } from '@/src/utils/statement'; /** @@ -50,7 +50,7 @@ export const handleIncluding = ( const relatedModel = getModelBySlug(models, queryModel); let joinType: 'LEFT' | 'CROSS' = 'LEFT'; - let relatedTableSelector = `"${getTableForModel(relatedModel)}"`; + let relatedTableSelector = `"${relatedModel.table}"`; const tableAlias = `including_${ephemeralFieldSlug}`; const single = queryModel !== relatedModel.pluralSlug; diff --git a/src/types/model.ts b/src/types/model.ts index 3248aae..92072b8 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -110,6 +110,9 @@ export interface Model { }; idPrefix: string; + /** The name of the table in SQLite. */ + table: string; + /** * If the model is used to associate two models with each other (in the case of * many-cardinality reference fields), this property should contain the field to which diff --git a/src/utils/index.ts b/src/utils/index.ts index 25592dc..d7213bc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,7 +9,7 @@ import { handleWith } from '@/src/instructions/with'; import type { Model } from '@/src/types/model'; import type { Query, Statement } from '@/src/types/query'; import { RoninError, isObject, splitQuery } from '@/src/utils/helpers'; -import { addModelQueries, getModelBySlug, getTableForModel } from '@/src/utils/model'; +import { addModelQueries, getModelBySlug } from '@/src/utils/model'; import { formatIdentifiers } from '@/src/utils/statement'; /** @@ -61,7 +61,7 @@ export const compileQueryInput = ( // The name of the table in SQLite that contains the records that are being addressed. // This always matches the plural slug of the model, but in snake case. - let table = getTableForModel(model); + let { table } = model; // A list of write statements that are required to be executed before the main read // statement. Their output is not relevant for the main statement, as they are merely diff --git a/src/utils/model.ts b/src/utils/model.ts index 05ca662..abe73aa 100644 --- a/src/utils/model.ts +++ b/src/utils/model.ts @@ -56,17 +56,6 @@ export const getModelBySlug = ( return model; }; -/** - * Composes the SQLite table name for a given RONIN model. - * - * @param model - The model to compose the table name for. - * - * @returns A table name. - */ -export const getTableForModel = (model: Model): string => { - return convertToSnakeCase(model.pluralSlug as string); -}; - /** * Composes the slug of an associative model that is used to establish a relationship * between two models that are not directly related to each other. @@ -239,7 +228,13 @@ const pluralize = (word: string) => { return `${word}s`; }; -type ComposableSettings = 'slug' | 'pluralSlug' | 'name' | 'pluralName' | 'idPrefix'; +type ComposableSettings = + | 'slug' + | 'pluralSlug' + | 'name' + | 'pluralName' + | 'idPrefix' + | 'table'; /** * A list of settings that can be automatically generated based on other settings. @@ -255,6 +250,7 @@ const modelSettings: Array< ['name', 'slug', slugToName], ['pluralName', 'pluralSlug', slugToName], ['idPrefix', 'slug', (slug: string) => slug.slice(0, 3)], + ['table', 'pluralSlug', convertToSnakeCase], ]; /** @@ -375,6 +371,7 @@ const SYSTEM_MODELS: Array = [ { slug: 'pluralSlug', type: 'string' }, { slug: 'idPrefix', type: 'string' }, + { slug: 'table', type: 'string' }, { slug: 'identifiers', type: 'group' }, { slug: 'identifiers.name', type: 'string' }, diff --git a/tests/meta.test.ts b/tests/meta.test.ts index f9bb8d6..088ca01 100644 --- a/tests/meta.test.ts +++ b/tests/meta.test.ts @@ -47,7 +47,7 @@ test('create new model', () => { }, { statement: - 'INSERT INTO "models" ("slug", "fields", "pluralSlug", "name", "pluralName", "idPrefix", "identifiers.name", "identifiers.slug", "id", "ronin.createdAt", "ronin.updatedAt") VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) RETURNING *', + 'INSERT INTO "models" ("slug", "fields", "pluralSlug", "name", "pluralName", "idPrefix", "table", "identifiers.name", "identifiers.slug", "id", "ronin.createdAt", "ronin.updatedAt") VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) RETURNING *', params: [ 'account', JSON.stringify([...SYSTEM_FIELDS, ...fields]), @@ -55,6 +55,7 @@ test('create new model', () => { 'Account', 'Accounts', 'acc', + 'accounts', 'id', 'id', expect.stringMatching(RECORD_ID_REGEX), @@ -100,8 +101,8 @@ test('create new model with suitable default identifiers', () => { const statements = compileQueries(queries, models); - expect(statements[1].params[6]).toEqual('name'); - expect(statements[1].params[7]).toEqual('handle'); + expect(statements[1].params[7]).toEqual('name'); + expect(statements[1].params[8]).toEqual('handle'); }); // Ensure that, if the `slug` of a model changes during an update, an `ALTER TABLE` @@ -137,13 +138,14 @@ test('update existing model (slug)', () => { }, { statement: - 'UPDATE "models" SET "slug" = ?1, "pluralSlug" = ?2, "name" = ?3, "pluralName" = ?4, "idPrefix" = ?5, "ronin.updatedAt" = ?6 WHERE ("slug" = ?7) RETURNING *', + 'UPDATE "models" SET "slug" = ?1, "pluralSlug" = ?2, "name" = ?3, "pluralName" = ?4, "idPrefix" = ?5, "table" = ?6, "ronin.updatedAt" = ?7 WHERE ("slug" = ?8) RETURNING *', params: [ 'user', 'users', 'User', 'Users', 'use', + 'users', expect.stringMatching(RECORD_TIMESTAMP_REGEX), 'account', ], From be69b997c30de0bda88339991196f670e888d0f1 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 12:11:27 +0100 Subject: [PATCH 22/28] Added table alias concept --- src/instructions/including.ts | 2 +- src/types/model.ts | 7 ++++++- src/utils/model.ts | 14 ++++++++++---- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/instructions/including.ts b/src/instructions/including.ts index 591b606..5e303b5 100644 --- a/src/instructions/including.ts +++ b/src/instructions/including.ts @@ -109,7 +109,7 @@ export const handleIncluding = ( const subStatement = composeConditions( models, - relatedModel, + { ...relatedModel, tableAlias }, statementParams, 'including', queryInstructions?.with as WithFilters, diff --git a/src/types/model.ts b/src/types/model.ts index 92072b8..ca55549 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -112,6 +112,11 @@ export interface Model { /** The name of the table in SQLite. */ table: string; + /** + * The table name to which the model was aliased. This will be set in the case that + * multiple tables are being joined into one SQL statement. + */ + tableAlias?: string; /** * If the model is used to associate two models with each other (in the case of @@ -136,7 +141,7 @@ export type PartialModel = RecursivePartial; // which is the required bare minimum. export type PublicModel = Omit< Partial, - 'slug' | 'identifiers' | 'associationSlug' + 'slug' | 'identifiers' | 'associationSlug' | 'table' > & { slug: Required; diff --git a/src/utils/model.ts b/src/utils/model.ts index abe73aa..b0a19e7 100644 --- a/src/utils/model.ts +++ b/src/utils/model.ts @@ -910,14 +910,20 @@ export const addModelQueries = ( // instructions will be validated for every row, and only if they match, the trigger // will then be fired. if (filterQuery) { - const tablePlaceholder = + const tableAlias = action === 'DELETE' ? RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD : RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW; - const withStatement = handleWith(models, currentModel, params, filterQuery, { - rootTable: tablePlaceholder, - }); + const withStatement = handleWith( + models, + { ...currentModel, table: tableAlias }, + params, + filterQuery, + { + rootTable: tableAlias, + }, + ); statementParts.push('WHEN', `(${withStatement})`); } From 9fc7e2a1e4bfcb5c285a7961d109c70f68c1a2d5 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 12:29:02 +0100 Subject: [PATCH 23/28] Removed a lot more unused code --- src/instructions/before-after.ts | 9 +------- src/instructions/including.ts | 1 - src/instructions/ordered-by.ts | 4 ---- src/instructions/to.ts | 2 +- src/instructions/with.ts | 2 +- src/utils/index.ts | 32 ++++++++++++---------------- src/utils/model.ts | 28 ++++++++----------------- src/utils/statement.ts | 36 ++++++++++---------------------- 8 files changed, 36 insertions(+), 78 deletions(-) diff --git a/src/instructions/before-after.ts b/src/instructions/before-after.ts index 8b41455..916a3e7 100644 --- a/src/instructions/before-after.ts +++ b/src/instructions/before-after.ts @@ -21,7 +21,6 @@ export const CURSOR_NULL_PLACEHOLDER = 'RONIN_NULL'; * @param statementParams - A collection of values that will automatically be * inserted into the query by SQLite. * @param instructions - The instructions associated with the current query. - * @param rootTable - The table for which the current query is being executed. * * @returns The SQL syntax for the provided `before` or `after` instruction. */ @@ -35,7 +34,6 @@ export const handleBeforeOrAfter = ( orderedBy: GetInstructions['orderedBy']; limitedTo?: GetInstructions['limitedTo']; }, - rootTable?: string, ): string => { if (!(instructions.before || instructions.after)) { throw new RoninError({ @@ -124,12 +122,7 @@ export const handleBeforeOrAfter = ( const key = keys[j]; const value = values[j]; - let { field, fieldSelector } = getFieldFromModel( - model, - key, - 'orderedBy', - rootTable, - ); + let { field, fieldSelector } = getFieldFromModel(model, key, 'orderedBy'); // If we're at the current field, add the comparison to the condition. if (j === i) { diff --git a/src/instructions/including.ts b/src/instructions/including.ts index 5e303b5..dfab968 100644 --- a/src/instructions/including.ts +++ b/src/instructions/including.ts @@ -114,7 +114,6 @@ export const handleIncluding = ( 'including', queryInstructions?.with as WithFilters, { - rootTable: tableAlias, parentTable: rootTableName, }, ); diff --git a/src/instructions/ordered-by.ts b/src/instructions/ordered-by.ts index 5ced601..67536d3 100644 --- a/src/instructions/ordered-by.ts +++ b/src/instructions/ordered-by.ts @@ -8,14 +8,12 @@ import { getFieldFromModel } from '@/src/utils/model'; * * @param model - The model being addressed in the query. * @param instruction - The `orderedBy` instruction provided in the current query. - * @param rootTable - The table for which the current query is being executed. * * @returns The SQL syntax for the provided `orderedBy` instruction. */ export const handleOrderedBy = ( model: Model, instruction: GetInstructions['orderedBy'], - rootTable?: string, ): string => { let statement = ''; @@ -25,7 +23,6 @@ export const handleOrderedBy = ( model, field, 'orderedBy.ascending', - rootTable, ); if (statement.length > 0) { @@ -48,7 +45,6 @@ export const handleOrderedBy = ( model, field, 'orderedBy.descending', - rootTable, ); if (statement.length > 0) { diff --git a/src/instructions/to.ts b/src/instructions/to.ts index 6c2d876..bf11e2f 100644 --- a/src/instructions/to.ts +++ b/src/instructions/to.ts @@ -41,7 +41,7 @@ export const handleTo = ( with: NonNullable | undefined; to: NonNullable; }, - options?: { rootTable?: string; parentTable?: string }, + options?: { parentTable?: string }, ): string => { const currentTime = new Date().toISOString(); const { with: withInstruction, to: toInstruction } = instructions; diff --git a/src/instructions/with.ts b/src/instructions/with.ts index e91fc96..99486e7 100644 --- a/src/instructions/with.ts +++ b/src/instructions/with.ts @@ -67,7 +67,7 @@ export const handleWith = ( model: Model, statementParams: Array | null, instruction: GetInstructions['with'], - options?: { rootTable?: string; parentTable?: string }, + options?: { parentTable?: string }, ): string => { const subStatement = composeConditions( models, diff --git a/src/utils/index.ts b/src/utils/index.ts index d7213bc..68f3925 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -136,6 +136,11 @@ export const compileQueryInput = ( } statement += `${including} `; + + // Show the table name for every column. By default, it doesn't show, but since we + // are joining multiple tables together, we need to show the table name for every + // table, in order to avoid conflicts. + model.tableAlias = table; } else { statement += `"${table}" `; } @@ -159,7 +164,6 @@ export const compileQueryInput = ( dependencyStatements, { with: instructions.with, to: instructions.to }, { - rootTable: isJoining ? table : undefined, parentTable: options?.parentTable, }, ); @@ -173,7 +177,6 @@ export const compileQueryInput = ( // those of type "create" do not. if (queryType !== 'create' && instructions && Object.hasOwn(instructions, 'with')) { const withStatement = handleWith(models, model, statementParams, instructions?.with, { - rootTable: isJoining ? table : undefined, parentTable: options?.parentTable, }); @@ -225,18 +228,13 @@ export const compileQueryInput = ( }); } - const beforeAndAfterStatement = handleBeforeOrAfter( - model, - statementParams, - { - before: instructions.before, - after: instructions.after, - with: instructions.with, - orderedBy: instructions.orderedBy, - limitedTo: instructions.limitedTo, - }, - isJoining ? table : undefined, - ); + const beforeAndAfterStatement = handleBeforeOrAfter(model, statementParams, { + before: instructions.before, + after: instructions.after, + with: instructions.with, + orderedBy: instructions.orderedBy, + limitedTo: instructions.limitedTo, + }); conditions.push(beforeAndAfterStatement); } @@ -251,11 +249,7 @@ export const compileQueryInput = ( } if (instructions?.orderedBy) { - const orderedByStatement = handleOrderedBy( - model, - instructions.orderedBy, - isJoining ? table : undefined, - ); + const orderedByStatement = handleOrderedBy(model, instructions.orderedBy); statement += `${orderedByStatement} `; } diff --git a/src/utils/model.ts b/src/utils/model.ts index b0a19e7..3426dfe 100644 --- a/src/utils/model.ts +++ b/src/utils/model.ts @@ -71,24 +71,24 @@ export const composeAssociationModelSlug = (model: PublicModel, field: ModelFiel /** * Constructs the SQL selector for a given field in a model. * - * @param field - A field from a model. + * @param model - The model to which the field belongs. + * @param field - A field from the model. * @param fieldPath - The path of the field being addressed. Supports dot notation for * accessing nested fields. * @param instructionName - The name of the query instruction that is being used. - * @param rootTable - The name of a table, if it should be included in the SQL selector. * * @returns The SQL column selector for the provided field. */ const getFieldSelector = ( + model: Model, field: ModelField, fieldPath: string, instructionName: QueryInstructionType, - rootTable?: string, ) => { - const symbol = rootTable?.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT) - ? `${rootTable.replace(RONIN_MODEL_SYMBOLS.FIELD_PARENT, '').slice(0, -1)}.` + const symbol = model.tableAlias?.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT) + ? `${model.tableAlias.replace(RONIN_MODEL_SYMBOLS.FIELD_PARENT, '').slice(0, -1)}.` : ''; - const tablePrefix = symbol || (rootTable ? `"${rootTable}".` : ''); + const tablePrefix = symbol || (model.tableAlias ? `"${model.tableAlias}".` : ''); // If the field is of type JSON and the field is being selected in a read query, that // means we should extract the nested property from the JSON field. @@ -110,7 +110,6 @@ const getFieldSelector = ( * @param fieldPath - The path of the field to retrieve. Supports dot notation for * accessing nested fields. * @param instructionName - The name of the query instruction that is being used. - * @param rootTable - The table for which the current query is being executed. * * @returns The requested field of the model, and its SQL selector. */ @@ -118,7 +117,6 @@ export const getFieldFromModel = ( model: Model, fieldPath: string, instructionName: QueryInstructionType, - rootTable?: string, ): { field: ModelField; fieldSelector: string } => { const errorPrefix = `Field "${fieldPath}" defined for \`${instructionName}\``; const modelFields = model.fields || []; @@ -132,10 +130,10 @@ export const getFieldFromModel = ( if (modelField?.type === 'json') { const fieldSelector = getFieldSelector( + model, modelField, fieldPath, instructionName, - rootTable, ); return { field: modelField, fieldSelector }; } @@ -152,12 +150,7 @@ export const getFieldFromModel = ( }); } - const fieldSelector = getFieldSelector( - modelField, - fieldPath, - instructionName, - rootTable, - ); + const fieldSelector = getFieldSelector(model, modelField, fieldPath, instructionName); return { field: modelField, fieldSelector }; }; @@ -917,12 +910,9 @@ export const addModelQueries = ( const withStatement = handleWith( models, - { ...currentModel, table: tableAlias }, + { ...currentModel, tableAlias: tableAlias }, params, filterQuery, - { - rootTable: tableAlias, - }, ); statementParts.push('WHEN', `(${withStatement})`); diff --git a/src/utils/statement.ts b/src/utils/statement.ts index d403f9f..6fb8295 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -80,7 +80,6 @@ const composeFieldValues = ( options: { fieldSlug: string; type?: 'fields' | 'values'; - rootTable?: string; parentTable?: string; condition?: WithCondition; }, @@ -89,7 +88,6 @@ const composeFieldValues = ( model, options.fieldSlug, instructionName, - options.rootTable, ); // If only the field selectors are being requested, do not register any values. @@ -105,34 +103,27 @@ const composeFieldValues = ( // syntax that can be run. if (symbol?.type === 'expression') { conditionValue = symbol.value.replace(RONIN_MODEL_FIELD_REGEX, (match) => { - let targetTable: string | undefined; let toReplace: string = RONIN_MODEL_SYMBOLS.FIELD; - - let rootModel = model; + let rootModel: Model = model; if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT)) { - targetTable = options.parentTable; + if (options.parentTable) { + const cleanModelSlug = options.parentTable.replace('sub_', ''); + rootModel = getModelBySlug(models, cleanModelSlug); + } + + rootModel.tableAlias = options.parentTable; toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT; if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD)) { - targetTable = toReplace = 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)) { - targetTable = toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW; - } - - if (options.parentTable) { - const cleanModelSlug = options.parentTable.replace('sub_', ''); - rootModel = getModelBySlug(models, cleanModelSlug); + rootModel.tableAlias = toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW; } } const fieldSlug = match.replace(toReplace, ''); - const field = getFieldFromModel( - rootModel, - fieldSlug, - instructionName, - targetTable, - ); + const field = getFieldFromModel(rootModel, fieldSlug, instructionName); return field.fieldSelector; }); @@ -209,12 +200,7 @@ export const composeConditions = ( // potential object value in a special way, instead of just iterating over the nested // fields and trying to assert the column for each one. if (options.fieldSlug) { - const fieldDetails = getFieldFromModel( - model, - options.fieldSlug, - instructionName, - options.rootTable, - ); + const fieldDetails = getFieldFromModel(model, options.fieldSlug, instructionName); const { field: modelField } = fieldDetails; From 0af0a124aae4f3d1bc77a08ad94724c166ed512a Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 12:35:31 +0100 Subject: [PATCH 24/28] Removed even more unused code --- src/instructions/including.ts | 10 +++++----- src/utils/index.ts | 16 +++++++--------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/instructions/including.ts b/src/instructions/including.ts index dfab968..d1eb676 100644 --- a/src/instructions/including.ts +++ b/src/instructions/including.ts @@ -11,18 +11,18 @@ import { composeConditions, getSymbol } from '@/src/utils/statement'; * joining records from other models. * * @param models - A list of models. + * @param model - The model associated with the current query. * @param statementParams - A collection of values that will automatically be * inserted into the query by SQLite. * @param instruction - The `including` instruction provided in the current query. - * @param rootTable - The table for which the current query is being executed. * * @returns The SQL syntax for the provided `including` instruction. */ export const handleIncluding = ( models: Array, + model: Model, statementParams: Array | null, instruction: Instructions['including'], - rootTable?: string, ): { statement: string; rootTableSubQuery?: string; @@ -31,7 +31,7 @@ export const handleIncluding = ( let statement = ''; let rootTableSubQuery: string | undefined; - let rootTableName = rootTable; + let rootTableName = model.table; for (const ephemeralFieldSlug in instruction) { const symbol = getSymbol(instruction[ephemeralFieldSlug]); @@ -103,8 +103,8 @@ export const handleIncluding = ( if (joinType === 'LEFT') { if (!single) { - rootTableSubQuery = `SELECT * FROM "${rootTable}" LIMIT 1`; - rootTableName = `sub_${rootTable}`; + rootTableSubQuery = `SELECT * FROM "${rootTableName}" LIMIT 1`; + rootTableName = `sub_${rootTableName}`; } const subStatement = composeConditions( diff --git a/src/utils/index.ts b/src/utils/index.ts index 68f3925..8a029e9 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -59,10 +59,6 @@ export const compileQueryInput = ( // `with` and `including`) are located. let instructions = formatIdentifiers(model, queryInstructions); - // The name of the table in SQLite that contains the records that are being addressed. - // This always matches the plural slug of the model, but in snake case. - let { table } = model; - // A list of write statements that are required to be executed before the main read // statement. Their output is not relevant for the main statement, as they are merely // used to update the database in a way that is required for the main read statement @@ -121,18 +117,20 @@ export const compileQueryInput = ( statement: including, rootTableSubQuery, rootTableName, - } = handleIncluding(models, statementParams, instructions?.including, table); + } = handleIncluding(models, model, statementParams, instructions?.including); + + let tableName = model.table; // If multiple rows are being joined from a different table, even though the root // query is only supposed to return a single row, we need to ensure a limit for the // root query *before* joining the other rows. Otherwise, if the limit sits at the // end of the full query, only one row would be available at the end. if (rootTableSubQuery && rootTableName) { - table = rootTableName; + tableName = rootTableName; statement += `(${rootTableSubQuery}) as ${rootTableName} `; isJoiningMultipleRows = true; } else { - statement += `"${table}" `; + statement += `"${tableName}" `; } statement += `${including} `; @@ -140,9 +138,9 @@ export const compileQueryInput = ( // Show the table name for every column. By default, it doesn't show, but since we // are joining multiple tables together, we need to show the table name for every // table, in order to avoid conflicts. - model.tableAlias = table; + model.tableAlias = tableName; } else { - statement += `"${table}" `; + statement += `"${model.table}" `; } if (queryType === 'create' || queryType === 'set') { From a8b0e458e58f75723e434fe85e82c8c5f751fa8f Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 12:41:46 +0100 Subject: [PATCH 25/28] Allow for passing parent model --- src/instructions/including.ts | 2 +- src/instructions/to.ts | 8 ++++---- src/instructions/with.ts | 6 +++--- src/utils/index.ts | 20 +++++++++++--------- src/utils/model.ts | 2 +- src/utils/statement.ts | 9 ++------- 6 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/instructions/including.ts b/src/instructions/including.ts index d1eb676..df42d47 100644 --- a/src/instructions/including.ts +++ b/src/instructions/including.ts @@ -114,7 +114,7 @@ export const handleIncluding = ( 'including', queryInstructions?.with as WithFilters, { - parentTable: rootTableName, + parentModel: { ...model, tableAlias: rootTableName }, }, ); diff --git a/src/instructions/to.ts b/src/instructions/to.ts index bf11e2f..bcfc08c 100644 --- a/src/instructions/to.ts +++ b/src/instructions/to.ts @@ -27,7 +27,7 @@ import { composeConditions, getSymbol } from '@/src/utils/statement'; * @param dependencyStatements - A list of SQL statements to be executed before the main * SQL statement, in order to prepare for it. * @param instructions - The `to` and `with` instruction included in the query. - * @param options - Additional options for customizing the behavior of the function. + * @param parentModel - The model of the parent query, if there is one. * * @returns The SQL syntax for the provided `to` instruction. */ @@ -41,7 +41,7 @@ export const handleTo = ( with: NonNullable | undefined; to: NonNullable; }, - options?: { parentTable?: string }, + parentModel?: Model, ): string => { const currentTime = new Date().toISOString(); const { with: withInstruction, to: toInstruction } = instructions; @@ -187,7 +187,7 @@ export const handleTo = ( } let statement = composeConditions(models, model, statementParams, 'to', toInstruction, { - ...options, + parentModel, type: queryType === 'create' ? 'fields' : undefined, }); @@ -199,7 +199,7 @@ export const handleTo = ( 'to', toInstruction, { - ...options, + parentModel, type: 'values', }, ); diff --git a/src/instructions/with.ts b/src/instructions/with.ts index 99486e7..d2dce06 100644 --- a/src/instructions/with.ts +++ b/src/instructions/with.ts @@ -58,7 +58,7 @@ export type { WithValue, WithValueOptions, WithFilters, WithCondition }; * @param statementParams - A collection of values that will automatically be * inserted into the query by SQLite. * @param instruction - The `with` instruction included in a query. - * @param options - Additional options for customizing the behavior of the function. + * @param parentModel - The model of the parent query, if there is one. * * @returns The SQL syntax for the provided `with` instruction. */ @@ -67,7 +67,7 @@ export const handleWith = ( model: Model, statementParams: Array | null, instruction: GetInstructions['with'], - options?: { parentTable?: string }, + parentModel?: Model, ): string => { const subStatement = composeConditions( models, @@ -75,7 +75,7 @@ export const handleWith = ( statementParams, 'with', instruction as WithFilters, - { ...options }, + { parentModel }, ); return `(${subStatement})`; diff --git a/src/utils/index.ts b/src/utils/index.ts index 8a029e9..b7506e3 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -39,10 +39,10 @@ export const compileQueryInput = ( returning?: boolean; /** * If the query is contained within another query, this property should be set to the - * name of the table of the parent query. Like that, it becomes possible to reference - * fields of the parent model in the nested query (the current query). + * model of the parent query. Like that, it becomes possible to reference fields of + * the parent model in the nested query (the current query). */ - parentTable?: string; + parentModel?: Model; }, ): { dependencies: Array; main: Statement } => { // Split out the individual components of the query. @@ -161,9 +161,7 @@ export const compileQueryInput = ( queryType, dependencyStatements, { with: instructions.with, to: instructions.to }, - { - parentTable: options?.parentTable, - }, + options?.parentModel, ); statement += `${toStatement} `; @@ -174,9 +172,13 @@ export const compileQueryInput = ( // Queries of type "get", "set", "drop", or "count" all support filtering records, but // those of type "create" do not. if (queryType !== 'create' && instructions && Object.hasOwn(instructions, 'with')) { - const withStatement = handleWith(models, model, statementParams, instructions?.with, { - parentTable: options?.parentTable, - }); + const withStatement = handleWith( + models, + model, + statementParams, + instructions?.with, + options?.parentModel, + ); if (withStatement.length > 0) conditions.push(withStatement); } diff --git a/src/utils/model.ts b/src/utils/model.ts index 3426dfe..1b37aed 100644 --- a/src/utils/model.ts +++ b/src/utils/model.ts @@ -922,7 +922,7 @@ export const addModelQueries = ( const effectStatements = effectQueries.map((effectQuery) => { return compileQueryInput(effectQuery, models, params, { returning: false, - parentTable: tableName, + parentModel: currentModel, }).main.statement; }); diff --git a/src/utils/statement.ts b/src/utils/statement.ts index 6fb8295..8293e56 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -80,7 +80,7 @@ const composeFieldValues = ( options: { fieldSlug: string; type?: 'fields' | 'values'; - parentTable?: string; + parentModel?: Model; condition?: WithCondition; }, ): string => { @@ -107,12 +107,7 @@ const composeFieldValues = ( let rootModel: Model = model; if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT)) { - if (options.parentTable) { - const cleanModelSlug = options.parentTable.replace('sub_', ''); - rootModel = getModelBySlug(models, cleanModelSlug); - } - - rootModel.tableAlias = options.parentTable; + rootModel = options.parentModel as Model; toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT; if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD)) { From 49575e813606d67d998c4cb744d990e535655b60 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 12:50:35 +0100 Subject: [PATCH 26/28] Removed last bit of unused code --- src/instructions/including.ts | 15 +++++++++------ src/utils/index.ts | 25 +++++++++---------------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/instructions/including.ts b/src/instructions/including.ts index df42d47..5dd369d 100644 --- a/src/instructions/including.ts +++ b/src/instructions/including.ts @@ -26,12 +26,10 @@ export const handleIncluding = ( ): { statement: string; rootTableSubQuery?: string; - rootTableName?: string; } => { let statement = ''; let rootTableSubQuery: string | undefined; - let rootTableName = model.table; for (const ephemeralFieldSlug in instruction) { const symbol = getSymbol(instruction[ephemeralFieldSlug]); @@ -101,10 +99,15 @@ export const handleIncluding = ( statement += `${joinType} JOIN ${relatedTableSelector} as ${tableAlias}`; + // Show the table name for every column. By default, it doesn't show, but since we + // are joining multiple tables together, we need to show the table name for every + // table, in order to avoid conflicts. + model.tableAlias = model.table; + if (joinType === 'LEFT') { if (!single) { - rootTableSubQuery = `SELECT * FROM "${rootTableName}" LIMIT 1`; - rootTableName = `sub_${rootTableName}`; + rootTableSubQuery = `SELECT * FROM "${model.table}" LIMIT 1`; + model.tableAlias = `sub_${model.table}`; } const subStatement = composeConditions( @@ -114,7 +117,7 @@ export const handleIncluding = ( 'including', queryInstructions?.with as WithFilters, { - parentModel: { ...model, tableAlias: rootTableName }, + parentModel: model, }, ); @@ -122,5 +125,5 @@ export const handleIncluding = ( } } - return { statement, rootTableSubQuery, rootTableName }; + return { statement, rootTableSubQuery }; }; diff --git a/src/utils/index.ts b/src/utils/index.ts index b7506e3..8b3180c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -113,32 +113,25 @@ export const compileQueryInput = ( let isJoiningMultipleRows = false; if (isJoining) { - const { - statement: including, - rootTableSubQuery, - rootTableName, - } = handleIncluding(models, model, statementParams, instructions?.including); - - let tableName = model.table; + const { statement: including, rootTableSubQuery } = handleIncluding( + models, + model, + statementParams, + instructions?.including, + ); // If multiple rows are being joined from a different table, even though the root // query is only supposed to return a single row, we need to ensure a limit for the // root query *before* joining the other rows. Otherwise, if the limit sits at the // end of the full query, only one row would be available at the end. - if (rootTableSubQuery && rootTableName) { - tableName = rootTableName; - statement += `(${rootTableSubQuery}) as ${rootTableName} `; + if (rootTableSubQuery) { + statement += `(${rootTableSubQuery}) as ${model.tableAlias} `; isJoiningMultipleRows = true; } else { - statement += `"${tableName}" `; + statement += `"${model.table}" `; } statement += `${including} `; - - // Show the table name for every column. By default, it doesn't show, but since we - // are joining multiple tables together, we need to show the table name for every - // table, in order to avoid conflicts. - model.tableAlias = tableName; } else { statement += `"${model.table}" `; } From 46a50ded8a32fc8f3f4f1dcf8aef992947191106 Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 12:51:06 +0100 Subject: [PATCH 27/28] Use shorter variable names --- src/instructions/including.ts | 8 ++++---- src/utils/index.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/instructions/including.ts b/src/instructions/including.ts index 5dd369d..b4eb33a 100644 --- a/src/instructions/including.ts +++ b/src/instructions/including.ts @@ -25,11 +25,11 @@ export const handleIncluding = ( instruction: Instructions['including'], ): { statement: string; - rootTableSubQuery?: string; + tableSubQuery?: string; } => { let statement = ''; - let rootTableSubQuery: string | undefined; + let tableSubQuery: string | undefined; for (const ephemeralFieldSlug in instruction) { const symbol = getSymbol(instruction[ephemeralFieldSlug]); @@ -106,7 +106,7 @@ export const handleIncluding = ( if (joinType === 'LEFT') { if (!single) { - rootTableSubQuery = `SELECT * FROM "${model.table}" LIMIT 1`; + tableSubQuery = `SELECT * FROM "${model.table}" LIMIT 1`; model.tableAlias = `sub_${model.table}`; } @@ -125,5 +125,5 @@ export const handleIncluding = ( } } - return { statement, rootTableSubQuery }; + return { statement, tableSubQuery }; }; diff --git a/src/utils/index.ts b/src/utils/index.ts index 8b3180c..d68d456 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -113,7 +113,7 @@ export const compileQueryInput = ( let isJoiningMultipleRows = false; if (isJoining) { - const { statement: including, rootTableSubQuery } = handleIncluding( + const { statement: including, tableSubQuery } = handleIncluding( models, model, statementParams, @@ -124,8 +124,8 @@ export const compileQueryInput = ( // query is only supposed to return a single row, we need to ensure a limit for the // root query *before* joining the other rows. Otherwise, if the limit sits at the // end of the full query, only one row would be available at the end. - if (rootTableSubQuery) { - statement += `(${rootTableSubQuery}) as ${model.tableAlias} `; + if (tableSubQuery) { + statement += `(${tableSubQuery}) as ${model.tableAlias} `; isJoiningMultipleRows = true; } else { statement += `"${model.table}" `; From 4afa1cae343827dabca37dafab66b6efb79a9bac Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Thu, 14 Nov 2024 13:03:41 +0100 Subject: [PATCH 28/28] Polish code comments --- src/instructions/including.ts | 6 +++--- src/types/model.ts | 2 +- src/utils/helpers.ts | 4 ++-- src/utils/index.ts | 3 ++- src/utils/statement.ts | 10 +++++++++- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/instructions/including.ts b/src/instructions/including.ts index b4eb33a..314e772 100644 --- a/src/instructions/including.ts +++ b/src/instructions/including.ts @@ -99,9 +99,9 @@ export const handleIncluding = ( statement += `${joinType} JOIN ${relatedTableSelector} as ${tableAlias}`; - // Show the table name for every column. By default, it doesn't show, but since we - // are joining multiple tables together, we need to show the table name for every - // table, in order to avoid conflicts. + // Show the table name for every column in the final SQL statement. By default, it + // doesn't show, but since we are joining multiple tables together, we need to show + // the table name for every column, in order to avoid conflicts. model.tableAlias = model.table; if (joinType === 'LEFT') { diff --git a/src/types/model.ts b/src/types/model.ts index ca55549..644d46b 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -141,7 +141,7 @@ export type PartialModel = RecursivePartial; // which is the required bare minimum. export type PublicModel = Omit< Partial, - 'slug' | 'identifiers' | 'associationSlug' | 'table' + 'slug' | 'identifiers' | 'associationSlug' | 'table' | 'tableAlias' > & { slug: Required; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 7bf09c6..dc46128 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -30,10 +30,10 @@ export const RONIN_MODEL_SYMBOLS = { // Represents the value of a field in the model of a parent query. FIELD_PARENT: '__RONIN_FIELD_PARENT_', - // Represents the old value of a field in the model. Used for triggers. + // Represents the old value of a field in the parent model. Used for triggers. FIELD_PARENT_OLD: '__RONIN_FIELD_PARENT_OLD_', - // Represents the new value of a field in the model. Used for triggers. + // Represents the new value of a field in the parent model. Used for triggers. FIELD_PARENT_NEW: '__RONIN_FIELD_PARENT_NEW_', // Represents a value provided to a query preset. diff --git a/src/utils/index.ts b/src/utils/index.ts index d68d456..89c913d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -38,7 +38,7 @@ export const compileQueryInput = ( */ returning?: boolean; /** - * If the query is contained within another query, this property should be set to the + * If the query is contained within another query, this option should be set to the * model of the parent query. Like that, it becomes possible to reference fields of * the parent model in the nested query (the current query). */ @@ -228,6 +228,7 @@ export const compileQueryInput = ( orderedBy: instructions.orderedBy, limitedTo: instructions.limitedTo, }); + conditions.push(beforeAndAfterStatement); } diff --git a/src/utils/statement.ts b/src/utils/statement.ts index 8293e56..cb7c0e1 100644 --- a/src/utils/statement.ts +++ b/src/utils/statement.ts @@ -106,10 +106,16 @@ const composeFieldValues = ( 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)) { @@ -366,7 +372,9 @@ export const formatIdentifiers = ( }; /** - * Checks if the provided value contains a symbol and returns its type and value. + * Checks if the provided value contains a RONIN model symbol (a represenation of a + * particular entity inside a query, such as an expression or a sub query) and returns + * its type and value. * * @param value - The value that should be checked. *