Skip to content

Commit

Permalink
Added tagged template literal for SQL
Browse files Browse the repository at this point in the history
  • Loading branch information
oskardudycz committed Oct 1, 2024
1 parent 9b8421a commit 3914509
Show file tree
Hide file tree
Showing 5 changed files with 248 additions and 32 deletions.
134 changes: 134 additions & 0 deletions src/packages/dumbo/src/core/sql/index.tagged-template.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import assert from 'assert';
import { describe, it } from 'node:test';
import { SQL, identifier, isSQL, literal, plainString, rawSql } from '.';

void describe('SQL Tagged Template Literal', () => {
void it('should format literals correctly', () => {
const name: string = 'John Doe';
const query: string = SQL`SELECT * FROM users WHERE name = ${literal(name)};`;

// Expected output directly without using pg-format
assert.strictEqual(query, "SELECT * FROM users WHERE name = 'John Doe';");
});

void it('should format identifiers correctly', () => {
const tableName: string = 'users';
const columnName: string = 'name';
const query: string = SQL`SELECT ${identifier(columnName)} FROM ${identifier(tableName)};`;

// Expected output directly without using pg-format
assert.strictEqual(query, 'SELECT name FROM users;');
});

void it('should format identifiers with CAPS correctly', () => {
const tableName: string = 'Users';
const columnName: string = 'Name';
const query: string = SQL`SELECT ${identifier(columnName)} FROM ${identifier(tableName)};`;

// Expected output directly without using pg-format
assert.strictEqual(query, 'SELECT "Name" FROM "Users";');
});

void it('should NOT format plain strings without escaping', () => {
const unsafeString: string = "some'unsafe";
const query: string = SQL`SELECT ${plainString(unsafeString)};`;

// Plain string without escaping
assert.strictEqual(query, "SELECT some'unsafe;");
});

void it('should handle default literal formatting for plain values', () => {
const name: string = 'John Doe';
const age: number = 30;
const query: string = SQL`INSERT INTO users (name, age) VALUES (${name}, ${age});`;

// Default literal formatting for plain values
assert.strictEqual(
query,
"INSERT INTO users (name, age) VALUES ('John Doe', 30);",
);
});

void it('should handle mixed types of formatting', () => {
const name: string = 'John Doe';
const age: number = 30;
const table: string = 'users';
const query: string = SQL`
INSERT INTO ${identifier(table)} (name, age)
VALUES (${literal(name)}, ${age})
RETURNING name, age;
`;

// Mixed formatting for identifiers and literals
assert.strictEqual(
query,
`
INSERT INTO users (name, age)
VALUES ('John Doe', 30)
RETURNING name, age;
`,
);
});

void it('should work with raw SQL', () => {
const rawQuery: string = rawSql('SELECT * FROM users');
assert.strictEqual(rawQuery, 'SELECT * FROM users');
assert.strictEqual(isSQL(rawQuery), true);
});

void it('should NOT recognize valid SQL using isSQL', () => {
const validSql: string = SQL`SELECT * FROM users;`;
const invalidSql: string = 'SELECT * FROM users;';

assert.strictEqual(isSQL(validSql), true);
assert.strictEqual(isSQL(invalidSql), true);
});

void it('should escape special characters in literals', () => {
const unsafeValue: string = "O'Reilly";
const query: string = SQL`SELECT * FROM users WHERE name = ${literal(unsafeValue)};`;

// SQL-safe escaping of single quote characters
assert.strictEqual(query, "SELECT * FROM users WHERE name = 'O''Reilly';");
});

void it('should correctly format empty strings and falsy values', () => {
const emptyString: string = '';
const nullValue: null = null;
const zeroValue: number = 0;

const query: string = SQL`INSERT INTO test (col1, col2, col3)
VALUES (${literal(emptyString)}, ${literal(nullValue)}, ${literal(zeroValue)});`;

// Handle empty string, null, and zero correctly
assert.strictEqual(
query,
`INSERT INTO test (col1, col2, col3)
VALUES ('', NULL, '0');`,
);
});

void it('should handle arrays of values using literals', () => {
const values: string[] = ['John', 'Doe', '30'];
const query: string = SQL`INSERT INTO users (first_name, last_name, age)
VALUES (${literal(values[0])}, ${literal(values[1])}, ${literal(values[2])});`;

// Handle array elements using literal formatting
assert.strictEqual(
query,
`INSERT INTO users (first_name, last_name, age)
VALUES ('John', 'Doe', '30');`,
);
});

void it('should handle SQL injections attempts safely', () => {
const unsafeInput: string = "'; DROP TABLE users; --";
const query: string = SQL`SELECT * FROM users WHERE name = ${literal(unsafeInput)};`;

// Escape SQL injection attempts correctly
assert.strictEqual(
query,
"SELECT * FROM users WHERE name = '''; DROP TABLE users; --';",
);
});
});
73 changes: 70 additions & 3 deletions src/packages/dumbo/src/core/sql/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,79 @@
import format from './pg-format';
// TODO: add core formatter, when adding other database type

export type SQL = string & { __brand: 'sql' };
type SQL = string & { __brand: 'sql' };

export const sql = (sqlQuery: string, ...params: unknown[]): SQL => {
const sql = (sqlQuery: string, ...params: unknown[]): SQL => {
return format(sqlQuery, ...params) as SQL;
};

export const rawSql = (sqlQuery: string): SQL => {
const rawSql = (sqlQuery: string): SQL => {
return sqlQuery as SQL;
};

const isSQL = (literal: unknown): literal is SQL =>
literal !== undefined && literal !== null && typeof literal === 'string';

type SQLValue =
| { type: 'literal'; value: unknown } // Literal types (e.g., strings, numbers)
| { type: 'identifier'; value: string } // Identifier types (e.g., table/column names)
| { type: 'plainString'; value: string }; // Plain string types (unsafe, unescaped)

// Wrapping functions for explicit formatting
const literal = (value: unknown) => ({ type: 'literal', value });
const identifier = (value: string) => ({ type: 'identifier', value });
const plainString = (value: string) => ({ type: 'plainString', value });

const defaultFormat = (value: unknown) => {
if (typeof value === 'string') {
return format('%L', value);
} else if (typeof value === 'number') {
return value.toString();
} else if (typeof value === 'bigint') {
return format('%L', value);
} else if (value instanceof Date) {
return format('%L', value);
} else if (Array.isArray(value)) {
return format('(%L)', value);
} else {
return format('%L', value);
}
};

function SQL(strings: TemplateStringsArray, ...values: unknown[]): SQL {
return strings
.map((string, index) => {
let formattedValue = '';

if (index < values.length) {
const value = values[index];

if (
value &&
typeof value === 'object' &&
'type' in value &&
'value' in value
) {
const sqlValue = value as SQLValue;
switch (sqlValue.type) {
case 'literal':
formattedValue = format('%L', sqlValue.value);
break;
case 'identifier':
formattedValue = format('%I', sqlValue.value);
break;
case 'plainString':
formattedValue = sqlValue.value;
break;
}
} else {
formattedValue = defaultFormat(value);
}
}

return string + formattedValue;
})
.join('') as SQL;
}

export { SQL, identifier, isSQL, literal, plainString, rawSql, sql };
2 changes: 1 addition & 1 deletion src/packages/dumbo/src/core/tracing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const LogLevel = {
};

const shouldLog = (logLevel: LogLevel): boolean => {
const definedLogLevel = process.env.PONGO_LOG_LEVEL ?? LogLevel.DISABLED;
const definedLogLevel = process.env.DUMBO_LOG_LEVEL ?? LogLevel.DISABLED;

if (definedLogLevel === LogLevel.ERROR && logLevel === LogLevel.ERROR)
return true;
Expand Down
24 changes: 15 additions & 9 deletions src/packages/pongo/src/core/collection/pongoCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,21 +481,27 @@ export type PongoCollectionSQLBuilder = {
insertOne: <T>(document: OptionalUnlessRequiredIdAndVersion<T>) => SQL;
insertMany: <T>(documents: OptionalUnlessRequiredIdAndVersion<T>[]) => SQL;
updateOne: <T>(
filter: PongoFilter<T>,
update: PongoUpdate<T>,
filter: PongoFilter<T> | SQL,
update: PongoUpdate<T> | SQL,
options?: UpdateOneOptions,
) => SQL;
replaceOne: <T>(
filter: PongoFilter<T>,
filter: PongoFilter<T> | SQL,
document: WithoutId<T>,
options?: ReplaceOneOptions,
) => SQL;
updateMany: <T>(filter: PongoFilter<T>, update: PongoUpdate<T>) => SQL;
deleteOne: <T>(filter: PongoFilter<T>, options?: DeleteOneOptions) => SQL;
deleteMany: <T>(filter: PongoFilter<T>) => SQL;
findOne: <T>(filter: PongoFilter<T>) => SQL;
find: <T>(filter: PongoFilter<T>) => SQL;
countDocuments: <T>(filter: PongoFilter<T>) => SQL;
updateMany: <T>(
filter: PongoFilter<T> | SQL,
update: PongoUpdate<T> | SQL,
) => SQL;
deleteOne: <T>(
filter: PongoFilter<T> | SQL,
options?: DeleteOneOptions,
) => SQL;
deleteMany: <T>(filter: PongoFilter<T> | SQL) => SQL;
findOne: <T>(filter: PongoFilter<T> | SQL) => SQL;
find: <T>(filter: PongoFilter<T> | SQL) => SQL;
countDocuments: <T>(filter: PongoFilter<T> | SQL) => SQL;
rename: (newName: string) => SQL;
drop: () => SQL;
};
Expand Down
47 changes: 28 additions & 19 deletions src/packages/pongo/src/postgres/sqlBuilder/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
isSQL,
JSONSerializer,
rawSql,
sql,
Expand Down Expand Up @@ -76,8 +77,8 @@ export const postgresSQLBuilder = (
);
},
updateOne: <T>(
filter: PongoFilter<T>,
update: PongoUpdate<T>,
filter: PongoFilter<T> | SQL,
update: PongoUpdate<T> | SQL,
options?: UpdateOneOptions,
): SQL => {
const expectedVersion = expectedVersionValue(options?.expectedVersion);
Expand All @@ -86,8 +87,8 @@ export const postgresSQLBuilder = (
const expectedVersionParams =
expectedVersion != null ? [collectionName, expectedVersion] : [];

const filterQuery = constructFilterQuery<T>(filter);
const updateQuery = buildUpdateQuery(update);
const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter);
const updateQuery = isSQL(update) ? filter : buildUpdateQuery(update);

return sql(
`WITH existing AS (
Expand Down Expand Up @@ -124,7 +125,7 @@ export const postgresSQLBuilder = (
);
},
replaceOne: <T>(
filter: PongoFilter<T>,
filter: PongoFilter<T> | SQL,
document: WithoutId<T>,
options?: ReplaceOneOptions,
): SQL => {
Expand All @@ -134,7 +135,7 @@ export const postgresSQLBuilder = (
const expectedVersionParams =
expectedVersion != null ? [collectionName, expectedVersion] : [];

const filterQuery = constructFilterQuery<T>(filter);
const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter);

return sql(
`WITH existing AS (
Expand Down Expand Up @@ -170,9 +171,12 @@ export const postgresSQLBuilder = (
collectionName,
);
},
updateMany: <T>(filter: PongoFilter<T>, update: PongoUpdate<T>): SQL => {
const filterQuery = constructFilterQuery(filter);
const updateQuery = buildUpdateQuery(update);
updateMany: <T>(
filter: PongoFilter<T> | SQL,
update: PongoUpdate<T> | SQL,
): SQL => {
const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter);
const updateQuery = isSQL(update) ? filter : buildUpdateQuery(update);

return sql(
`UPDATE %I
Expand All @@ -185,14 +189,17 @@ export const postgresSQLBuilder = (
where(filterQuery),
);
},
deleteOne: <T>(filter: PongoFilter<T>, options?: DeleteOneOptions): SQL => {
deleteOne: <T>(
filter: PongoFilter<T> | SQL,
options?: DeleteOneOptions,
): SQL => {
const expectedVersion = expectedVersionValue(options?.expectedVersion);
const expectedVersionUpdate =
expectedVersion != null ? 'AND %I._version = %L' : '';
const expectedVersionParams =
expectedVersion != null ? [collectionName, expectedVersion] : [];

const filterQuery = constructFilterQuery<T>(filter);
const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter);

return sql(
`WITH existing AS (
Expand Down Expand Up @@ -221,24 +228,26 @@ export const postgresSQLBuilder = (
collectionName,
);
},
deleteMany: <T>(filter: PongoFilter<T>): SQL => {
const filterQuery = constructFilterQuery(filter);
deleteMany: <T>(filter: PongoFilter<T> | SQL): SQL => {
const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter);

return sql('DELETE FROM %I %s', collectionName, where(filterQuery));
},
findOne: <T>(filter: PongoFilter<T>): SQL => {
const filterQuery = constructFilterQuery(filter);
findOne: <T>(filter: PongoFilter<T> | SQL): SQL => {
const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter);

return sql(
'SELECT data FROM %I %s LIMIT 1;',
collectionName,
where(filterQuery),
);
},
find: <T>(filter: PongoFilter<T>): SQL => {
const filterQuery = constructFilterQuery(filter);
find: <T>(filter: PongoFilter<T> | SQL): SQL => {
const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter);
return sql('SELECT data FROM %I %s;', collectionName, where(filterQuery));
},
countDocuments: <T>(filter: PongoFilter<T>): SQL => {
const filterQuery = constructFilterQuery(filter);
countDocuments: <T>(filter: PongoFilter<T> | SQL): SQL => {
const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter);
return sql(
'SELECT COUNT(1) as count FROM %I %s;',
collectionName,
Expand Down

0 comments on commit 3914509

Please sign in to comment.