diff --git a/src/packages/dumbo/src/core/sql/index.tagged-template.unit.spec.ts b/src/packages/dumbo/src/core/sql/index.tagged-template.unit.spec.ts new file mode 100644 index 0000000..7bfbe66 --- /dev/null +++ b/src/packages/dumbo/src/core/sql/index.tagged-template.unit.spec.ts @@ -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; --';", + ); + }); +}); diff --git a/src/packages/dumbo/src/core/sql/index.ts b/src/packages/dumbo/src/core/sql/index.ts index e9d3007..d171637 100644 --- a/src/packages/dumbo/src/core/sql/index.ts +++ b/src/packages/dumbo/src/core/sql/index.ts @@ -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 }; diff --git a/src/packages/dumbo/src/core/tracing/index.ts b/src/packages/dumbo/src/core/tracing/index.ts index abfd6db..d814540 100644 --- a/src/packages/dumbo/src/core/tracing/index.ts +++ b/src/packages/dumbo/src/core/tracing/index.ts @@ -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; diff --git a/src/packages/pongo/src/core/collection/pongoCollection.ts b/src/packages/pongo/src/core/collection/pongoCollection.ts index a226e09..982c890 100644 --- a/src/packages/pongo/src/core/collection/pongoCollection.ts +++ b/src/packages/pongo/src/core/collection/pongoCollection.ts @@ -481,21 +481,27 @@ export type PongoCollectionSQLBuilder = { insertOne: (document: OptionalUnlessRequiredIdAndVersion) => SQL; insertMany: (documents: OptionalUnlessRequiredIdAndVersion[]) => SQL; updateOne: ( - filter: PongoFilter, - update: PongoUpdate, + filter: PongoFilter | SQL, + update: PongoUpdate | SQL, options?: UpdateOneOptions, ) => SQL; replaceOne: ( - filter: PongoFilter, + filter: PongoFilter | SQL, document: WithoutId, options?: ReplaceOneOptions, ) => SQL; - updateMany: (filter: PongoFilter, update: PongoUpdate) => SQL; - deleteOne: (filter: PongoFilter, options?: DeleteOneOptions) => SQL; - deleteMany: (filter: PongoFilter) => SQL; - findOne: (filter: PongoFilter) => SQL; - find: (filter: PongoFilter) => SQL; - countDocuments: (filter: PongoFilter) => SQL; + updateMany: ( + filter: PongoFilter | SQL, + update: PongoUpdate | SQL, + ) => SQL; + deleteOne: ( + filter: PongoFilter | SQL, + options?: DeleteOneOptions, + ) => SQL; + deleteMany: (filter: PongoFilter | SQL) => SQL; + findOne: (filter: PongoFilter | SQL) => SQL; + find: (filter: PongoFilter | SQL) => SQL; + countDocuments: (filter: PongoFilter | SQL) => SQL; rename: (newName: string) => SQL; drop: () => SQL; }; diff --git a/src/packages/pongo/src/postgres/sqlBuilder/index.ts b/src/packages/pongo/src/postgres/sqlBuilder/index.ts index 612ee02..f0d2a90 100644 --- a/src/packages/pongo/src/postgres/sqlBuilder/index.ts +++ b/src/packages/pongo/src/postgres/sqlBuilder/index.ts @@ -1,4 +1,5 @@ import { + isSQL, JSONSerializer, rawSql, sql, @@ -76,8 +77,8 @@ export const postgresSQLBuilder = ( ); }, updateOne: ( - filter: PongoFilter, - update: PongoUpdate, + filter: PongoFilter | SQL, + update: PongoUpdate | SQL, options?: UpdateOneOptions, ): SQL => { const expectedVersion = expectedVersionValue(options?.expectedVersion); @@ -86,8 +87,8 @@ export const postgresSQLBuilder = ( const expectedVersionParams = expectedVersion != null ? [collectionName, expectedVersion] : []; - const filterQuery = constructFilterQuery(filter); - const updateQuery = buildUpdateQuery(update); + const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter); + const updateQuery = isSQL(update) ? filter : buildUpdateQuery(update); return sql( `WITH existing AS ( @@ -124,7 +125,7 @@ export const postgresSQLBuilder = ( ); }, replaceOne: ( - filter: PongoFilter, + filter: PongoFilter | SQL, document: WithoutId, options?: ReplaceOneOptions, ): SQL => { @@ -134,7 +135,7 @@ export const postgresSQLBuilder = ( const expectedVersionParams = expectedVersion != null ? [collectionName, expectedVersion] : []; - const filterQuery = constructFilterQuery(filter); + const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter); return sql( `WITH existing AS ( @@ -170,9 +171,12 @@ export const postgresSQLBuilder = ( collectionName, ); }, - updateMany: (filter: PongoFilter, update: PongoUpdate): SQL => { - const filterQuery = constructFilterQuery(filter); - const updateQuery = buildUpdateQuery(update); + updateMany: ( + filter: PongoFilter | SQL, + update: PongoUpdate | SQL, + ): SQL => { + const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter); + const updateQuery = isSQL(update) ? filter : buildUpdateQuery(update); return sql( `UPDATE %I @@ -185,14 +189,17 @@ export const postgresSQLBuilder = ( where(filterQuery), ); }, - deleteOne: (filter: PongoFilter, options?: DeleteOneOptions): SQL => { + deleteOne: ( + filter: PongoFilter | 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(filter); + const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter); return sql( `WITH existing AS ( @@ -221,24 +228,26 @@ export const postgresSQLBuilder = ( collectionName, ); }, - deleteMany: (filter: PongoFilter): SQL => { - const filterQuery = constructFilterQuery(filter); + deleteMany: (filter: PongoFilter | SQL): SQL => { + const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter); + return sql('DELETE FROM %I %s', collectionName, where(filterQuery)); }, - findOne: (filter: PongoFilter): SQL => { - const filterQuery = constructFilterQuery(filter); + findOne: (filter: PongoFilter | SQL): SQL => { + const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter); + return sql( 'SELECT data FROM %I %s LIMIT 1;', collectionName, where(filterQuery), ); }, - find: (filter: PongoFilter): SQL => { - const filterQuery = constructFilterQuery(filter); + find: (filter: PongoFilter | SQL): SQL => { + const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter); return sql('SELECT data FROM %I %s;', collectionName, where(filterQuery)); }, - countDocuments: (filter: PongoFilter): SQL => { - const filterQuery = constructFilterQuery(filter); + countDocuments: (filter: PongoFilter | SQL): SQL => { + const filterQuery = isSQL(filter) ? filter : constructFilterQuery(filter); return sql( 'SELECT COUNT(1) as count FROM %I %s;', collectionName,