Skip to content

Allow for inlining statement values #14

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

Merged
merged 5 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ You will just need to make sure that, once you [create a pull request](https://d
The programmatic API of the RONIN compiler looks like this:

```typescript
import { compileQueryInput } from '@ronin/compiler';
import { compileQuery } from '@ronin/compiler';

const query = {
get: {
Expand All @@ -53,12 +53,27 @@ const schemas = [
},
];

const { writeStatements, readStatement } = compileQueryInput(query, schemas);
const { writeStatements, readStatement } = compileQuery(query, schemas);

console.log(readStatement);
// SELECT * FROM "accounts" ORDER BY "ronin.createdAt" DESC LIMIT 101
```

#### Options

To fine-tune the behavior of the compiler, you can pass the following options:

```typescript
compileQuery(query, schemas, {
// Instead of returning an array of values for every statement (which allows for
// preventing SQL injections), all values are inlined directly into the SQL strings.
// This option should only be used if the generated SQL will be manually verified.
inlineValues: true
});
```

#### Transpilation

In order to be compatible with a wide range of projects, the source code of the `compiler` repo needs to be compiled (transpiled) whenever you make changes to it. To automate this, you can keep this command running in your terminal:

```bash
Expand Down
262 changes: 9 additions & 253 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,6 @@
import { handleBeforeOrAfter } from '@/src/instructions/before-after';
import { handleFor } from '@/src/instructions/for';
import { handleIncluding } from '@/src/instructions/including';
import { handleLimitedTo } from '@/src/instructions/limited-to';
import { handleOrderedBy } from '@/src/instructions/ordered-by';
import { handleSelecting } from '@/src/instructions/selecting';
import { handleTo } from '@/src/instructions/to';
import { handleWith } from '@/src/instructions/with';
import type { Query } from '@/src/types/query';
import type { PublicSchema } from '@/src/types/schema';
import { RoninError, isObject, splitQuery } from '@/src/utils';
import {
addSchemaQueries,
addSystemSchemas,
getSchemaBySlug,
getTableForSchema,
} from '@/src/utils/schema';
import { formatIdentifiers } from '@/src/utils/statement';
import { compileQueryInput } from '@/src/utils';

/**
* Composes an SQL statement for a provided RONIN query.
Expand All @@ -26,250 +11,21 @@ import { formatIdentifiers } from '@/src/utils/statement';
*
* @returns The composed SQL statement.
*/
export const compileQueryInput = (
export const compileQuery = (
query: Query,
defaultSchemas: Array<PublicSchema>,
schemas: Array<PublicSchema>,
options?: {
statementValues: Array<unknown>;
disableReturning?: boolean;
inlineValues?: boolean;
},
): { writeStatements: Array<string>; readStatement: string; values: Array<unknown> } => {
// Split out the individual components of the query.
const parsedQuery = splitQuery(query);
const { queryType, querySchema, queryInstructions } = parsedQuery;

const schemas = addSystemSchemas(defaultSchemas);
const schema = getSchemaBySlug(schemas, querySchema);

// Whether the query will interact with a single record, or multiple at the same time.
const single = querySchema !== schema.pluralSlug;

// Walk deeper into the query, to the level on which the actual instructions (such as
// `with` and `including`) are located.
let instructions = formatIdentifiers(schema, queryInstructions);

// The name of the table in SQLite that contains the records that are being addressed.
// This always matches the plural slug of the schema, but in snake case.
let table = getTableForSchema(schema);

) => {
// In order to prevent SQL injections and allow for faster query execution, we're not
// inserting any values into the SQL statement directly. Instead, we will pass them to
// SQLite's API later on, so that it can prepare an object that the database can
// execute in a safe and fast manner. SQLite allows strings, numbers, booleans, and
// `null` to be provided as values.
const statementValues: Array<unknown> = options?.statementValues || [];

// 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
// to return the expected results.
const writeStatements: Array<string> = [];

// Generate additional dependency statements for meta queries, meaning queries that
// affect the database schema.
instructions = addSchemaQueries(
schemas,
statementValues,
{ queryType, querySchema, queryInstructions: instructions },
writeStatements,
);

// A list of columns that should be selected when querying records.
const columns = handleSelecting(schema, statementValues, {
selecting: instructions?.selecting,
including: instructions?.including,
});

let statement = '';

switch (queryType) {
case 'get':
statement += `SELECT ${columns} FROM `;
break;

case 'count':
statement += `SELECT COUNT(${columns}) FROM `;
break;

case 'drop':
statement += 'DELETE FROM ';
break;

case 'create':
statement += 'INSERT INTO ';
break;

case 'set':
statement += 'UPDATE ';
break;
}

const isJoining =
typeof instructions?.including !== 'undefined' && !isObject(instructions.including);
let isJoiningMultipleRows = false;

if (isJoining) {
const {
statement: including,
rootTableSubQuery,
rootTableName,
} = handleIncluding(schemas, statementValues, schema, instructions?.including, 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;
statement += `(${rootTableSubQuery}) as ${rootTableName} `;
isJoiningMultipleRows = true;
} else {
statement += `"${table}" `;
}

statement += `${including} `;
} else {
statement += `"${table}" `;
}

if (queryType === 'create' || queryType === 'set') {
// This validation must be performed before any default fields (such as `ronin`) are
// added to the record. Otherwise there are always fields present.
if (!isObject(instructions.to) || Object.keys(instructions.to).length === 0) {
throw new RoninError({
message: `When using a \`${queryType}\` query, the \`to\` instruction must be a non-empty object.`,
code: 'INVALID_TO_VALUE',
queries: [query],
});
}

const toStatement = handleTo(
schemas,
schema,
statementValues,
queryType,
writeStatements,
{ with: instructions.with, to: instructions.to },
isJoining ? table : undefined,
);

statement += `${toStatement} `;
}

const conditions: Array<string> = [];

// 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(
schemas,
schema,
statementValues,
instructions?.with,
isJoining ? table : undefined,
);

if (withStatement.length > 0) conditions.push(withStatement);
}

if (instructions && Object.hasOwn(instructions, 'for')) {
const forStatement = handleFor(
schemas,
schema,
statementValues,
instructions?.for,
isJoining ? table : undefined,
);

if (forStatement.length > 0) conditions.push(forStatement);
}

// Per default, records are being ordered by the time they were created. This is
// necessary for our pagination to work properly as the pagination cursor is based on
// the time the record was created.
if ((queryType === 'get' || queryType === 'count') && !single) {
instructions = instructions || {};
instructions.orderedBy = instructions.orderedBy || {};
instructions.orderedBy.ascending = instructions.orderedBy.ascending || [];
instructions.orderedBy.descending = instructions.orderedBy.descending || [];

// `ronin.createdAt` always has to be present in the `orderedBy` instruction because
// it's used for pagination. If it's not provided by the user, we have to add it.
if (
![
...instructions.orderedBy.ascending,
...instructions.orderedBy.descending,
].includes('ronin.createdAt')
) {
// It's extremely important that the item is added to the end of the array,
// otherwise https://github.com/ronin-co/core/issues/257 would occur.
instructions.orderedBy.descending.push('ronin.createdAt');
}
}

if (
instructions &&
(Object.hasOwn(instructions, 'before') || Object.hasOwn(instructions, 'after'))
) {
if (single) {
throw new RoninError({
message:
'The `before` and `after` instructions are not supported when querying for a single record.',
code: 'INVALID_BEFORE_OR_AFTER_INSTRUCTION',
queries: [query],
});
}

const beforeAndAfterStatement = handleBeforeOrAfter(
schema,
statementValues,
{
before: instructions.before,
after: instructions.after,
with: instructions.with,
orderedBy: instructions.orderedBy,
},
isJoining ? table : undefined,
);
conditions.push(beforeAndAfterStatement);
}

if (conditions.length > 0) {
// If multiple conditions are available, wrap them in parentheses to ensure that the
// AND/OR comparisons are asserted correctly.
if (conditions.length === 1) {
statement += `WHERE ${conditions[0]} `;
} else {
statement += `WHERE (${conditions.join(' ')}) `;
}
}

if (instructions?.orderedBy) {
const orderedByStatement = handleOrderedBy(
schema,
instructions.orderedBy,
isJoining ? table : undefined,
);
statement += `${orderedByStatement} `;
}

if (queryType === 'get' && !isJoiningMultipleRows) {
statement += handleLimitedTo(single, instructions?.limitedTo);
}

// For queries that modify records, we want to make sure that the modified record is
// returned after the modification has been performed.
if (['create', 'set', 'drop'].includes(queryType) && !options?.disableReturning) {
statement += 'RETURNING * ';
}

const finalStatement = statement.trimEnd();
// execute in a safe and fast manner. SQLite allows strings, numbers, and booleans to
// be provided as values.
const statementValues = options?.inlineValues ? null : [];

return {
writeStatements,
readStatement: finalStatement,
values: statementValues,
};
return compileQueryInput(query, schemas, statementValues);
};

// Expose schema types
Expand Down
4 changes: 2 additions & 2 deletions src/instructions/before-after.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { GetInstructions } from '@/src/types/query';
import type { Schema } from '@/src/types/schema';
import { RoninError } from '@/src/utils';
import { RoninError } from '@/src/utils/helpers';
import { getFieldFromSchema } from '@/src/utils/schema';
import { prepareStatementValue } from '@/src/utils/statement';

Expand All @@ -27,7 +27,7 @@ export const CURSOR_NULL_PLACEHOLDER = 'RONIN_NULL';
*/
export const handleBeforeOrAfter = (
schema: Schema,
statementValues: Array<unknown>,
statementValues: Array<unknown> | null,
instructions: {
before?: GetInstructions['before'];
after?: GetInstructions['after'];
Expand Down
4 changes: 2 additions & 2 deletions src/instructions/for.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { WithFilters } from '@/src/instructions/with';
import type { GetInstructions } from '@/src/types/query';
import type { Schema } from '@/src/types/schema';
import { RONIN_SCHEMA_SYMBOLS, RoninError, findInObject } from '@/src/utils';
import { RONIN_SCHEMA_SYMBOLS, RoninError, findInObject } from '@/src/utils/helpers';
import { composeConditions } from '@/src/utils/statement';

/**
Expand All @@ -20,7 +20,7 @@ import { composeConditions } from '@/src/utils/statement';
export const handleFor = (
schemas: Array<Schema>,
schema: Schema,
statementValues: Array<unknown>,
statementValues: Array<unknown> | null,
instruction: GetInstructions['for'],
rootTable?: string,
) => {
Expand Down
8 changes: 4 additions & 4 deletions src/instructions/including.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { compileQueryInput } from '@/src/index';
import type { WithFilters } from '@/src/instructions/with';
import type { Instructions } from '@/src/types/query';
import type { Schema } from '@/src/types/schema';
import { RoninError, splitQuery } from '@/src/utils';
import { RoninError, splitQuery } from '@/src/utils/helpers';
import { compileQueryInput } from '@/src/utils/index';
import { getSchemaBySlug, getTableForSchema } from '@/src/utils/schema';
import { composeConditions } from '@/src/utils/statement';

Expand All @@ -21,7 +21,7 @@ import { composeConditions } from '@/src/utils/statement';
*/
export const handleIncluding = (
schemas: Array<Schema>,
statementValues: Array<unknown>,
statementValues: Array<unknown> | null,
schema: Schema,
instruction: Instructions['including'],
rootTable?: string,
Expand Down Expand Up @@ -94,7 +94,7 @@ export const handleIncluding = (
},
},
schemas,
{ statementValues },
statementValues,
);

relatedTableSelector = `(${subSelect.readStatement})`;
Expand Down
Loading
Loading