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 27435a2..314e772 100644 --- a/src/instructions/including.ts +++ b/src/instructions/including.ts @@ -3,38 +3,36 @@ 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 { composeConditions, getSubQuery } from '@/src/utils/statement'; +import { getModelBySlug } from '@/src/utils/model'; +import { composeConditions, getSymbol } from '@/src/utils/statement'; /** * Generates the SQL syntax for the `including` query instruction, which allows for * 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; - rootTableName?: string; + tableSubQuery?: string; } => { let statement = ''; - let rootTableSubQuery: string | undefined; - let rootTableName = rootTable; + let tableSubQuery: string | undefined; 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,15 +40,15 @@ 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); let joinType: 'LEFT' | 'CROSS' = 'LEFT'; - let relatedTableSelector = `"${getTableForModel(relatedModel)}"`; + let relatedTableSelector = `"${relatedModel.table}"`; const tableAlias = `including_${ephemeralFieldSlug}`; const single = queryModel !== relatedModel.pluralSlug; @@ -101,21 +99,25 @@ export const handleIncluding = ( statement += `${joinType} JOIN ${relatedTableSelector} as ${tableAlias}`; + // 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') { if (!single) { - rootTableSubQuery = `SELECT * FROM "${rootTable}" LIMIT 1`; - rootTableName = `sub_${rootTable}`; + tableSubQuery = `SELECT * FROM "${model.table}" LIMIT 1`; + model.tableAlias = `sub_${model.table}`; } const subStatement = composeConditions( models, - relatedModel, + { ...relatedModel, tableAlias }, statementParams, 'including', queryInstructions?.with as WithFilters, { - rootTable: rootTableName, - customTable: tableAlias, + parentModel: model, }, ); @@ -123,5 +125,5 @@ export const handleIncluding = ( } } - return { statement, rootTableSubQuery, rootTableName }; + return { statement, tableSubQuery }; }; 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/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..bcfc08c 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 @@ -27,7 +27,7 @@ import { composeConditions, getSubQuery } 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 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; }, - rootTable?: string, + parentModel?: Model, ): string => { const currentTime = new Date().toISOString(); const { with: withInstruction, to: toInstruction } = instructions; @@ -63,14 +63,15 @@ export const handleTo = ( ...toInstruction.ronin, }; - const subQuery = getSubQuery(toInstruction); + // 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 // 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 +122,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. @@ -186,7 +187,7 @@ export const handleTo = ( } let statement = composeConditions(models, model, statementParams, 'to', toInstruction, { - rootTable, + parentModel, type: queryType === 'create' ? 'fields' : undefined, }); @@ -198,7 +199,7 @@ export const handleTo = ( 'to', toInstruction, { - rootTable, + parentModel, type: 'values', }, ); diff --git a/src/instructions/with.ts b/src/instructions/with.ts index 0be8924..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 rootTable - The table for which the current query is being executed. + * @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'], - rootTable?: string, + parentModel?: Model, ): string => { const subStatement = composeConditions( models, @@ -75,7 +75,7 @@ export const handleWith = ( statementParams, 'with', instruction as WithFilters, - { rootTable }, + { parentModel }, ); return `(${subStatement})`; diff --git a/src/types/model.ts b/src/types/model.ts index d102102..644d46b 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -99,16 +99,31 @@ 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; + + /** 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 + * many-cardinality reference fields), this property should contain the field to which + * the associative model should be mounted. + */ + associationSlug?: string; fields?: Array; indexes?: Array; @@ -116,9 +131,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' | 'table' | 'tableAlias' +> & { slug: Required; // It should also be possible for models to only define one of the two identifiers, diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index ae03eaf..dc46128 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -21,14 +21,20 @@ export const RONIN_MODEL_SYMBOLS = { // Represents a sub query. QUERY: '__RONIN_QUERY', - // Represents the value of a field in a model. + // Represents an expression that should be evaluated. + EXPRESSION: '__RONIN_EXPRESSION', + + // 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_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 parent 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_NEW_', + // 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. VALUE: '__RONIN_VALUE', @@ -38,7 +44,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..89c913d 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'; /** @@ -37,6 +37,12 @@ export const compileQueryInput = ( * Whether the query should explicitly return records. Defaults to `true`. */ returning?: boolean; + /** + * 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). + */ + parentModel?: Model; }, ): { dependencies: Array; main: Statement } => { // Split out the individual components of the query. @@ -53,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 = getTableForModel(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 @@ -111,27 +113,27 @@ export const compileQueryInput = ( let isJoiningMultipleRows = false; if (isJoining) { - const { - statement: including, - rootTableSubQuery, - rootTableName, - } = handleIncluding(models, statementParams, instructions?.including, table); + const { statement: including, tableSubQuery } = 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) { - table = rootTableName; - statement += `(${rootTableSubQuery}) as ${rootTableName} `; + if (tableSubQuery) { + statement += `(${tableSubQuery}) as ${model.tableAlias} `; isJoiningMultipleRows = true; } else { - statement += `"${table}" `; + statement += `"${model.table}" `; } statement += `${including} `; } else { - statement += `"${table}" `; + statement += `"${model.table}" `; } if (queryType === 'create' || queryType === 'set') { @@ -152,7 +154,7 @@ export const compileQueryInput = ( queryType, dependencyStatements, { with: instructions.with, to: instructions.to }, - isJoining ? table : undefined, + options?.parentModel, ); statement += `${toStatement} `; @@ -168,7 +170,7 @@ export const compileQueryInput = ( model, statementParams, instructions?.with, - isJoining ? table : undefined, + options?.parentModel, ); if (withStatement.length > 0) conditions.push(withStatement); @@ -219,18 +221,14 @@ 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); } @@ -245,11 +243,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 1eace70..1b37aed 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 { @@ -55,27 +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 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}`); - /** * 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. @@ -86,29 +66,29 @@ 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_link_${model.slug}_${field.slug}`); /** * 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) - ? `${rootTable.replace(RONIN_MODEL_SYMBOLS.FIELD, '').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. @@ -130,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. */ @@ -138,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 || []; @@ -152,10 +130,10 @@ export const getFieldFromModel = ( if (modelField?.type === 'json') { const fieldSelector = getFieldSelector( + model, modelField, fieldPath, instructionName, - rootTable, ); return { field: modelField, fieldSelector }; } @@ -172,12 +150,7 @@ export const getFieldFromModel = ( }); } - const fieldSelector = getFieldSelector( - modelField, - fieldPath, - instructionName, - rootTable, - ); + const fieldSelector = getFieldSelector(model, modelField, fieldPath, instructionName); return { field: modelField, fieldSelector }; }; @@ -248,7 +221,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. @@ -264,6 +243,7 @@ const modelSettings: Array< ['name', 'slug', slugToName], ['pluralName', 'pluralSlug', slugToName], ['idPrefix', 'slug', (slug: string) => slug.slice(0, 3)], + ['table', 'pluralSlug', convertToSnakeCase], ]; /** @@ -274,7 +254,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) { @@ -384,6 +364,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' }, @@ -503,9 +484,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.')) { @@ -523,6 +504,7 @@ export const addSystemModels = (models: Array): Array addedModels.push({ pluralSlug: fieldSlug, slug: fieldSlug, + associationSlug: field.slug, fields: [ { slug: 'source', @@ -565,11 +547,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 +561,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 +598,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 +618,7 @@ export const addDefaultModelPresets = (list: Array, model: Model): Model }, }, }, - slug: pluralSlug, + slug: presetSlug, }); } @@ -864,6 +852,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 +881,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(', ')})`); @@ -913,17 +903,16 @@ 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_OLD - : RONIN_MODEL_SYMBOLS.FIELD_NEW; + ? RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD + : RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW; const withStatement = handleWith( models, - targetModel as Model, + { ...currentModel, tableAlias: tableAlias }, params, filterQuery, - tablePlaceholder, ); statementParts.push('WHEN', `(${withStatement})`); @@ -933,6 +922,7 @@ export const addModelQueries = ( const effectStatements = effectQueries.map((effectQuery) => { return compileQueryInput(effectQuery, models, params, { returning: false, + parentModel: currentModel, }).main.statement; }); diff --git a/src/utils/statement.ts b/src/utils/statement.ts index 77a8190..cb7c0e1 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, @@ -73,48 +78,65 @@ const composeFieldValues = ( instructionName: QueryInstructionType, value: WithValue | Record, options: { - rootTable?: string; fieldSlug: string; type?: 'fields' | 'values'; - customTable?: string; + parentModel?: Model; condition?: WithCondition; }, ): string => { - const { field: modelField, fieldSelector: selector } = getFieldFromModel( + const { fieldSelector: conditionSelector } = getFieldFromModel( model, options.fieldSlug, instructionName, - options.rootTable, ); // If only the field selectors are being requested, do not register any values. const collectStatementValue = options.type !== 'fields'; - let conditionSelector = selector; + // Determine if the value of the field is a symbol. + const symbol = getSymbol(value); + let conditionValue = value; - if (getSubQuery(value) && collectStatementValue) { - conditionValue = `(${ - compileQueryInput( - (value as Record)[RONIN_MODEL_SYMBOLS.QUERY], - models, - statementParams, - ).main.statement - })`; - } 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; + 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') { + conditionValue = symbol.value.replace(RONIN_MODEL_FIELD_REGEX, (match) => { + let toReplace: string = RONIN_MODEL_SYMBOLS.FIELD; + let rootModel: Model = model; + + // If a parent field is being referenced inside the value of the field, we need + // to obtain the field from the parent model instead of the current model. + if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT)) { + rootModel = options.parentModel as Model; + toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT; + + // If the old or new value of a field in the parent model is being referenced, + // we can't use the table name directly and instead have to resort to using + // special keywords such as `OLD` and `NEW` as the table names, which SQLite + // will parse itself. + if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD)) { + rootModel.tableAlias = toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT_OLD; + } else if (match.startsWith(RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW)) { + rootModel.tableAlias = toReplace = RONIN_MODEL_SYMBOLS.FIELD_PARENT_NEW; + } + } + + const fieldSlug = match.replace(toReplace, ''); + const field = getFieldFromModel(rootModel, fieldSlug, instructionName); + + return field.fieldSelector; + }); } - conditionSelector = `${options.customTable ? `"${options.customTable}".` : ''}"${modelField.slug}"`; - conditionValue = `${targetTable}."${value.replace(toReplace, '')}"`; + // 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); } @@ -179,19 +201,14 @@ 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; // 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) { + if (!(isObject(value) || Array.isArray(value)) || getSymbol(value) || consumeJSON) { return composeFieldValues( models, model, @@ -355,14 +372,44 @@ export const formatIdentifiers = ( }; /** - * Obtains a sub query from a value, if the value contains one. + * 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. * - * @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; }; diff --git a/tests/instructions/for.test.ts b/tests/instructions/for.test.ts index 71ad614..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_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, }, 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 20e62e2..ffc4623 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 = [ { @@ -146,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!', @@ -215,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!', @@ -279,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!'], }, { @@ -338,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 = [ { @@ -431,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 = [ { @@ -622,7 +581,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), diff --git a/tests/meta.test.ts b/tests/meta.test.ts index 4f0ade3..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', ], @@ -1154,7 +1156,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_PARENT_NEW}createdBy`, + }, role: 'owner', pending: false, }, @@ -1182,14 +1186,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' }, ], @@ -1234,7 +1241,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_PARENT_OLD}createdBy`, + }, }, }, }, @@ -1260,14 +1269,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' }, ], @@ -1306,7 +1318,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_PARENT_NEW}createdBy`, + }, role: 'owner', pending: false, }, @@ -1341,15 +1355,15 @@ 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' }, ],