Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for expressions as field values #29

Merged
merged 28 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions src/instructions/before-after.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -35,7 +34,6 @@ export const handleBeforeOrAfter = (
orderedBy: GetInstructions['orderedBy'];
limitedTo?: GetInstructions['limitedTo'];
},
rootTable?: string,
): string => {
if (!(instructions.before || instructions.after)) {
throw new RoninError({
Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 20 additions & 18 deletions src/instructions/including.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,54 +3,52 @@ 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: Model,
statementParams: Array<unknown> | 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
// static values that must be added to the resulting SQL statement as custom columns.
//
// 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;
Expand Down Expand Up @@ -101,27 +99,31 @@ 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,
},
);

statement += ` ON (${subStatement})`;
}
}

return { statement, rootTableSubQuery, rootTableName };
return { statement, tableSubQuery };
};
4 changes: 0 additions & 4 deletions src/instructions/ordered-by.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';

Expand All @@ -25,7 +23,6 @@ export const handleOrderedBy = (
model,
field,
'orderedBy.ascending',
rootTable,
);

if (statement.length > 0) {
Expand All @@ -48,7 +45,6 @@ export const handleOrderedBy = (
model,
field,
'orderedBy.descending',
rootTable,
);

if (statement.length > 0) {
Expand Down
6 changes: 4 additions & 2 deletions src/instructions/selecting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
});
Expand Down
19 changes: 10 additions & 9 deletions src/instructions/to.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*/
Expand All @@ -41,7 +41,7 @@ export const handleTo = (
with: NonNullable<SetInstructions['with']> | undefined;
to: NonNullable<SetInstructions['to']>;
},
rootTable?: string,
parentModel?: Model,
): string => {
const currentTime = new Date().toISOString();
const { with: withInstruction, to: toInstruction } = instructions;
Expand All @@ -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;
Expand Down Expand Up @@ -121,7 +122,7 @@ export const handleTo = (
} as unknown as Array<string>;
}

return compileQueryInput(subQuery, models, statementParams).main.statement;
return compileQueryInput(symbol.value, models, statementParams).main.statement;
}

// Assign default field values to the provided instruction.
Expand Down Expand Up @@ -186,7 +187,7 @@ export const handleTo = (
}

let statement = composeConditions(models, model, statementParams, 'to', toInstruction, {
rootTable,
parentModel,
type: queryType === 'create' ? 'fields' : undefined,
});

Expand All @@ -198,7 +199,7 @@ export const handleTo = (
'to',
toInstruction,
{
rootTable,
parentModel,
type: 'values',
},
);
Expand Down
6 changes: 3 additions & 3 deletions src/instructions/with.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -67,15 +67,15 @@ export const handleWith = (
model: Model,
statementParams: Array<unknown> | null,
instruction: GetInstructions['with'],
rootTable?: string,
parentModel?: Model,
): string => {
const subStatement = composeConditions(
models,
model,
statementParams,
'with',
instruction as WithFilters,
{ rootTable },
{ parentModel },
);

return `(${subStatement})`;
Expand Down
34 changes: 29 additions & 5 deletions src/types/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,26 +99,50 @@ 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<ModelField>;
indexes?: Array<ModelIndex>;
triggers?: Array<ModelTrigger>;
presets?: Array<ModelPreset>;
}

type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends object ? RecursivePartial<T[P]> : T[P];
};

export type PartialModel = RecursivePartial<Model>;

// In models provided to the compiler, all settings are optional, except for the `slug`,
// which is the required bare minimum.
export type PublicModel = Omit<Partial<Model>, 'slug' | 'identifiers'> & {
export type PublicModel = Omit<
Partial<Model>,
'slug' | 'identifiers' | 'associationSlug' | 'table' | 'tableAlias'
> & {
slug: Required<Model['slug']>;

// It should also be possible for models to only define one of the two identifiers,
Expand Down
18 changes: 12 additions & 6 deletions src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
);

Expand Down
Loading
Loading