From 5df717d0074ba6ec0f3457077453d6cc3b20a573 Mon Sep 17 00:00:00 2001 From: Rifa Sania Date: Thu, 6 Mar 2025 09:55:30 +0700 Subject: [PATCH 1/6] dml-operation --- src/config/database.ts | 1 + src/controllers/ddl-controller.ts | 4 +- src/controllers/dml-controller.ts | 27 ++ src/controllers/schema-controller.ts | 36 ++ src/index.ts | 6 +- src/models/metadata-column.ts | 5 - src/models/metadata-table.ts | 6 +- src/models/relations.ts | 14 + src/operations/ddl/create-column.ts | 1 + src/operations/dml/delete.ts | 48 +++ src/operations/dml/index.ts | 4 + src/operations/dml/insert.ts | 33 ++ src/operations/dml/select.ts | 37 +++ src/operations/dml/update.ts | 43 +++ src/operations/execute.ts | 59 ++++ src/operations/migrate.ts | 8 +- src/repositories/dml-repository.ts | 141 ++++++++ .../metadata-column-repository.ts | 4 + src/repositories/metadata-table-repository.ts | 4 + src/repositories/schema-repository.ts | 28 ++ src/routes/dml-routes.ts | 8 + src/routes/schema-routes.ts | 8 + src/tests/ddl.test.ts | 32 +- src/tests/dml.test.ts | 310 ++++++++++++++++++ src/types/ddl.ts | 2 +- src/types/dml.ts | 54 +++ src/utils/condition-parser.ts | 109 ++++++ 27 files changed, 1000 insertions(+), 32 deletions(-) create mode 100644 src/controllers/dml-controller.ts create mode 100644 src/controllers/schema-controller.ts create mode 100644 src/models/relations.ts create mode 100644 src/operations/dml/delete.ts create mode 100644 src/operations/dml/index.ts create mode 100644 src/operations/dml/insert.ts create mode 100644 src/operations/dml/select.ts create mode 100644 src/operations/dml/update.ts create mode 100644 src/operations/execute.ts create mode 100644 src/repositories/dml-repository.ts create mode 100644 src/repositories/schema-repository.ts create mode 100644 src/routes/dml-routes.ts create mode 100644 src/routes/schema-routes.ts create mode 100644 src/tests/dml.test.ts create mode 100644 src/types/dml.ts create mode 100644 src/utils/condition-parser.ts diff --git a/src/config/database.ts b/src/config/database.ts index f5fb593..8121c06 100644 --- a/src/config/database.ts +++ b/src/config/database.ts @@ -19,4 +19,5 @@ const sequelize = new Sequelize( }, ); +import '../models/relations'; export { sequelize }; diff --git a/src/controllers/ddl-controller.ts b/src/controllers/ddl-controller.ts index f077bf4..1b42424 100644 --- a/src/controllers/ddl-controller.ts +++ b/src/controllers/ddl-controller.ts @@ -1,11 +1,11 @@ import { Request, Response } from 'express'; import { sequelize } from '../config/database'; import { successResponse, errorResponse } from '../utils/response'; -import { Operations } from '../types/ddl'; +import { DDLOperations } from '../types/ddl'; import { DDLExecutor } from '../operations/migrate'; export const migrate = async (req: Request, res: Response) => { - const { operations }: { operations: Operations[] } = req.body; + const { operations }: { operations: DDLOperations[] } = req.body; if (!operations || !Array.isArray(operations)) return errorResponse(res, 'Invalid payload structure', 400); diff --git a/src/controllers/dml-controller.ts b/src/controllers/dml-controller.ts new file mode 100644 index 0000000..8c41150 --- /dev/null +++ b/src/controllers/dml-controller.ts @@ -0,0 +1,27 @@ +import { Request, Response } from 'express'; +import { sequelize } from '../config/database'; +import { successResponse, errorResponse } from '../utils/response'; +import { DMLOperations } from '../types/dml'; +import { DMLExecutor } from '../operations/execute'; + +export const execute = async (req: Request, res: Response) => { + const { operations }: { operations: DMLOperations[] } = req.body; + if (!operations || !Array.isArray(operations)) + return errorResponse(res, 'Invalid payload structure', 400); + + const transaction = await sequelize.transaction(); + // console.log('Received Operations:', JSON.stringify(operations, null, 2)); + + try { + const result = await DMLExecutor.execute(operations, transaction); + await transaction.commit(); + return successResponse( + res, + result, + 'DML operations completed successfully', + ); + } catch (error: any) { + await transaction.rollback(); + return errorResponse(res, error.message, 500); + } +}; diff --git a/src/controllers/schema-controller.ts b/src/controllers/schema-controller.ts new file mode 100644 index 0000000..9a79184 --- /dev/null +++ b/src/controllers/schema-controller.ts @@ -0,0 +1,36 @@ +import { Request, Response } from 'express'; +import { sequelize } from '../config/database'; +import { Transaction } from 'sequelize'; +import SchemaRepository from '../repositories/schema-repository'; + +export const schema = async (req: Request, res: Response) => { + const transaction = await sequelize.transaction(); + try { + const transaction = await sequelize.transaction({ + isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED, + }); + const tables = await SchemaRepository.getSchemas(transaction); + + const schema = tables.map((table) => ({ + tableId: table.id, + tableName: table.table_name, + columns: + table.columns?.map((col) => ({ + columnId: col.id, + name: col.column_name, + type: col.data_type, + isPrimary: col.is_primary, + isNullable: col.is_nullable, + isUnique: col.is_unique, + })) || [], + })); + + await transaction.commit(); + return res.json({ success: true, schema }); + } catch (error) { + await transaction.rollback(); + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ error: errorMessage }); + } +}; diff --git a/src/index.ts b/src/index.ts index 14e9c47..395af3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,9 +4,11 @@ import userRoutes from './routes/user-routes'; import authRoutes from './routes/auth-routes'; import ddlRoutes from './routes/ddl-routes'; import { sequelize } from './config/database'; +import schemaRoutes from './routes/schema-routes'; +import dmlRoutes from './routes/dml-routes'; sequelize - .sync({ force: true }) + .sync({ alter: true }) .then(async () => { console.log('Database synchronized successfully.'); }) @@ -26,6 +28,8 @@ const apiRouter = express.Router(); apiRouter.use('/auth', authRoutes); apiRouter.use('/users', userRoutes); apiRouter.use('/migrate', ddlRoutes); +apiRouter.use('/schemas', schemaRoutes); +apiRouter.use('/execute', dmlRoutes); app.use('/api', apiRouter); diff --git a/src/models/metadata-column.ts b/src/models/metadata-column.ts index f47f259..2b2cb25 100644 --- a/src/models/metadata-column.ts +++ b/src/models/metadata-column.ts @@ -68,9 +68,4 @@ MetadataColumn.init( }, ); -MetadataColumn.belongsTo(MetadataTable, { - foreignKey: 'table_id', - onDelete: 'CASCADE', -}); - export default MetadataColumn; diff --git a/src/models/metadata-table.ts b/src/models/metadata-table.ts index 5a0c4f6..bb131cd 100644 --- a/src/models/metadata-table.ts +++ b/src/models/metadata-table.ts @@ -1,5 +1,6 @@ -import { Model, DataTypes } from 'sequelize'; +import { Model, DataTypes, HasManyGetAssociationsMixin } from 'sequelize'; import { sequelize } from '../config/database'; +import MetadataColumn from './metadata-column'; class MetadataTable extends Model { public id!: string; @@ -7,6 +8,9 @@ class MetadataTable extends Model { public readonly createdAt!: Date; public readonly updatedAt!: Date; primaryKey: any; + + public columns?: MetadataColumn[]; + public getColumns!: HasManyGetAssociationsMixin; } MetadataTable.init( diff --git a/src/models/relations.ts b/src/models/relations.ts new file mode 100644 index 0000000..ec7a09c --- /dev/null +++ b/src/models/relations.ts @@ -0,0 +1,14 @@ +import MetadataTable from './metadata-table'; +import MetadataColumn from './metadata-column'; + +MetadataTable.hasMany(MetadataColumn, { + foreignKey: 'table_id', + as: 'columns', + onDelete: 'CASCADE', +}); + +MetadataColumn.belongsTo(MetadataTable, { + foreignKey: 'table_id', + as: 'table', + onDelete: 'CASCADE', +}); diff --git a/src/operations/ddl/create-column.ts b/src/operations/ddl/create-column.ts index 1426bfb..beeb289 100644 --- a/src/operations/ddl/create-column.ts +++ b/src/operations/ddl/create-column.ts @@ -46,6 +46,7 @@ export class CreateColumn { ALTER TABLE "${table}" ADD COLUMN "${columnName}" ${colType} ${columnDefinition.nullable ? '' : 'NOT NULL'} ${columnDefinition.unique ? 'UNIQUE' : ''} + ${colType === 'timestamp' ? 'DEFAULT NOW()' : ''} `; await sequelize.query(addColumnQuery, { transaction }); diff --git a/src/operations/dml/delete.ts b/src/operations/dml/delete.ts new file mode 100644 index 0000000..50619fa --- /dev/null +++ b/src/operations/dml/delete.ts @@ -0,0 +1,48 @@ +import { QueryTypes, Transaction } from 'sequelize'; +import { DeleteInstruction } from '../../types/dml'; +import { DMLRepository } from '../../repositories/dml-repository'; +import MetadataTableRepository from '../../repositories/metadata-table-repository'; +import { sequelize } from '../../config/database'; +import { parseConditionForQuery } from '../../utils/condition-parser'; + +export class DeleteOperation { + static async execute( + instruction: DeleteInstruction, + transaction: Transaction, + ) { + const { table, condition, params } = instruction; + + const metadataTable = await MetadataTableRepository.findOne( + { table_name: table }, + transaction, + ); + if (!metadataTable) throw new Error(`Table ${table} does not exist`); + + const replacements: any[] = []; + const whereClause = parseConditionForQuery(condition, replacements, params); + + const checkExistQuery = `SELECT EXISTS (SELECT 1 FROM "${table}" WHERE ${whereClause}) AS "exists"`; + const [{ exists }] = (await sequelize.query(checkExistQuery, { + type: QueryTypes.SELECT, + bind: replacements, + transaction, + })) as { exists: boolean }[]; + + if (!exists) { + throw new Error(`No matching record found in table ${table} for update`); + } + + const result = await DMLRepository.delete( + table, + condition, + params, + transaction, + ); + + await transaction.afterCommit(() => { + console.log(`Data deleted from ${table} successfully`); + }); + + return result; + } +} diff --git a/src/operations/dml/index.ts b/src/operations/dml/index.ts new file mode 100644 index 0000000..39ba299 --- /dev/null +++ b/src/operations/dml/index.ts @@ -0,0 +1,4 @@ +export { SelectOperation } from './select'; +export { InsertOperation } from './insert'; +export { UpdateOperation } from './update'; +export { DeleteOperation } from './delete'; diff --git a/src/operations/dml/insert.ts b/src/operations/dml/insert.ts new file mode 100644 index 0000000..168fd97 --- /dev/null +++ b/src/operations/dml/insert.ts @@ -0,0 +1,33 @@ +import { Transaction } from 'sequelize'; +import { InsertInstruction } from '../../types/dml'; +import { DMLRepository } from '../../repositories/dml-repository'; +import { validIdentifier } from '../../utils/validation'; +import MetadataTableRepository from '../../repositories/metadata-table-repository'; + +export class InsertOperation { + static async execute( + instruction: InsertInstruction, + transaction: Transaction, + ) { + const { table, data } = instruction; + if (!validIdentifier(table)) { + throw new Error(`Invalid table name: ${table}`); + } + + if (!data || Object.keys(data).length === 0) { + throw new Error('Insert data cannot be empty'); + } + + const metadataTable = await MetadataTableRepository.findOne( + { table_name: table }, + transaction, + ); + if (!metadataTable) throw new Error(`Table ${table} does not exist`); + + await DMLRepository.insert(table, data, transaction); + + await transaction.afterCommit(() => { + console.log(`Data inserted into ${table} successfully`); + }); + } +} diff --git a/src/operations/dml/select.ts b/src/operations/dml/select.ts new file mode 100644 index 0000000..5c68031 --- /dev/null +++ b/src/operations/dml/select.ts @@ -0,0 +1,37 @@ +import { Transaction } from 'sequelize'; +import { SelectInstruction } from '../../types/dml'; +import { DMLRepository } from '../../repositories/dml-repository'; +import MetadataTableRepository from '../../repositories/metadata-table-repository'; + +export class SelectOperation { + static async execute( + instruction: SelectInstruction, + transaction: Transaction, + ) { + const { table, condition, orderBy, limit, offset, params } = instruction; + + const metadataTable = await MetadataTableRepository.findOne( + { table_name: table }, + transaction, + ); + if (!metadataTable) { + throw new Error(`Table ${table} does not exist`); + } + + const result = await DMLRepository.select( + table, + condition || {}, + orderBy, + limit, + offset, + params, + transaction, + ); + + if (!result || result.length === 0) { + return { message: 'No data found' }; + } + + return result; + } +} diff --git a/src/operations/dml/update.ts b/src/operations/dml/update.ts new file mode 100644 index 0000000..a6ad6e8 --- /dev/null +++ b/src/operations/dml/update.ts @@ -0,0 +1,43 @@ +import { QueryTypes, Transaction } from 'sequelize'; +import { UpdateInstruction } from '../../types/dml'; +import { DMLRepository } from '../../repositories/dml-repository'; +import MetadataTableRepository from '../../repositories/metadata-table-repository'; +import { parseConditionForQuery } from '../../utils/condition-parser'; +import { sequelize } from '../../config/database'; + +export class UpdateOperation { + static async execute( + instruction: UpdateInstruction, + transaction: Transaction, + ) { + const { table, condition, set, params } = instruction; + if (!set || Object.keys(set).length === 0) + throw new Error('Update set cannot be empty'); + + const metadataTable = await MetadataTableRepository.findOne( + { table_name: table }, + transaction, + ); + if (!metadataTable) throw new Error(`Table ${table} does not exist`); + + const replacements: any[] = []; + const whereClause = parseConditionForQuery(condition, replacements, params); + + const checkExistQuery = `SELECT EXISTS (SELECT 1 FROM "${table}" WHERE ${whereClause}) AS "exists"`; + const [{ exists }] = (await sequelize.query(checkExistQuery, { + type: QueryTypes.SELECT, + bind: replacements, + transaction, + })) as { exists: boolean }[]; + + if (!exists) { + throw new Error(`No matching record found in table ${table} for update`); + } + + await DMLRepository.update(table, set, condition, params, transaction); + + await transaction.afterCommit(() => { + console.log(`Data updated in ${table} successfully`); + }); + } +} diff --git a/src/operations/execute.ts b/src/operations/execute.ts new file mode 100644 index 0000000..0249142 --- /dev/null +++ b/src/operations/execute.ts @@ -0,0 +1,59 @@ +import { Transaction } from 'sequelize'; +import { + DeleteInstruction, + InsertInstruction, + DMLOperations, + SelectInstruction, + UpdateInstruction, +} from '../types/dml'; +import { + SelectOperation, + InsertOperation, + UpdateOperation, + DeleteOperation, +} from '../operations/dml'; + +export class DMLExecutor { + static async execute(operations: DMLOperations[], transaction: Transaction) { + const results: Record[] = []; + + for (const { operation, instruction } of operations) { + switch (operation) { + case 'Select': { + const selectResult = await SelectOperation.execute( + instruction as SelectInstruction, + transaction, + ); + results.push(selectResult); + break; + } + case 'Insert': { + await InsertOperation.execute( + instruction as InsertInstruction, + transaction, + ); + break; + } + case 'Update': { + await UpdateOperation.execute( + instruction as UpdateInstruction, + transaction, + ); + break; + } + case 'Delete': { + await DeleteOperation.execute( + instruction as DeleteInstruction, + transaction, + ); + break; + } + default: { + throw new Error(`Unsupported operation: ${operation}`); + } + } + } + + return results; + } +} diff --git a/src/operations/migrate.ts b/src/operations/migrate.ts index ea40e85..242d0f7 100644 --- a/src/operations/migrate.ts +++ b/src/operations/migrate.ts @@ -1,5 +1,5 @@ import { Transaction } from 'sequelize'; -import { ColumnObject, Operations } from '../types/ddl'; +import { ColumnObject, DDLOperations } from '../types/ddl'; import { CreateTable, CreateColumn, @@ -10,16 +10,12 @@ import { } from '../operations/ddl'; export class DDLExecutor { - static async execute(operations: Operations[], transaction: Transaction) { + static async execute(operations: DDLOperations[], transaction: Transaction) { for (const { operation, resource, migration } of operations) { const { name, table, column, from, to } = migration; let columnName: string; let finalColumnDefinition = {}; - // let columnName = typeof column === 'string' ? column : undefined; - // if (column && typeof column === 'object' && 'definition' in column) { - // finalColumnDefinition = (column as any).definition; - // } if (typeof column === 'string') { columnName = column; diff --git a/src/repositories/dml-repository.ts b/src/repositories/dml-repository.ts new file mode 100644 index 0000000..1d8385d --- /dev/null +++ b/src/repositories/dml-repository.ts @@ -0,0 +1,141 @@ +import { sequelize } from '../config/database'; +import { QueryTypes, Transaction } from 'sequelize'; +import { validIdentifier } from '../utils/validation'; +import { ConditionOperator } from '../types/dml'; +import { + parseConditionForNamedParams, + parseConditionForQuery, +} from '../utils/condition-parser'; + +export class DMLRepository { + static async insert( + table: string, + data: Record, + transaction?: Transaction, + ) { + // console.log('Sequelize Dialect:', sequelize.getDialect()); + + const keys = Object.keys(data); + const values = Object.values(data); + + const placeholders = keys.map((_, index) => `$${index + 1}`).join(', '); + const query = `INSERT INTO "${table}" (${keys.map((key) => `"${key}"`).join(', ')}) VALUES (${placeholders}) RETURNING *`; + + const result = await sequelize.query(query, { + type: QueryTypes.INSERT, + bind: values, + transaction, + }); + // console.log('QUERY RESULT:', result); + return result; + } + + static async update( + table: string, + set: Record, + condition: ConditionOperator, + params?: Record, + transaction?: Transaction, + ) { + if (!validIdentifier(table)) + throw new Error(`Invalid table name: ${table}`); + + if (!set || Object.keys(set).length === 0) + throw new Error('Update set cannot be empty'); + + let query = `UPDATE "${table}" SET `; + const setClauses: string[] = []; + const replacements: any[] = []; + let index = 1; + + for (const [key, value] of Object.entries(set)) { + setClauses.push(`"${key}" = $${index}`); + replacements.push(value); + index++; + } + query += setClauses.join(', '); + + const whereClause = parseConditionForQuery(condition, replacements, params); + query += ` WHERE ${whereClause}`; + + // console.log("QUERY:", query); + // console.log("REPLACEMENTS:", replacements); + + return await sequelize.query(query, { + type: QueryTypes.UPDATE, + bind: replacements, + transaction, + }); + } + + static async delete( + table: string, + condition: ConditionOperator, + params?: Record, + transaction?: Transaction, + ) { + if (!validIdentifier(table)) + throw new Error(`Invalid table name: ${table}`); + + let query = `DELETE FROM "${table}"`; + const replacements: any[] = []; + + const whereClause = parseConditionForQuery(condition, replacements, params); + query += ` WHERE ${whereClause}`; + + // console.log("QUERY:", query); + // console.log("REPLACEMENTS:", replacements); + + await sequelize.query(query, { + type: QueryTypes.DELETE, + bind: replacements, + transaction, + }); + } + + public static async select( + table: string, + condition: any, + orderBy?: Record, + limit?: number, + offset?: number, + params?: Record, + transaction?: any, + ) { + let query = `SELECT * FROM "${table}"`; + const replacements: any = {}; + + const whereClause = parseConditionForNamedParams( + condition, + replacements, + params, + ); + if (whereClause) { + query += ` WHERE ${whereClause}`; + } + + if (orderBy) { + const orderStrings = Object.entries(orderBy).map( + ([key, dir]) => `"${key}" ${dir}`, + ); + query += ` ORDER BY ${orderStrings.join(', ')}`; + } + + if (typeof limit === 'number') { + query += ` LIMIT :limit`; + replacements.limit = limit; + } + if (typeof offset === 'number') { + query += ` OFFSET :offset`; + replacements.offset = offset; + } + + const result = await sequelize.query(query, { + type: QueryTypes.SELECT, + replacements, + transaction, + }); + + return result; + } +} diff --git a/src/repositories/metadata-column-repository.ts b/src/repositories/metadata-column-repository.ts index 3a6a74e..6c9e0f0 100644 --- a/src/repositories/metadata-column-repository.ts +++ b/src/repositories/metadata-column-repository.ts @@ -52,6 +52,10 @@ class MetadataColumnRepository { static async findOne(condition: WhereOptions, transaction?: Transaction) { return await MetadataColumn.findOne({ where: condition, transaction }); } + + static async getAll(transaction?: Transaction) { + return await MetadataColumn.findAll({ transaction }); + } } export default MetadataColumnRepository; diff --git a/src/repositories/metadata-table-repository.ts b/src/repositories/metadata-table-repository.ts index 28b369a..22bde96 100644 --- a/src/repositories/metadata-table-repository.ts +++ b/src/repositories/metadata-table-repository.ts @@ -24,6 +24,10 @@ class MetadataTableRepository { static async delete(condition: WhereOptions, transaction?: Transaction) { return await MetadataTable.destroy({ where: condition, transaction }); } + + static async getAll(transaction?: Transaction) { + return await MetadataTable.findAll({ transaction }); + } } export default MetadataTableRepository; diff --git a/src/repositories/schema-repository.ts b/src/repositories/schema-repository.ts new file mode 100644 index 0000000..262d753 --- /dev/null +++ b/src/repositories/schema-repository.ts @@ -0,0 +1,28 @@ +import MetadataTable from '../models/metadata-table'; +import MetadataColumn from '../models/metadata-column'; +import { Transaction } from 'sequelize'; + +class SchemaRepository { + static async getSchemas(transaction?: Transaction) { + return MetadataTable.findAll({ + include: [ + { + model: MetadataColumn, + as: 'columns', + attributes: [ + 'id', + 'column_name', + 'data_type', + 'is_primary', + 'is_nullable', + 'is_unique', + ], + }, + ], + attributes: ['id', 'table_name'], + transaction, + }); + } +} + +export default SchemaRepository; diff --git a/src/routes/dml-routes.ts b/src/routes/dml-routes.ts new file mode 100644 index 0000000..23769a4 --- /dev/null +++ b/src/routes/dml-routes.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +const { execute } = require('../controllers/dml-controller'); + +const router = Router(); + +router.post('/', execute); + +export default router; diff --git a/src/routes/schema-routes.ts b/src/routes/schema-routes.ts new file mode 100644 index 0000000..7decec3 --- /dev/null +++ b/src/routes/schema-routes.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +const { schema } = require('../controllers/schema-controller'); + +const router = Router(); + +router.get('/', schema); + +export default router; diff --git a/src/tests/ddl.test.ts b/src/tests/ddl.test.ts index 4844cdb..e72200f 100644 --- a/src/tests/ddl.test.ts +++ b/src/tests/ddl.test.ts @@ -1,7 +1,7 @@ import { sequelize } from '../config/database'; import { Transaction } from 'sequelize'; import { DDLExecutor } from '../operations/migrate'; -import { Operations } from '../types/ddl'; +import { DDLOperations } from '../types/ddl'; import MetadataTableRepository from '../repositories/metadata-table-repository'; let transaction: Transaction; @@ -24,7 +24,7 @@ describe('DDL Operations', () => { }); test('Execute a sequence of DDL operations successfully', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Create', resource: 'Table', @@ -111,7 +111,7 @@ describe('DDL Operations', () => { // CREATE TABLE TESTS test('Fail creating a table with an existing name', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Create', resource: 'Table', @@ -136,7 +136,7 @@ describe('DDL Operations', () => { }); test('Create a table and verify its existence in metadata', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Create', resource: 'Table', @@ -159,7 +159,7 @@ describe('DDL Operations', () => { }); test('Prevent SQL Injection in table name', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Create', resource: 'Table', @@ -176,7 +176,7 @@ describe('DDL Operations', () => { }); test('Prevent invalid characters in table name', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Create', resource: 'Table', @@ -194,7 +194,7 @@ describe('DDL Operations', () => { // CREATE COLUMN TESTS test('Fail creating a column that already exists', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Create', resource: 'Table', @@ -232,7 +232,7 @@ describe('DDL Operations', () => { }); test('Fail creating a column in a non-existent table', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Create', resource: 'Column', @@ -257,7 +257,7 @@ describe('DDL Operations', () => { }); test('Prevent invalid characters in column name', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Create', resource: 'Column', @@ -278,7 +278,7 @@ describe('DDL Operations', () => { }); test('Prevent SQL Injection in column name', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Create', resource: 'Column', @@ -300,7 +300,7 @@ describe('DDL Operations', () => { // ALTER TABLE TESTS test('Fail renaming a table if target name already exists', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Create', resource: 'Table', @@ -333,7 +333,7 @@ describe('DDL Operations', () => { }); test('Fail renaming a non-existent table', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Alter', resource: 'Table', @@ -351,7 +351,7 @@ describe('DDL Operations', () => { // ALTER COLUMN TESTS test('Fail renaming a column that does not exist', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Create', resource: 'Table', @@ -381,7 +381,7 @@ describe('DDL Operations', () => { }); test('Fail renaming a column if target name already exists', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Create', resource: 'Table', @@ -432,7 +432,7 @@ describe('DDL Operations', () => { // DROP TABLE TESTS test('Fail dropping a table that does not exist', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Drop', resource: 'Table', @@ -449,7 +449,7 @@ describe('DDL Operations', () => { // DROP COLUMN TESTS test('Fail dropping a column that does not exist', async () => { - const payload: Operations[] = [ + const payload: DDLOperations[] = [ { operation: 'Create', resource: 'Table', diff --git a/src/tests/dml.test.ts b/src/tests/dml.test.ts new file mode 100644 index 0000000..9243b37 --- /dev/null +++ b/src/tests/dml.test.ts @@ -0,0 +1,310 @@ +import { sequelize } from '../config/database'; +import { Transaction } from 'sequelize'; +import { DMLExecutor } from '../operations/execute'; +import { DMLOperations } from '../types/dml'; +import { DDLOperations } from '../types/ddl'; +import { DDLExecutor } from '../operations/migrate'; + +let transaction: Transaction; + +beforeAll(async () => { + await sequelize.authenticate(); +}); + +afterAll(async () => { + await sequelize.close(); +}); + +describe('DML Operations', () => { + beforeEach(async () => { + transaction = await sequelize.transaction(); + + // SETUP DDL + const ddlPayload: DDLOperations[] = [ + { + operation: 'Create', + resource: 'Table', + migration: { + name: 'test_dml', + primaryKey: 'UUID', + }, + }, + { + operation: 'Create', + resource: 'Column', + migration: { + name: 'email', + table: 'test_dml', + column: { + type: 'text', + definition: { + textType: 'text', + default: null, + unique: false, + nullable: true, + }, + }, + }, + }, + { + operation: 'Create', + resource: 'Column', + migration: { + name: 'external_id', + table: 'test_dml', + column: { + type: 'text', + definition: { + textType: 'text', + default: null, + unique: false, + nullable: true, + }, + }, + }, + }, + { + operation: 'Create', + resource: 'Column', + migration: { + name: 'created_at', + table: 'test_dml', + column: { + type: 'timestamp', + definition: { + default: 'now()', + unique: false, + nullable: false, + }, + }, + }, + }, + ]; + + await DDLExecutor.execute(ddlPayload, transaction); + }); + + afterEach(async () => { + await transaction.rollback(); + }); + + test('Execute a sequence of DML operations successfully', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + name: 'data', + orderBy: { + created_at: 'ASC', + }, + condition: {}, + limit: 26, + offset: 0, + params: {}, + table: 'test_dml', + }, + }, + { + operation: 'Select', + instruction: { + name: 'data', + orderBy: { + created_at: 'ASC', + }, + condition: { + $or: [ + { + $or: [ + { + email: { + $eq: '{{name}}', + }, + external_id: { + $eq: 'user1', + }, + }, + ], + }, + ], + }, + limit: 26, + offset: 0, + params: { + name: 'admin@admin.com', + }, + table: 'test_dml', + }, + }, + { + operation: 'Insert', + instruction: { + table: 'test_dml', + name: 'data', + data: { + external_id: 'user1', + email: 'admin@admin.com', + }, + }, + }, + { + operation: 'Update', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + $and: [ + { + external_id: { + $eq: 'user1', + }, + }, + ], + }, + set: { + external_id: 'admin1', + }, + }, + }, + { + operation: 'Delete', + instruction: { + view: 'ok', + table: 'test_dml', + name: 'data', + condition: { + $and: [ + { + external_id: { + $eq: 'admin1', + }, + }, + ], + }, + params: {}, + }, + }, + ]; + + await DMLExecutor.execute(dmlPayload, transaction); + + const [test_dml]: any = await sequelize.query( + `SELECT * FROM test_dml WHERE external_id = 'admin1';`, + { transaction }, + ); + + expect(test_dml.length).toBeGreaterThanOrEqual(0); + }); + + // INSERT + test('Fail insert row into non-existent table', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Insert', + instruction: { + table: 'non_existent_table', + name: 'data', + data: { + external_id: 'user1', + email: 'admin@admin.com', + }, + }, + }, + ]; + + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Table non_existent_table does not exist', + ); + }); + + // UPDATE + test(' Fail update row into non-existent table', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Update', + instruction: { + table: 'non_existent_table', + name: 'data', + condition: { + external_id: { + $eq: 'user1', + }, + }, + set: { + external_id: 'admin1', + }, + }, + }, + ]; + + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Table non_existent_table does not exist', + ); + }); + + test('Fail update row with non-existent condition', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Update', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + external_id: { + $eq: 'non_existing_user', + }, + }, + set: { + external_id: 'admin1', + }, + }, + }, + ]; + + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'No matching record found in table test_dml for update', + ); + }); + + // DELETE + test('Fail delete non-existent table', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Delete', + instruction: { + table: 'non_existent_table', + name: 'data', + condition: { + external_id: { + $eq: 'user1', + }, + }, + }, + }, + ]; + + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Table non_existent_table does not exist', + ); + }); + + test('Fail delete row with non-existent condition', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Delete', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + external_id: { + $eq: 'non_existing_user', + }, + }, + }, + }, + ]; + + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'No matching record found in table test_dml for update', + ); + }); +}); diff --git a/src/types/ddl.ts b/src/types/ddl.ts index 06d7581..0d0c69f 100644 --- a/src/types/ddl.ts +++ b/src/types/ddl.ts @@ -1,4 +1,4 @@ -export interface Operations { +export interface DDLOperations { operation: 'Create' | 'Alter' | 'Drop'; resource: 'Table' | 'Column'; migration: MigrationDetails; diff --git a/src/types/dml.ts b/src/types/dml.ts new file mode 100644 index 0000000..c6b3c62 --- /dev/null +++ b/src/types/dml.ts @@ -0,0 +1,54 @@ +export interface DMLOperations { + operation: 'Select' | 'Insert' | 'Update' | 'Delete'; + instruction: + | SelectInstruction + | InsertInstruction + | UpdateInstruction + | DeleteInstruction; +} + +export type ConditionOperator = { + $eq?: string | number; + $neq?: string | number | boolean | null; + $gt?: number; + $gte?: number; + $lt?: number; + $lte?: number; + $in?: (string | number | boolean | null)[]; + $nin?: (string | number | boolean | null)[]; + $and?: Condition[]; + $or?: Condition[]; +}; + +export type Condition = + | { $and?: Condition[]; $or?: Condition[] } + | Record; + +export interface Instruction { + name?: string; + table: string; +} + +export interface SelectInstruction extends Instruction { + orderBy?: Record; + condition?: Condition; + limit?: number; + offset?: number; + params?: Record; +} + +export interface InsertInstruction extends Instruction { + data: Record; +} + +export interface UpdateInstruction extends Instruction { + condition: Condition; + set: Record; + params?: Record; +} + +export interface DeleteInstruction extends Instruction { + view?: string; + condition: Condition; + params?: Record; +} diff --git a/src/utils/condition-parser.ts b/src/utils/condition-parser.ts new file mode 100644 index 0000000..fdbeef7 --- /dev/null +++ b/src/utils/condition-parser.ts @@ -0,0 +1,109 @@ +import { ConditionOperator } from '../types/dml'; + +export function parseConditionForQuery( + cond: ConditionOperator, + replacements: Record, + params?: Record, +): string { + if (!cond || Object.keys(cond).length === 0) return '1=1'; + + const clauses: string[] = []; + let index = replacements.length + 1; + + for (const [key, value] of Object.entries(cond)) { + if (key === '$or' && Array.isArray(value)) { + const orClauses = value + .map((v) => `(${parseConditionForQuery(v, replacements, params)})`) + .join(' OR '); + clauses.push(`(${orClauses})`); + } else if (key === '$and' && Array.isArray(value)) { + const andClauses = value + .map((v) => `(${parseConditionForQuery(v, replacements, params)})`) + .join(' AND '); + clauses.push(`(${andClauses})`); + } else if (typeof value === 'object' && value !== null && '$eq' in value) { + let paramValue = value['$eq']; + if ( + typeof paramValue === 'string' && + paramValue.startsWith('{{') && + paramValue.endsWith('}}') + ) { + const paramKey = paramValue.slice(2, -2); + paramValue = params?.[paramKey]; + } + clauses.push(`"${key}" = $${index}`); + replacements.push(paramValue); + index++; + } + } + + return clauses.length > 0 ? clauses.join(' AND ') : '1=1'; +} + +export function parseConditionForNamedParams( + condition: ConditionOperator, + replacements: Record, + params?: Record, +): string { + if (!condition || Object.keys(condition).length === 0) return ''; + + const buildClause = (cond: any, depth = 0): string => { + if (typeof cond !== 'object') return ''; + + const conditions: string[] = []; + + for (const key in cond) { + if (['$and', '$or'].includes(key)) { + const subConditions = cond[key].map( + (subCond: any) => `(${buildClause(subCond, depth + 1)})`, + ); + const operator = key === '$and' ? 'AND' : 'OR'; + conditions.push(subConditions.join(` ${operator} `)); + } else { + let value = cond[key]; + + if (typeof value === 'object' && Object.keys(value).length === 1) { + const operator = Object.keys(value)[0]; + let paramValue = value[operator]; + + if ( + typeof paramValue === 'string' && + paramValue.startsWith('{{') && + paramValue.endsWith('}}') + ) { + const paramKey = paramValue.slice(2, -2); + paramValue = params?.[paramKey] ?? null; + } + + const paramPlaceholder = `param_${depth}_${key}`; + replacements[paramPlaceholder] = paramValue; + + switch (operator) { + case '$eq': + conditions.push(`"${key}" = :${paramPlaceholder}`); + break; + case '$ne': + conditions.push(`"${key}" != :${paramPlaceholder}`); + break; + case '$lt': + conditions.push(`"${key}" < :${paramPlaceholder}`); + break; + case '$gt': + conditions.push(`"${key}" > :${paramPlaceholder}`); + break; + case '$lte': + conditions.push(`"${key}" <= :${paramPlaceholder}`); + break; + case '$gte': + conditions.push(`"${key}" >= :${paramPlaceholder}`); + break; + } + } + } + } + + return conditions.length > 0 ? conditions.join(' AND ') : ''; + }; + + return buildClause(condition); +} From b27ca197ebfb6fa88df7dd1bc2282502c0152a29 Mon Sep 17 00:00:00 2001 From: Rifa Sania Date: Mon, 10 Mar 2025 13:25:58 +0700 Subject: [PATCH 2/6] fixed1:dml-operation --- src/controllers/dml-controller.ts | 1 - src/controllers/schema-controller.ts | 26 +----- src/models/metadata-table.ts | 7 +- src/operations/dml/delete.ts | 26 +++--- src/operations/dml/insert.ts | 5 +- src/operations/dml/select.ts | 12 ++- src/operations/dml/update.ts | 33 ++++---- src/operations/execute.ts | 25 ++---- src/repositories/dml-repository.ts | 30 ++++--- .../metadata-column-repository.ts | 4 - src/repositories/metadata-table-repository.ts | 4 - src/tests/dml.test.ts | 76 +++++++++++++++--- src/types/dml.ts | 80 +++++++++---------- src/utils/condition-parser.ts | 64 +++++++++------ src/utils/validation.ts | 24 ++++++ 15 files changed, 228 insertions(+), 189 deletions(-) diff --git a/src/controllers/dml-controller.ts b/src/controllers/dml-controller.ts index 8c41150..7184216 100644 --- a/src/controllers/dml-controller.ts +++ b/src/controllers/dml-controller.ts @@ -10,7 +10,6 @@ export const execute = async (req: Request, res: Response) => { return errorResponse(res, 'Invalid payload structure', 400); const transaction = await sequelize.transaction(); - // console.log('Received Operations:', JSON.stringify(operations, null, 2)); try { const result = await DMLExecutor.execute(operations, transaction); diff --git a/src/controllers/schema-controller.ts b/src/controllers/schema-controller.ts index 9a79184..704fe0d 100644 --- a/src/controllers/schema-controller.ts +++ b/src/controllers/schema-controller.ts @@ -1,34 +1,12 @@ import { Request, Response } from 'express'; -import { sequelize } from '../config/database'; -import { Transaction } from 'sequelize'; import SchemaRepository from '../repositories/schema-repository'; export const schema = async (req: Request, res: Response) => { - const transaction = await sequelize.transaction(); try { - const transaction = await sequelize.transaction({ - isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED, - }); - const tables = await SchemaRepository.getSchemas(transaction); + const tables = await SchemaRepository.getSchemas(); - const schema = tables.map((table) => ({ - tableId: table.id, - tableName: table.table_name, - columns: - table.columns?.map((col) => ({ - columnId: col.id, - name: col.column_name, - type: col.data_type, - isPrimary: col.is_primary, - isNullable: col.is_nullable, - isUnique: col.is_unique, - })) || [], - })); - - await transaction.commit(); - return res.json({ success: true, schema }); + return res.json({ success: true, tables }); } catch (error) { - await transaction.rollback(); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: errorMessage }); diff --git a/src/models/metadata-table.ts b/src/models/metadata-table.ts index bb131cd..18d6508 100644 --- a/src/models/metadata-table.ts +++ b/src/models/metadata-table.ts @@ -1,16 +1,11 @@ -import { Model, DataTypes, HasManyGetAssociationsMixin } from 'sequelize'; +import { Model, DataTypes } from 'sequelize'; import { sequelize } from '../config/database'; -import MetadataColumn from './metadata-column'; class MetadataTable extends Model { public id!: string; public table_name!: string; public readonly createdAt!: Date; public readonly updatedAt!: Date; - primaryKey: any; - - public columns?: MetadataColumn[]; - public getColumns!: HasManyGetAssociationsMixin; } MetadataTable.init( diff --git a/src/operations/dml/delete.ts b/src/operations/dml/delete.ts index 50619fa..d3a52ed 100644 --- a/src/operations/dml/delete.ts +++ b/src/operations/dml/delete.ts @@ -1,9 +1,8 @@ -import { QueryTypes, Transaction } from 'sequelize'; +import { Transaction } from 'sequelize'; import { DeleteInstruction } from '../../types/dml'; import { DMLRepository } from '../../repositories/dml-repository'; import MetadataTableRepository from '../../repositories/metadata-table-repository'; -import { sequelize } from '../../config/database'; -import { parseConditionForQuery } from '../../utils/condition-parser'; +import { validateCondition, validIdentifier } from '../../utils/validation'; export class DeleteOperation { static async execute( @@ -12,26 +11,19 @@ export class DeleteOperation { ) { const { table, condition, params } = instruction; + if (!validIdentifier(table)) + throw new Error(`Invalid table name: ${table}`); + + if (typeof condition === 'object') { + validateCondition(condition); + } + const metadataTable = await MetadataTableRepository.findOne( { table_name: table }, transaction, ); if (!metadataTable) throw new Error(`Table ${table} does not exist`); - const replacements: any[] = []; - const whereClause = parseConditionForQuery(condition, replacements, params); - - const checkExistQuery = `SELECT EXISTS (SELECT 1 FROM "${table}" WHERE ${whereClause}) AS "exists"`; - const [{ exists }] = (await sequelize.query(checkExistQuery, { - type: QueryTypes.SELECT, - bind: replacements, - transaction, - })) as { exists: boolean }[]; - - if (!exists) { - throw new Error(`No matching record found in table ${table} for update`); - } - const result = await DMLRepository.delete( table, condition, diff --git a/src/operations/dml/insert.ts b/src/operations/dml/insert.ts index 168fd97..9998012 100644 --- a/src/operations/dml/insert.ts +++ b/src/operations/dml/insert.ts @@ -1,7 +1,7 @@ import { Transaction } from 'sequelize'; import { InsertInstruction } from '../../types/dml'; import { DMLRepository } from '../../repositories/dml-repository'; -import { validIdentifier } from '../../utils/validation'; +import { validateData, validIdentifier } from '../../utils/validation'; import MetadataTableRepository from '../../repositories/metadata-table-repository'; export class InsertOperation { @@ -10,6 +10,7 @@ export class InsertOperation { transaction: Transaction, ) { const { table, data } = instruction; + if (!validIdentifier(table)) { throw new Error(`Invalid table name: ${table}`); } @@ -18,6 +19,8 @@ export class InsertOperation { throw new Error('Insert data cannot be empty'); } + validateData(data); + const metadataTable = await MetadataTableRepository.findOne( { table_name: table }, transaction, diff --git a/src/operations/dml/select.ts b/src/operations/dml/select.ts index 5c68031..c5e25ab 100644 --- a/src/operations/dml/select.ts +++ b/src/operations/dml/select.ts @@ -2,6 +2,7 @@ import { Transaction } from 'sequelize'; import { SelectInstruction } from '../../types/dml'; import { DMLRepository } from '../../repositories/dml-repository'; import MetadataTableRepository from '../../repositories/metadata-table-repository'; +import { validateCondition, validIdentifier } from '../../utils/validation'; export class SelectOperation { static async execute( @@ -10,6 +11,13 @@ export class SelectOperation { ) { const { table, condition, orderBy, limit, offset, params } = instruction; + if (!validIdentifier(table)) + throw new Error(`Invalid table name: ${table}`); + + if (condition) { + validateCondition(condition); + } + const metadataTable = await MetadataTableRepository.findOne( { table_name: table }, transaction, @@ -28,10 +36,6 @@ export class SelectOperation { transaction, ); - if (!result || result.length === 0) { - return { message: 'No data found' }; - } - return result; } } diff --git a/src/operations/dml/update.ts b/src/operations/dml/update.ts index a6ad6e8..77c960b 100644 --- a/src/operations/dml/update.ts +++ b/src/operations/dml/update.ts @@ -1,9 +1,12 @@ -import { QueryTypes, Transaction } from 'sequelize'; +import { Transaction } from 'sequelize'; import { UpdateInstruction } from '../../types/dml'; import { DMLRepository } from '../../repositories/dml-repository'; import MetadataTableRepository from '../../repositories/metadata-table-repository'; -import { parseConditionForQuery } from '../../utils/condition-parser'; -import { sequelize } from '../../config/database'; +import { + validateCondition, + validateData, + validIdentifier, +} from '../../utils/validation'; export class UpdateOperation { static async execute( @@ -11,29 +14,25 @@ export class UpdateOperation { transaction: Transaction, ) { const { table, condition, set, params } = instruction; + + if (!validIdentifier(table)) + throw new Error(`Invalid table name: ${table}`); + if (!set || Object.keys(set).length === 0) throw new Error('Update set cannot be empty'); + validateData(set); + + if (condition) { + validateCondition(condition); + } + const metadataTable = await MetadataTableRepository.findOne( { table_name: table }, transaction, ); if (!metadataTable) throw new Error(`Table ${table} does not exist`); - const replacements: any[] = []; - const whereClause = parseConditionForQuery(condition, replacements, params); - - const checkExistQuery = `SELECT EXISTS (SELECT 1 FROM "${table}" WHERE ${whereClause}) AS "exists"`; - const [{ exists }] = (await sequelize.query(checkExistQuery, { - type: QueryTypes.SELECT, - bind: replacements, - transaction, - })) as { exists: boolean }[]; - - if (!exists) { - throw new Error(`No matching record found in table ${table} for update`); - } - await DMLRepository.update(table, set, condition, params, transaction); await transaction.afterCommit(() => { diff --git a/src/operations/execute.ts b/src/operations/execute.ts index 0249142..5f27785 100644 --- a/src/operations/execute.ts +++ b/src/operations/execute.ts @@ -1,11 +1,5 @@ import { Transaction } from 'sequelize'; -import { - DeleteInstruction, - InsertInstruction, - DMLOperations, - SelectInstruction, - UpdateInstruction, -} from '../types/dml'; +import { DMLOperations } from '../types/dml'; import { SelectOperation, InsertOperation, @@ -21,31 +15,22 @@ export class DMLExecutor { switch (operation) { case 'Select': { const selectResult = await SelectOperation.execute( - instruction as SelectInstruction, + instruction, transaction, ); results.push(selectResult); break; } case 'Insert': { - await InsertOperation.execute( - instruction as InsertInstruction, - transaction, - ); + await InsertOperation.execute(instruction, transaction); break; } case 'Update': { - await UpdateOperation.execute( - instruction as UpdateInstruction, - transaction, - ); + await UpdateOperation.execute(instruction, transaction); break; } case 'Delete': { - await DeleteOperation.execute( - instruction as DeleteInstruction, - transaction, - ); + await DeleteOperation.execute(instruction, transaction); break; } default: { diff --git a/src/repositories/dml-repository.ts b/src/repositories/dml-repository.ts index 1d8385d..d705c01 100644 --- a/src/repositories/dml-repository.ts +++ b/src/repositories/dml-repository.ts @@ -1,7 +1,7 @@ import { sequelize } from '../config/database'; import { QueryTypes, Transaction } from 'sequelize'; import { validIdentifier } from '../utils/validation'; -import { ConditionOperator } from '../types/dml'; +import { Condition } from '../types/dml'; import { parseConditionForNamedParams, parseConditionForQuery, @@ -13,11 +13,15 @@ export class DMLRepository { data: Record, transaction?: Transaction, ) { - // console.log('Sequelize Dialect:', sequelize.getDialect()); + if (!validIdentifier(table)) + throw new Error(`Invalid table name: ${table}`); const keys = Object.keys(data); - const values = Object.values(data); + if (keys.some((key) => !validIdentifier(key))) { + throw new Error(`Invalid column name in insert operation`); + } + const values = Object.values(data); const placeholders = keys.map((_, index) => `$${index + 1}`).join(', '); const query = `INSERT INTO "${table}" (${keys.map((key) => `"${key}"`).join(', ')}) VALUES (${placeholders}) RETURNING *`; @@ -26,14 +30,13 @@ export class DMLRepository { bind: values, transaction, }); - // console.log('QUERY RESULT:', result); return result; } static async update( table: string, set: Record, - condition: ConditionOperator, + condition: Condition, params?: Record, transaction?: Transaction, ) { @@ -43,6 +46,9 @@ export class DMLRepository { if (!set || Object.keys(set).length === 0) throw new Error('Update set cannot be empty'); + if (Object.keys(set).some((key) => !validIdentifier(key))) + throw new Error(`Invalid column name in update operation`); + let query = `UPDATE "${table}" SET `; const setClauses: string[] = []; const replacements: any[] = []; @@ -58,9 +64,6 @@ export class DMLRepository { const whereClause = parseConditionForQuery(condition, replacements, params); query += ` WHERE ${whereClause}`; - // console.log("QUERY:", query); - // console.log("REPLACEMENTS:", replacements); - return await sequelize.query(query, { type: QueryTypes.UPDATE, bind: replacements, @@ -70,7 +73,7 @@ export class DMLRepository { static async delete( table: string, - condition: ConditionOperator, + condition: Condition, params?: Record, transaction?: Transaction, ) { @@ -83,9 +86,6 @@ export class DMLRepository { const whereClause = parseConditionForQuery(condition, replacements, params); query += ` WHERE ${whereClause}`; - // console.log("QUERY:", query); - // console.log("REPLACEMENTS:", replacements); - await sequelize.query(query, { type: QueryTypes.DELETE, bind: replacements, @@ -102,6 +102,12 @@ export class DMLRepository { params?: Record, transaction?: any, ) { + if (!validIdentifier(table)) + throw new Error(`Invalid table name: ${table}`); + + if (orderBy && Object.keys(orderBy).some((key) => !validIdentifier(key))) + throw new Error(`Invalid column name in orderBy`); + let query = `SELECT * FROM "${table}"`; const replacements: any = {}; diff --git a/src/repositories/metadata-column-repository.ts b/src/repositories/metadata-column-repository.ts index 6c9e0f0..3a6a74e 100644 --- a/src/repositories/metadata-column-repository.ts +++ b/src/repositories/metadata-column-repository.ts @@ -52,10 +52,6 @@ class MetadataColumnRepository { static async findOne(condition: WhereOptions, transaction?: Transaction) { return await MetadataColumn.findOne({ where: condition, transaction }); } - - static async getAll(transaction?: Transaction) { - return await MetadataColumn.findAll({ transaction }); - } } export default MetadataColumnRepository; diff --git a/src/repositories/metadata-table-repository.ts b/src/repositories/metadata-table-repository.ts index 22bde96..28b369a 100644 --- a/src/repositories/metadata-table-repository.ts +++ b/src/repositories/metadata-table-repository.ts @@ -24,10 +24,6 @@ class MetadataTableRepository { static async delete(condition: WhereOptions, transaction?: Transaction) { return await MetadataTable.destroy({ where: condition, transaction }); } - - static async getAll(transaction?: Transaction) { - return await MetadataTable.findAll({ transaction }); - } } export default MetadataTableRepository; diff --git a/src/tests/dml.test.ts b/src/tests/dml.test.ts index 9243b37..8fe026c 100644 --- a/src/tests/dml.test.ts +++ b/src/tests/dml.test.ts @@ -163,12 +163,12 @@ describe('DML Operations', () => { set: { external_id: 'admin1', }, + params: {}, }, }, { operation: 'Delete', instruction: { - view: 'ok', table: 'test_dml', name: 'data', condition: { @@ -195,6 +195,34 @@ describe('DML Operations', () => { expect(test_dml.length).toBeGreaterThanOrEqual(0); }); + // SELECT + test('Prevent SQL injection on select', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + name: 'data', + table: 'test_dml', + condition: { + email: { + $eq: "' OR 1=1; --", + }, + }, + orderBy: { + created_at: 'ASC', + }, + limit: 10, + offset: 0, + params: {}, + }, + }, + ]; + + await expect( + DMLExecutor.execute(dmlPayload, transaction), + ).rejects.toThrow(); + }); + // INSERT test('Fail insert row into non-existent table', async () => { const dmlPayload: DMLOperations[] = [ @@ -216,6 +244,26 @@ describe('DML Operations', () => { ); }); + test('Prevent SQL injection on insert', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Insert', + instruction: { + table: 'test_dml', + name: 'data', + data: { + external_id: "user1'; DROP TABLE test_dml; --", + email: "admin@admin.com'); SELECT * FROM users; --", + }, + }, + }, + ]; + + await expect( + DMLExecutor.execute(dmlPayload, transaction), + ).rejects.toThrow(); + }); + // UPDATE test(' Fail update row into non-existent table', async () => { const dmlPayload: DMLOperations[] = [ @@ -232,6 +280,7 @@ describe('DML Operations', () => { set: { external_id: 'admin1', }, + params: {}, }, }, ]; @@ -241,7 +290,7 @@ describe('DML Operations', () => { ); }); - test('Fail update row with non-existent condition', async () => { + test('Prevent SQL injection on update', async () => { const dmlPayload: DMLOperations[] = [ { operation: 'Update', @@ -250,19 +299,20 @@ describe('DML Operations', () => { name: 'data', condition: { external_id: { - $eq: 'non_existing_user', + $eq: "'; DROP TABLE test_dml; --", }, }, set: { - external_id: 'admin1', + email: "admin@admin.com'); SELECT * FROM users; --", }, + params: {}, }, }, ]; - await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( - 'No matching record found in table test_dml for update', - ); + await expect( + DMLExecutor.execute(dmlPayload, transaction), + ).rejects.toThrow(); }); // DELETE @@ -278,6 +328,7 @@ describe('DML Operations', () => { $eq: 'user1', }, }, + params: {}, }, }, ]; @@ -287,7 +338,7 @@ describe('DML Operations', () => { ); }); - test('Fail delete row with non-existent condition', async () => { + test('Prevent SQL injection on delete', async () => { const dmlPayload: DMLOperations[] = [ { operation: 'Delete', @@ -296,15 +347,16 @@ describe('DML Operations', () => { name: 'data', condition: { external_id: { - $eq: 'non_existing_user', + $eq: "' OR 1=1; --", }, }, + params: {}, }, }, ]; - await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( - 'No matching record found in table test_dml for update', - ); + await expect( + DMLExecutor.execute(dmlPayload, transaction), + ).rejects.toThrow(); }); }); diff --git a/src/types/dml.ts b/src/types/dml.ts index c6b3c62..002aaa2 100644 --- a/src/types/dml.ts +++ b/src/types/dml.ts @@ -1,54 +1,52 @@ -export interface DMLOperations { - operation: 'Select' | 'Insert' | 'Update' | 'Delete'; - instruction: - | SelectInstruction - | InsertInstruction - | UpdateInstruction - | DeleteInstruction; -} +export type DMLOperations = + | { operation: 'Select'; instruction: SelectInstruction } + | { operation: 'Insert'; instruction: InsertInstruction } + | { operation: 'Update'; instruction: UpdateInstruction } + | { operation: 'Delete'; instruction: DeleteInstruction }; -export type ConditionOperator = { - $eq?: string | number; - $neq?: string | number | boolean | null; - $gt?: number; - $gte?: number; - $lt?: number; - $lte?: number; - $in?: (string | number | boolean | null)[]; - $nin?: (string | number | boolean | null)[]; - $and?: Condition[]; - $or?: Condition[]; -}; - -export type Condition = - | { $and?: Condition[]; $or?: Condition[] } - | Record; - -export interface Instruction { - name?: string; - table: string; -} +export type ComparisonOperator = string | number | boolean | null; + +export type ConditionOperator = + | { $eq: ComparisonOperator } + | { $neq: ComparisonOperator } + | { $gt: number } + | { $gte: number } + | { $lt: number } + | { $lte: number } + | { $in: ComparisonOperator[] } + | { $nin: ComparisonOperator[] }; + +export type LogicalOperator = { $and: Condition[] } | { $or: Condition[] }; + +export type Condition = LogicalOperator | Record; -export interface SelectInstruction extends Instruction { - orderBy?: Record; - condition?: Condition; - limit?: number; - offset?: number; - params?: Record; +export interface SelectInstruction { + table: string; + name: string; + orderBy: Record; + condition: Condition; + limit: number; + offset: number; + params: Record; } -export interface InsertInstruction extends Instruction { +export interface InsertInstruction { + table: string; + name: string; data: Record; } -export interface UpdateInstruction extends Instruction { +export interface UpdateInstruction { + table: string; + name: string; condition: Condition; set: Record; - params?: Record; + params: Record; } -export interface DeleteInstruction extends Instruction { - view?: string; +export interface DeleteInstruction { + table: string; + name: string; condition: Condition; - params?: Record; + params: Record; } diff --git a/src/utils/condition-parser.ts b/src/utils/condition-parser.ts index fdbeef7..6eabeac 100644 --- a/src/utils/condition-parser.ts +++ b/src/utils/condition-parser.ts @@ -1,7 +1,7 @@ -import { ConditionOperator } from '../types/dml'; +import { Condition } from '../types/dml'; export function parseConditionForQuery( - cond: ConditionOperator, + cond: Condition, replacements: Record, params?: Record, ): string { @@ -11,29 +11,41 @@ export function parseConditionForQuery( let index = replacements.length + 1; for (const [key, value] of Object.entries(cond)) { - if (key === '$or' && Array.isArray(value)) { - const orClauses = value - .map((v) => `(${parseConditionForQuery(v, replacements, params)})`) - .join(' OR '); - clauses.push(`(${orClauses})`); - } else if (key === '$and' && Array.isArray(value)) { - const andClauses = value - .map((v) => `(${parseConditionForQuery(v, replacements, params)})`) - .join(' AND '); - clauses.push(`(${andClauses})`); - } else if (typeof value === 'object' && value !== null && '$eq' in value) { - let paramValue = value['$eq']; - if ( - typeof paramValue === 'string' && - paramValue.startsWith('{{') && - paramValue.endsWith('}}') - ) { - const paramKey = paramValue.slice(2, -2); - paramValue = params?.[paramKey]; - } - clauses.push(`"${key}" = $${index}`); - replacements.push(paramValue); - index++; + switch (key) { + case '$or': + if (Array.isArray(value)) { + const orClauses = value + .map((v) => `(${parseConditionForQuery(v, replacements, params)})`) + .join(' OR '); + clauses.push(`(${orClauses})`); + } + break; + + case '$and': + if (Array.isArray(value)) { + const andClauses = value + .map((v) => `(${parseConditionForQuery(v, replacements, params)})`) + .join(' AND '); + clauses.push(`(${andClauses})`); + } + break; + + default: + if (typeof value === 'object' && value !== null && '$eq' in value) { + let paramValue = value['$eq']; + if ( + typeof paramValue === 'string' && + paramValue.startsWith('{{') && + paramValue.endsWith('}}') + ) { + const paramKey = paramValue.slice(2, -2); + paramValue = params?.[paramKey]; + } + clauses.push(`"${key}" = $${index}`); + replacements.push(paramValue); + index++; + } + break; } } @@ -41,7 +53,7 @@ export function parseConditionForQuery( } export function parseConditionForNamedParams( - condition: ConditionOperator, + condition: Condition, replacements: Record, params?: Record, ): string { diff --git a/src/utils/validation.ts b/src/utils/validation.ts index b2b0995..932c7d5 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -1,3 +1,27 @@ export function validIdentifier(name: string): boolean { return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); } + +export function validateCondition(condition: any): void { + if (typeof condition === 'object' && condition !== null) { + for (const value of Object.values(condition)) { + if (typeof value === 'string' && /['";]/.test(value)) { + throw new Error(`Invalid value detected in condition`); + } + if (typeof value === 'object') { + validateCondition(value); + } + } + } +} + +export function validateData(data: Record): void { + for (const value of Object.values(data)) { + if (typeof value === 'string' && /['";]/.test(value)) { + throw new Error(`Invalid value detected in data`); + } + if (typeof value === 'object') { + validateCondition(value); + } + } +} From c6efa87fe1cd69726ae823d36c18bd6da6d75fdd Mon Sep 17 00:00:00 2001 From: Rifa Sania Date: Thu, 13 Mar 2025 10:46:31 +0700 Subject: [PATCH 3/6] fixed2:dml-operation --- src/operations/dml/delete.ts | 22 +- src/operations/dml/insert.ts | 20 +- src/operations/dml/select.ts | 15 +- src/operations/dml/update.ts | 20 +- src/operations/execute.ts | 20 +- src/repositories/dml-repository.ts | 10 +- .../metadata-column-repository.ts | 4 + src/tests/dml.test.ts | 525 ++++++++++++++++++ src/types/dml.ts | 18 +- src/utils/condition-parser.ts | 110 ++-- src/utils/validation.ts | 136 ++++- 11 files changed, 802 insertions(+), 98 deletions(-) diff --git a/src/operations/dml/delete.ts b/src/operations/dml/delete.ts index d3a52ed..4cc9e73 100644 --- a/src/operations/dml/delete.ts +++ b/src/operations/dml/delete.ts @@ -2,7 +2,12 @@ import { Transaction } from 'sequelize'; import { DeleteInstruction } from '../../types/dml'; import { DMLRepository } from '../../repositories/dml-repository'; import MetadataTableRepository from '../../repositories/metadata-table-repository'; -import { validateCondition, validIdentifier } from '../../utils/validation'; +import { + validateCondition, + validateSQL, + validIdentifier, +} from '../../utils/validation'; +import MetadataColumnRepository from '../../repositories/metadata-column-repository'; export class DeleteOperation { static async execute( @@ -14,16 +19,23 @@ export class DeleteOperation { if (!validIdentifier(table)) throw new Error(`Invalid table name: ${table}`); - if (typeof condition === 'object') { - validateCondition(condition); - } - const metadataTable = await MetadataTableRepository.findOne( { table_name: table }, transaction, ); if (!metadataTable) throw new Error(`Table ${table} does not exist`); + validateSQL(condition); + if (condition) { + validateCondition(condition); + } + + const metadataColumns = await MetadataColumnRepository.findAll( + { table_id: metadataTable.id }, + transaction, + ); + validateCondition(condition, metadataColumns); + const result = await DMLRepository.delete( table, condition, diff --git a/src/operations/dml/insert.ts b/src/operations/dml/insert.ts index 9998012..14408d2 100644 --- a/src/operations/dml/insert.ts +++ b/src/operations/dml/insert.ts @@ -1,8 +1,11 @@ import { Transaction } from 'sequelize'; import { InsertInstruction } from '../../types/dml'; import { DMLRepository } from '../../repositories/dml-repository'; -import { validateData, validIdentifier } from '../../utils/validation'; -import MetadataTableRepository from '../../repositories/metadata-table-repository'; +import { + validateDataType, + validateSQL, + validIdentifier, +} from '../../utils/validation'; export class InsertOperation { static async execute( @@ -19,18 +22,15 @@ export class InsertOperation { throw new Error('Insert data cannot be empty'); } - validateData(data); + validateSQL(data); + await validateDataType(table, data, transaction); - const metadataTable = await MetadataTableRepository.findOne( - { table_name: table }, - transaction, - ); - if (!metadataTable) throw new Error(`Table ${table} does not exist`); - - await DMLRepository.insert(table, data, transaction); + const result = await DMLRepository.insert(table, data, transaction); await transaction.afterCommit(() => { console.log(`Data inserted into ${table} successfully`); }); + + return result[0][0].id; } } diff --git a/src/operations/dml/select.ts b/src/operations/dml/select.ts index c5e25ab..5df816e 100644 --- a/src/operations/dml/select.ts +++ b/src/operations/dml/select.ts @@ -2,7 +2,11 @@ import { Transaction } from 'sequelize'; import { SelectInstruction } from '../../types/dml'; import { DMLRepository } from '../../repositories/dml-repository'; import MetadataTableRepository from '../../repositories/metadata-table-repository'; -import { validateCondition, validIdentifier } from '../../utils/validation'; +import { + validateCondition, + validateSQL, + validIdentifier, +} from '../../utils/validation'; export class SelectOperation { static async execute( @@ -14,10 +18,6 @@ export class SelectOperation { if (!validIdentifier(table)) throw new Error(`Invalid table name: ${table}`); - if (condition) { - validateCondition(condition); - } - const metadataTable = await MetadataTableRepository.findOne( { table_name: table }, transaction, @@ -26,6 +26,11 @@ export class SelectOperation { throw new Error(`Table ${table} does not exist`); } + if (condition) { + validateSQL(condition); + validateCondition(condition); + } + const result = await DMLRepository.select( table, condition || {}, diff --git a/src/operations/dml/update.ts b/src/operations/dml/update.ts index 77c960b..01a0056 100644 --- a/src/operations/dml/update.ts +++ b/src/operations/dml/update.ts @@ -1,10 +1,10 @@ import { Transaction } from 'sequelize'; import { UpdateInstruction } from '../../types/dml'; import { DMLRepository } from '../../repositories/dml-repository'; -import MetadataTableRepository from '../../repositories/metadata-table-repository'; import { validateCondition, - validateData, + validateDataType, + validateSQL, validIdentifier, } from '../../utils/validation'; @@ -21,22 +21,26 @@ export class UpdateOperation { if (!set || Object.keys(set).length === 0) throw new Error('Update set cannot be empty'); - validateData(set); + validateSQL(set); + await validateDataType(table, set, transaction); if (condition) { + validateSQL(condition); validateCondition(condition); } - const metadataTable = await MetadataTableRepository.findOne( - { table_name: table }, + const result = await DMLRepository.update( + table, + set, + condition, + params, transaction, ); - if (!metadataTable) throw new Error(`Table ${table} does not exist`); - - await DMLRepository.update(table, set, condition, params, transaction); await transaction.afterCommit(() => { console.log(`Data updated in ${table} successfully`); }); + + return result; } } diff --git a/src/operations/execute.ts b/src/operations/execute.ts index 5f27785..8956db6 100644 --- a/src/operations/execute.ts +++ b/src/operations/execute.ts @@ -22,15 +22,29 @@ export class DMLExecutor { break; } case 'Insert': { - await InsertOperation.execute(instruction, transaction); + const insertResult = await InsertOperation.execute( + instruction, + transaction, + ); + results.push(insertResult); break; } case 'Update': { - await UpdateOperation.execute(instruction, transaction); + const updateResult = await UpdateOperation.execute( + instruction, + transaction, + ); + if (updateResult) { + results.push(updateResult); + } break; } case 'Delete': { - await DeleteOperation.execute(instruction, transaction); + const deleteResult = await DeleteOperation.execute( + instruction, + transaction, + ); + results.push(deleteResult); break; } default: { diff --git a/src/repositories/dml-repository.ts b/src/repositories/dml-repository.ts index d705c01..f1171e0 100644 --- a/src/repositories/dml-repository.ts +++ b/src/repositories/dml-repository.ts @@ -62,13 +62,14 @@ export class DMLRepository { query += setClauses.join(', '); const whereClause = parseConditionForQuery(condition, replacements, params); - query += ` WHERE ${whereClause}`; + query += ` WHERE ${whereClause} RETURNING id;`; - return await sequelize.query(query, { + const result = await sequelize.query(query, { type: QueryTypes.UPDATE, bind: replacements, transaction, }); + return result[0]; } static async delete( @@ -84,13 +85,14 @@ export class DMLRepository { const replacements: any[] = []; const whereClause = parseConditionForQuery(condition, replacements, params); - query += ` WHERE ${whereClause}`; + query += ` WHERE ${whereClause} RETURNING id;`; - await sequelize.query(query, { + const result = await sequelize.query(query, { type: QueryTypes.DELETE, bind: replacements, transaction, }); + return result[0]; } public static async select( diff --git a/src/repositories/metadata-column-repository.ts b/src/repositories/metadata-column-repository.ts index 3a6a74e..6d07c81 100644 --- a/src/repositories/metadata-column-repository.ts +++ b/src/repositories/metadata-column-repository.ts @@ -52,6 +52,10 @@ class MetadataColumnRepository { static async findOne(condition: WhereOptions, transaction?: Transaction) { return await MetadataColumn.findOne({ where: condition, transaction }); } + + static async findAll(condition: WhereOptions, transaction?: Transaction) { + return await MetadataColumn.findAll({ where: condition, transaction }); + } } export default MetadataColumnRepository; diff --git a/src/tests/dml.test.ts b/src/tests/dml.test.ts index 8fe026c..30b315a 100644 --- a/src/tests/dml.test.ts +++ b/src/tests/dml.test.ts @@ -79,6 +79,22 @@ describe('DML Operations', () => { }, }, }, + { + operation: 'Create', + resource: 'Column', + migration: { + name: 'count', + table: 'test_dml', + column: { + type: 'integer', + definition: { + default: 0, + unique: false, + nullable: true, + }, + }, + }, + }, ]; await DDLExecutor.execute(ddlPayload, transaction); @@ -143,6 +159,7 @@ describe('DML Operations', () => { data: { external_id: 'user1', email: 'admin@admin.com', + count: 6, }, }, }, @@ -264,6 +281,27 @@ describe('DML Operations', () => { ).rejects.toThrow(); }); + test('Insert invalid type: number into text column', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Insert', + instruction: { + table: 'test_dml', + name: 'data', + data: { + external_id: 12345, + email: 'admin@admin.com', + count: 6, + }, + }, + }, + ]; + + await expect( + DMLExecutor.execute(dmlPayload, transaction), + ).rejects.toThrow(); + }); + // UPDATE test(' Fail update row into non-existent table', async () => { const dmlPayload: DMLOperations[] = [ @@ -315,6 +353,35 @@ describe('DML Operations', () => { ).rejects.toThrow(); }); + test('Update invalid type: string into integer column', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Update', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + $and: [ + { + external_id: { + $eq: 'user1', + }, + }, + ], + }, + set: { + count: 'invalid_number', + }, + params: {}, + }, + }, + ]; + + await expect( + DMLExecutor.execute(dmlPayload, transaction), + ).rejects.toThrow(); + }); + // DELETE test('Fail delete non-existent table', async () => { const dmlPayload: DMLOperations[] = [ @@ -359,4 +426,462 @@ describe('DML Operations', () => { DMLExecutor.execute(dmlPayload, transaction), ).rejects.toThrow(); }); + + test('Delete with invalid type in condition: object instead of string', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Delete', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + $and: [ + { + external_id: { + $eq: 18729, + }, + }, + ], + }, + params: {}, + }, + }, + ]; + + await expect( + DMLExecutor.execute(dmlPayload, transaction), + ).rejects.toThrow(); + }); +}); + +// mas kalau dipisah gini tuh gapapa ga? atau better disatuin aja sama yang atas gitu? +describe('Test all condition operator on DML Operations', () => { + beforeEach(async () => { + transaction = await sequelize.transaction(); + + // SETUP DDL + const ddlPayload: DDLOperations[] = [ + { + operation: 'Create', + resource: 'Table', + migration: { + name: 'test_dml', + primaryKey: 'UUID', + }, + }, + { + operation: 'Create', + resource: 'Column', + migration: { + name: 'email', + table: 'test_dml', + column: { + type: 'text', + definition: { + textType: 'text', + default: null, + unique: false, + nullable: true, + }, + }, + }, + }, + { + operation: 'Create', + resource: 'Column', + migration: { + name: 'external_id', + table: 'test_dml', + column: { + type: 'text', + definition: { + textType: 'text', + default: null, + unique: false, + nullable: true, + }, + }, + }, + }, + { + operation: 'Create', + resource: 'Column', + migration: { + name: 'created_at', + table: 'test_dml', + column: { + type: 'timestamp', + definition: { + default: 'now()', + unique: false, + nullable: false, + }, + }, + }, + }, + { + operation: 'Create', + resource: 'Column', + migration: { + name: 'count', + table: 'test_dml', + column: { + type: 'integer', + definition: { + default: 0, + unique: false, + nullable: true, + }, + }, + }, + }, + ]; + + await DDLExecutor.execute(ddlPayload, transaction); + + // SETUP DML + const dmlPayload: DMLOperations[] = [ + { + operation: 'Insert', + instruction: { + table: 'test_dml', + name: 'data', + data: { + external_id: 'user1', + email: 'user1@example.com', + count: 10, + }, + }, + }, + { + operation: 'Insert', + instruction: { + table: 'test_dml', + name: 'data', + data: { + external_id: 'user2', + email: 'user2@example.com', + count: 20, + }, + }, + }, + { + operation: 'Insert', + instruction: { + table: 'test_dml', + name: 'data', + data: { + external_id: 'user3', + email: 'user3@example.com', + count: 30, + }, + }, + }, + ]; + + await DMLExecutor.execute(dmlPayload, transaction); + }); + + afterEach(async () => { + await transaction.rollback(); + }); + + test('Select rows where count = 10 ($eq)', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + $or: [ + { + $or: [ + { + count: { + $eq: 10, + }, + }, + ], + }, + ], + }, + orderBy: { + count: 'ASC', + }, + limit: 10, + offset: 0, + params: {}, + }, + }, + ]; + + const result = await DMLExecutor.execute(dmlPayload, transaction); + expect(result[result.length - 1].length).toBe(1); + }); + + test('Select rows where count != 10 ($neq)', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + $or: [ + { + $or: [ + { + count: { + $neq: 10, + }, + }, + ], + }, + ], + }, + orderBy: { + count: 'ASC', + }, + limit: 10, + offset: 0, + params: {}, + }, + }, + ]; + + const result = await DMLExecutor.execute(dmlPayload, transaction); + expect(result[result.length - 1].length).toBe(2); + }); + + test('Select rows where count > 15', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + $or: [ + { + $or: [ + { + count: { + $gt: 15, + }, + }, + ], + }, + ], + }, + orderBy: { count: 'ASC' }, + limit: 10, + offset: 0, + params: {}, + }, + }, + ]; + const result = await DMLExecutor.execute(dmlPayload, transaction); + expect(result[result.length - 1].length).toBe(2); + }); + + test('Select rows where count >= 10 ($gte)', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + count: { + $gte: 10, + }, + }, + orderBy: { + count: 'ASC', + }, + limit: 10, + offset: 0, + params: {}, + }, + }, + ]; + + const result = await DMLExecutor.execute(dmlPayload, transaction); + expect(result[result.length - 1].length).toBe(3); + }); + + test('Select rows where count < 20', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + count: { + $lt: 20, + }, + }, + orderBy: { + count: 'ASC', + }, + limit: 10, + offset: 0, + params: {}, + }, + }, + ]; + + const result = await DMLExecutor.execute(dmlPayload, transaction); + expect(result[result.length - 1].length).toBe(1); + }); + + test('Select rows where count <= 10 ($lte)', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + count: { + $lte: 10, + }, + }, + orderBy: { + count: 'ASC', + }, + limit: 10, + offset: 0, + params: {}, + }, + }, + ]; + + const result = await DMLExecutor.execute(dmlPayload, transaction); + expect(result[result.length - 1].length).toBe(1); + }); + + test('Select rows where count is in [10, 30] ($in)', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + count: { + $in: [10, 30], + }, + }, + orderBy: { + count: 'ASC', + }, + limit: 10, + offset: 0, + params: {}, + }, + }, + ]; + + const result = await DMLExecutor.execute(dmlPayload, transaction); + expect(result[result.length - 1].length).toBe(2); + }); + + test('Select rows where count is NOT in [10, 30] ($nin)', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + count: { + $nin: [10, 30], + }, + }, + orderBy: { + count: 'ASC', + }, + limit: 10, + offset: 0, + params: {}, + }, + }, + ]; + + const result = await DMLExecutor.execute(dmlPayload, transaction); + expect(result[result.length - 1].length).toBe(1); + }); + + test('Select rows using AND condition ($and)', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + $and: [ + { + count: { + $gt: 10, + }, + }, + { + external_id: { + $eq: 'user2', + }, + }, + ], + }, + orderBy: { + count: 'ASC', + }, + limit: 10, + offset: 0, + params: {}, + }, + }, + ]; + + const result = await DMLExecutor.execute(dmlPayload, transaction); + expect(result[result.length - 1].length).toBe(1); + }); + + test('Select rows using OR condition ($or)', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + $or: [ + { + count: { + $eq: 10, + }, + }, + { + external_id: { + $eq: 'user2', + }, + }, + ], + }, + orderBy: { + count: 'ASC', + }, + limit: 10, + offset: 0, + params: {}, + }, + }, + ]; + + const result = await DMLExecutor.execute(dmlPayload, transaction); + expect(result[result.length - 1].length).toBe(2); + }); }); diff --git a/src/types/dml.ts b/src/types/dml.ts index 002aaa2..5335250 100644 --- a/src/types/dml.ts +++ b/src/types/dml.ts @@ -4,21 +4,25 @@ export type DMLOperations = | { operation: 'Update'; instruction: UpdateInstruction } | { operation: 'Delete'; instruction: DeleteInstruction }; -export type ComparisonOperator = string | number | boolean | null; +export type PrimitiveType = string | number | boolean | null; export type ConditionOperator = - | { $eq: ComparisonOperator } - | { $neq: ComparisonOperator } + | { $eq: PrimitiveType } + | { $neq: PrimitiveType } | { $gt: number } | { $gte: number } | { $lt: number } | { $lte: number } - | { $in: ComparisonOperator[] } - | { $nin: ComparisonOperator[] }; + | { $in: PrimitiveType[] } + | { $nin: PrimitiveType[] }; -export type LogicalOperator = { $and: Condition[] } | { $or: Condition[] }; +// kalau kayak gini bisa ga ya mas? soalnya kemungkinan ada condition yang gak dibungkus and or? atau engga? +export type LogicalOperator = + | { $and: Condition[] } + | { $or: Condition[] } + | { [column: string]: ConditionOperator }; -export type Condition = LogicalOperator | Record; +export type Condition = LogicalOperator; export interface SelectInstruction { table: string; diff --git a/src/utils/condition-parser.ts b/src/utils/condition-parser.ts index 6eabeac..d166046 100644 --- a/src/utils/condition-parser.ts +++ b/src/utils/condition-parser.ts @@ -1,4 +1,4 @@ -import { Condition } from '../types/dml'; +import { Condition, ConditionOperator, PrimitiveType } from '../types/dml'; export function parseConditionForQuery( cond: Condition, @@ -31,19 +31,32 @@ export function parseConditionForQuery( break; default: - if (typeof value === 'object' && value !== null && '$eq' in value) { - let paramValue = value['$eq']; - if ( - typeof paramValue === 'string' && - paramValue.startsWith('{{') && - paramValue.endsWith('}}') - ) { - const paramKey = paramValue.slice(2, -2); - paramValue = params?.[paramKey]; + if ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ) { + const operatorKey = Object.keys(value)[0] as keyof ConditionOperator; + const conditionValue = (value as ConditionOperator)[operatorKey]; + + if (conditionValue !== undefined) { + const sqlOperator = getSQLOperator(operatorKey); + + if (Array.isArray(conditionValue)) { + const paramPlaceholder = `$${index}`; + const sqlArrayType = + typeof conditionValue[0] === 'number' ? 'integer[]' : 'text[]'; + + clauses.push( + `"${key}" ${sqlOperator} (${paramPlaceholder}::${sqlArrayType})`, + ); + replacements[paramPlaceholder] = conditionValue; + } else { + clauses.push(`"${key}" ${getSQLOperator(operatorKey)} $${index}`); + replacements.push(conditionValue); + } + index++; } - clauses.push(`"${key}" = $${index}`); - replacements.push(paramValue); - index++; } break; } @@ -72,43 +85,35 @@ export function parseConditionForNamedParams( const operator = key === '$and' ? 'AND' : 'OR'; conditions.push(subConditions.join(` ${operator} `)); } else { - let value = cond[key]; - - if (typeof value === 'object' && Object.keys(value).length === 1) { - const operator = Object.keys(value)[0]; - let paramValue = value[operator]; - - if ( - typeof paramValue === 'string' && - paramValue.startsWith('{{') && - paramValue.endsWith('}}') - ) { - const paramKey = paramValue.slice(2, -2); - paramValue = params?.[paramKey] ?? null; + const value = cond[key as keyof Condition]; + + if (typeof value === 'object' && value !== null) { + const operatorKey = Object.keys(value)[0] as keyof ConditionOperator; + let paramValue: PrimitiveType = (value as ConditionOperator)[ + operatorKey + ]; + + if (typeof paramValue === 'string') { + if ( + (paramValue as string).startsWith('{{') && + (paramValue as string).endsWith('}}') + ) { + const paramKey = (paramValue as string).slice(2, -2).trim(); + paramValue = params?.[paramKey] ?? null; + } } const paramPlaceholder = `param_${depth}_${key}`; replacements[paramPlaceholder] = paramValue; - switch (operator) { - case '$eq': - conditions.push(`"${key}" = :${paramPlaceholder}`); - break; - case '$ne': - conditions.push(`"${key}" != :${paramPlaceholder}`); - break; - case '$lt': - conditions.push(`"${key}" < :${paramPlaceholder}`); - break; - case '$gt': - conditions.push(`"${key}" > :${paramPlaceholder}`); - break; - case '$lte': - conditions.push(`"${key}" <= :${paramPlaceholder}`); - break; - case '$gte': - conditions.push(`"${key}" >= :${paramPlaceholder}`); - break; + if (Array.isArray(paramValue)) { + conditions.push( + `"${key}" ${getSQLOperator(operatorKey)} (:${paramPlaceholder})`, + ); + } else { + conditions.push( + `"${key}" ${getSQLOperator(operatorKey)} :${paramPlaceholder}`, + ); } } } @@ -119,3 +124,18 @@ export function parseConditionForNamedParams( return buildClause(condition); } + +function getSQLOperator(operator: keyof ConditionOperator): string { + const operatorMap: Record = { + $eq: '=', + $neq: '!=', + $gt: '>', + $gte: '>=', + $lt: '<', + $lte: '<=', + $in: `IN`, + $nin: `NOT IN`, + }; + + return operatorMap[operator] || '='; +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 932c7d5..a21ed8f 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -1,27 +1,141 @@ +import { Transaction } from 'sequelize'; +import MetadataColumnRepository from '../repositories/metadata-column-repository'; +import MetadataTableRepository from '../repositories/metadata-table-repository'; +import { Condition, ConditionOperator, LogicalOperator } from '../types/dml'; + export function validIdentifier(name: string): boolean { return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); } -export function validateCondition(condition: any): void { - if (typeof condition === 'object' && condition !== null) { - for (const value of Object.values(condition)) { +export function validateSQL(data: any): void { + if (typeof data === 'object' && data !== null) { + for (const value of Object.values(data)) { if (typeof value === 'string' && /['";]/.test(value)) { - throw new Error(`Invalid value detected in condition`); + throw new Error(`Invalid SQL input detected`); } if (typeof value === 'object') { - validateCondition(value); + validateSQL(value); + } + } + } +} + +export function validateCondition( + condition: Condition, + metadataColumns?: any[], +): void { + if (isLogicalOperator(condition)) { + if ('$and' in condition && Array.isArray(condition.$and)) { + condition.$and.forEach((subCond) => + validateCondition(subCond, metadataColumns), + ); + } + if ('$or' in condition && Array.isArray(condition.$or)) { + condition.$or.forEach((subCond) => + validateCondition(subCond, metadataColumns), + ); + } + } else { + const conditionObject = condition as Record; + for (const key in conditionObject) { + const columnCondition = conditionObject[key]; + + if (isConditionOperator(columnCondition)) { + const operator = Object.keys(columnCondition)[0]; + const value = columnCondition[operator as keyof ConditionOperator]; + + if (metadataColumns) { + const column = metadataColumns.find((col) => col.column_name === key); + if (column && !isValidType(column.data_type.toUpperCase(), value)) { + throw new Error( + `Invalid type for column ${key}: expected ${column.data_type}, got ${typeof value}`, + ); + } + } } } } } -export function validateData(data: Record): void { - for (const value of Object.values(data)) { - if (typeof value === 'string' && /['";]/.test(value)) { - throw new Error(`Invalid value detected in data`); +export async function validateDataType( + table: string, + data: Record, + transaction: Transaction, +): Promise { + const metadataTable = await MetadataTableRepository.findOne( + { table_name: table }, + transaction, + ); + if (!metadataTable) throw new Error(`Table ${table} does not exist`); + + const metadataColumns = await MetadataColumnRepository.findAll( + { table_id: metadataTable.id }, + transaction, + ); + if (!metadataColumns.length) + throw new Error(`No columns found for table ${table}`); + + const columnMap = new Map( + metadataColumns.map((col) => [col.column_name, col]), + ); + + for (const key in data) { + if (isLogicalOperator(data[key]) || isConditionOperator(data[key])) + continue; + + const column = columnMap.get(key); + if (!column) + throw new Error(`Column ${key} does not exist in table ${table}`); + + const expectedType = column.data_type.toUpperCase(); + const actualValue = data[key]; + + if (actualValue === null) { + if (!column.is_nullable) throw new Error(`Column ${key} cannot be NULL`); + continue; } - if (typeof value === 'object') { - validateCondition(value); + + if (!isValidType(expectedType, actualValue)) { + throw new Error( + `Invalid type for column ${key}: expected ${expectedType}, got ${typeof actualValue}`, + ); } } } + +function isConditionOperator(obj: any): obj is ConditionOperator { + return ( + typeof obj === 'object' && + obj !== null && + Object.keys(obj).some((key) => + ['$eq', '$neq', '$gt', '$gte', '$lt', '$lte', '$in', '$nin'].includes( + key, + ), + ) + ); +} + +function isLogicalOperator(obj: any): obj is LogicalOperator { + return ( + typeof obj === 'object' && obj !== null && ('$and' in obj || '$or' in obj) + ); +} + +function isValidType(expectedType: string, value: any): boolean { + if (/CHAR|TEXT/.test(expectedType)) { + return typeof value === 'string'; + } + if (/INT|DECIMAL|NUMERIC/.test(expectedType)) { + return typeof value === 'number' && !isNaN(value); + } + if (/BOOLEAN/.test(expectedType)) { + return typeof value === 'boolean'; + } + if (/DATE|TIMESTAMP/.test(expectedType)) { + return value instanceof Date || !isNaN(Date.parse(value)); + } + if (/UUID/.test(expectedType)) { + return typeof value === 'string' && /^[0-9a-fA-F-]{36}$/.test(value); + } + return true; +} From ed3f42f91977c6d635f6fa69998ae807624fed3c Mon Sep 17 00:00:00 2001 From: Rifa Sania Date: Mon, 17 Mar 2025 10:54:00 +0700 Subject: [PATCH 4/6] fixed3:dml-operation --- src/operations/dml/delete.ts | 16 ++--- src/operations/dml/insert.ts | 12 +--- src/operations/dml/select.ts | 13 ++-- src/operations/dml/update.ts | 20 +++--- src/tests/dml.test.ts | 100 +++++++++++++++++++-------- src/types/dml.ts | 1 - src/utils/condition-parser.ts | 2 +- src/utils/validation.ts | 124 ++++++++++++++++++---------------- 8 files changed, 158 insertions(+), 130 deletions(-) diff --git a/src/operations/dml/delete.ts b/src/operations/dml/delete.ts index 4cc9e73..c55628e 100644 --- a/src/operations/dml/delete.ts +++ b/src/operations/dml/delete.ts @@ -2,12 +2,11 @@ import { Transaction } from 'sequelize'; import { DeleteInstruction } from '../../types/dml'; import { DMLRepository } from '../../repositories/dml-repository'; import MetadataTableRepository from '../../repositories/metadata-table-repository'; +import MetadataColumnRepository from '../../repositories/metadata-column-repository'; import { - validateCondition, - validateSQL, + parseAndValidateCondition, validIdentifier, } from '../../utils/validation'; -import MetadataColumnRepository from '../../repositories/metadata-column-repository'; export class DeleteOperation { static async execute( @@ -25,20 +24,17 @@ export class DeleteOperation { ); if (!metadataTable) throw new Error(`Table ${table} does not exist`); - validateSQL(condition); - if (condition) { - validateCondition(condition); - } - const metadataColumns = await MetadataColumnRepository.findAll( { table_id: metadataTable.id }, transaction, ); - validateCondition(condition, metadataColumns); + const parsedCondition = condition + ? parseAndValidateCondition(condition, metadataColumns) + : {}; const result = await DMLRepository.delete( table, - condition, + parsedCondition, params, transaction, ); diff --git a/src/operations/dml/insert.ts b/src/operations/dml/insert.ts index 14408d2..e30b0d3 100644 --- a/src/operations/dml/insert.ts +++ b/src/operations/dml/insert.ts @@ -1,11 +1,7 @@ import { Transaction } from 'sequelize'; import { InsertInstruction } from '../../types/dml'; import { DMLRepository } from '../../repositories/dml-repository'; -import { - validateDataType, - validateSQL, - validIdentifier, -} from '../../utils/validation'; +import { parseAndValidateData, validIdentifier } from '../../utils/validation'; export class InsertOperation { static async execute( @@ -22,10 +18,8 @@ export class InsertOperation { throw new Error('Insert data cannot be empty'); } - validateSQL(data); - await validateDataType(table, data, transaction); - - const result = await DMLRepository.insert(table, data, transaction); + const parsedData = await parseAndValidateData(table, data, transaction); + const result = await DMLRepository.insert(table, parsedData, transaction); await transaction.afterCommit(() => { console.log(`Data inserted into ${table} successfully`); diff --git a/src/operations/dml/select.ts b/src/operations/dml/select.ts index 5df816e..ce2162e 100644 --- a/src/operations/dml/select.ts +++ b/src/operations/dml/select.ts @@ -3,8 +3,7 @@ import { SelectInstruction } from '../../types/dml'; import { DMLRepository } from '../../repositories/dml-repository'; import MetadataTableRepository from '../../repositories/metadata-table-repository'; import { - validateCondition, - validateSQL, + parseAndValidateCondition, validIdentifier, } from '../../utils/validation'; @@ -26,14 +25,12 @@ export class SelectOperation { throw new Error(`Table ${table} does not exist`); } - if (condition) { - validateSQL(condition); - validateCondition(condition); - } - + const parsedCondition = condition + ? parseAndValidateCondition(condition) + : {}; const result = await DMLRepository.select( table, - condition || {}, + parsedCondition, orderBy, limit, offset, diff --git a/src/operations/dml/update.ts b/src/operations/dml/update.ts index 01a0056..993d88f 100644 --- a/src/operations/dml/update.ts +++ b/src/operations/dml/update.ts @@ -2,9 +2,8 @@ import { Transaction } from 'sequelize'; import { UpdateInstruction } from '../../types/dml'; import { DMLRepository } from '../../repositories/dml-repository'; import { - validateCondition, - validateDataType, - validateSQL, + parseAndValidateCondition, + parseAndValidateData, validIdentifier, } from '../../utils/validation'; @@ -21,18 +20,15 @@ export class UpdateOperation { if (!set || Object.keys(set).length === 0) throw new Error('Update set cannot be empty'); - validateSQL(set); - await validateDataType(table, set, transaction); - - if (condition) { - validateSQL(condition); - validateCondition(condition); - } + const parsedSet = await parseAndValidateData(table, set, transaction); + const parsedCondition = condition + ? parseAndValidateCondition(condition) + : {}; const result = await DMLRepository.update( table, - set, - condition, + parsedSet, + parsedCondition, params, transaction, ); diff --git a/src/tests/dml.test.ts b/src/tests/dml.test.ts index 30b315a..91007ce 100644 --- a/src/tests/dml.test.ts +++ b/src/tests/dml.test.ts @@ -221,9 +221,13 @@ describe('DML Operations', () => { name: 'data', table: 'test_dml', condition: { - email: { - $eq: "' OR 1=1; --", - }, + $and: [ + { + email: { + $eq: "' OR 1=1; --", + }, + }, + ], }, orderBy: { created_at: 'ASC', @@ -311,9 +315,13 @@ describe('DML Operations', () => { table: 'non_existent_table', name: 'data', condition: { - external_id: { - $eq: 'user1', - }, + $and: [ + { + external_id: { + $eq: 'user1', + }, + }, + ], }, set: { external_id: 'admin1', @@ -336,9 +344,13 @@ describe('DML Operations', () => { table: 'test_dml', name: 'data', condition: { - external_id: { - $eq: "'; DROP TABLE test_dml; --", - }, + $and: [ + { + external_id: { + $eq: "'; DROP TABLE test_dml; --", + }, + }, + ], }, set: { email: "admin@admin.com'); SELECT * FROM users; --", @@ -391,9 +403,13 @@ describe('DML Operations', () => { table: 'non_existent_table', name: 'data', condition: { - external_id: { - $eq: 'user1', - }, + $and: [ + { + external_id: { + $eq: 'user1', + }, + }, + ], }, params: {}, }, @@ -413,9 +429,13 @@ describe('DML Operations', () => { table: 'test_dml', name: 'data', condition: { - external_id: { - $eq: "' OR 1=1; --", - }, + $and: [ + { + external_id: { + $eq: "' OR 1=1; --", + }, + }, + ], }, params: {}, }, @@ -693,9 +713,13 @@ describe('Test all condition operator on DML Operations', () => { table: 'test_dml', name: 'data', condition: { - count: { - $gte: 10, - }, + $and: [ + { + count: { + $gte: 10, + }, + }, + ], }, orderBy: { count: 'ASC', @@ -719,9 +743,13 @@ describe('Test all condition operator on DML Operations', () => { table: 'test_dml', name: 'data', condition: { - count: { - $lt: 20, - }, + $and: [ + { + count: { + $lt: 20, + }, + }, + ], }, orderBy: { count: 'ASC', @@ -745,9 +773,13 @@ describe('Test all condition operator on DML Operations', () => { table: 'test_dml', name: 'data', condition: { - count: { - $lte: 10, - }, + $and: [ + { + count: { + $lte: 10, + }, + }, + ], }, orderBy: { count: 'ASC', @@ -771,9 +803,13 @@ describe('Test all condition operator on DML Operations', () => { table: 'test_dml', name: 'data', condition: { - count: { - $in: [10, 30], - }, + $and: [ + { + count: { + $in: [10, 30], + }, + }, + ], }, orderBy: { count: 'ASC', @@ -797,9 +833,13 @@ describe('Test all condition operator on DML Operations', () => { table: 'test_dml', name: 'data', condition: { - count: { - $nin: [10, 30], - }, + $and: [ + { + count: { + $nin: [10, 30], + }, + }, + ], }, orderBy: { count: 'ASC', diff --git a/src/types/dml.ts b/src/types/dml.ts index 5335250..7a5ca22 100644 --- a/src/types/dml.ts +++ b/src/types/dml.ts @@ -16,7 +16,6 @@ export type ConditionOperator = | { $in: PrimitiveType[] } | { $nin: PrimitiveType[] }; -// kalau kayak gini bisa ga ya mas? soalnya kemungkinan ada condition yang gak dibungkus and or? atau engga? export type LogicalOperator = | { $and: Condition[] } | { $or: Condition[] } diff --git a/src/utils/condition-parser.ts b/src/utils/condition-parser.ts index d166046..9f37526 100644 --- a/src/utils/condition-parser.ts +++ b/src/utils/condition-parser.ts @@ -2,7 +2,7 @@ import { Condition, ConditionOperator, PrimitiveType } from '../types/dml'; export function parseConditionForQuery( cond: Condition, - replacements: Record, + replacements: any[], params?: Record, ): string { if (!cond || Object.keys(cond).length === 0) return '1=1'; diff --git a/src/utils/validation.ts b/src/utils/validation.ts index a21ed8f..ff2f53e 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -7,61 +7,11 @@ export function validIdentifier(name: string): boolean { return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); } -export function validateSQL(data: any): void { - if (typeof data === 'object' && data !== null) { - for (const value of Object.values(data)) { - if (typeof value === 'string' && /['";]/.test(value)) { - throw new Error(`Invalid SQL input detected`); - } - if (typeof value === 'object') { - validateSQL(value); - } - } - } -} - -export function validateCondition( - condition: Condition, - metadataColumns?: any[], -): void { - if (isLogicalOperator(condition)) { - if ('$and' in condition && Array.isArray(condition.$and)) { - condition.$and.forEach((subCond) => - validateCondition(subCond, metadataColumns), - ); - } - if ('$or' in condition && Array.isArray(condition.$or)) { - condition.$or.forEach((subCond) => - validateCondition(subCond, metadataColumns), - ); - } - } else { - const conditionObject = condition as Record; - for (const key in conditionObject) { - const columnCondition = conditionObject[key]; - - if (isConditionOperator(columnCondition)) { - const operator = Object.keys(columnCondition)[0]; - const value = columnCondition[operator as keyof ConditionOperator]; - - if (metadataColumns) { - const column = metadataColumns.find((col) => col.column_name === key); - if (column && !isValidType(column.data_type.toUpperCase(), value)) { - throw new Error( - `Invalid type for column ${key}: expected ${column.data_type}, got ${typeof value}`, - ); - } - } - } - } - } -} - -export async function validateDataType( +export async function parseAndValidateData( table: string, data: Record, transaction: Transaction, -): Promise { +): Promise> { const metadataTable = await MetadataTableRepository.findOne( { table_name: table }, transaction, @@ -79,28 +29,84 @@ export async function validateDataType( metadataColumns.map((col) => [col.column_name, col]), ); - for (const key in data) { - if (isLogicalOperator(data[key]) || isConditionOperator(data[key])) - continue; + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'string') { + if ( + /(['";])+|(\b(OR|AND|DROP|DELETE|INSERT|UPDATE|SELECT)\b\s+.+\s*=?\s*.+)/i.test( + value, + ) + ) { + throw new Error(`Possible SQL injection detected in column ${key}`); + } + } const column = columnMap.get(key); if (!column) throw new Error(`Column ${key} does not exist in table ${table}`); const expectedType = column.data_type.toUpperCase(); - const actualValue = data[key]; - if (actualValue === null) { + if (value === null) { if (!column.is_nullable) throw new Error(`Column ${key} cannot be NULL`); continue; } - if (!isValidType(expectedType, actualValue)) { + if (!isValidType(expectedType, value)) { throw new Error( - `Invalid type for column ${key}: expected ${expectedType}, got ${typeof actualValue}`, + `Invalid type for column ${key}: expected ${expectedType}, got ${typeof value}`, ); } } + + return data; +} + +export function parseAndValidateCondition( + condition: Condition, + metadataColumns?: any[], +): Condition { + if (isLogicalOperator(condition)) { + if ('$and' in condition && Array.isArray(condition.$and)) { + condition.$and = condition.$and.map((subCond) => + parseAndValidateCondition(subCond, metadataColumns), + ); + } + if ('$or' in condition && Array.isArray(condition.$or)) { + condition.$or = condition.$or.map((subCond) => + parseAndValidateCondition(subCond, metadataColumns), + ); + } + } else { + const conditionObject = condition as Record; + for (const key in conditionObject) { + const columnCondition = conditionObject[key]; + + if (isConditionOperator(columnCondition)) { + const operator = Object.keys(columnCondition)[0]; + const value = columnCondition[operator as keyof ConditionOperator]; + + if (typeof value === 'string') { + if ( + /(['";])+|(\b(OR|AND|DROP|DELETE|INSERT|UPDATE|SELECT)\b\s+.+\s*=?\s*.+)/i.test( + value, + ) + ) { + throw new Error(`Possible SQL injection detected in column ${key}`); + } + } + + if (metadataColumns) { + const column = metadataColumns.find((col) => col.column_name === key); + if (column && !isValidType(column.data_type.toUpperCase(), value)) { + throw new Error( + `Invalid type for column ${key}: expected ${column.data_type}, got ${typeof value}`, + ); + } + } + } + } + } + return condition; } function isConditionOperator(obj: any): obj is ConditionOperator { From 9f04963a4350e177b6d2865d4db48f90c58dba13 Mon Sep 17 00:00:00 2001 From: Rifa Sania Date: Mon, 24 Mar 2025 07:27:43 +0700 Subject: [PATCH 5/6] fixbgt unused-var --- .eslintrc.js | 1 + eslint.config.mjs | 2 + package.json | 1 + src/tests/dml.test.ts | 93 +++++++++++++++---------- src/types/dml.ts | 45 +++++++----- src/utils/condition-parser.ts | 116 ++++++++++++++++--------------- src/utils/validation.ts | 124 ++++++++++++++++++++++++---------- yarn.lock | 5 ++ 8 files changed, 245 insertions(+), 142 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 54a8680..ae7a3ed 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -18,6 +18,7 @@ module.exports = { rules: { "prettier/prettier": "error", "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": "off" }, }; \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 4c10780..f92c433 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,6 +20,7 @@ export default [ "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-var-requires": "off", "no-undef": "off", + "@typescript-eslint/no-unused-vars": "off", }, }, { @@ -30,6 +31,7 @@ export default [ rules: { "no-undef": "off", "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/no-unused-vars": "off", }, }, ]; diff --git a/package.json b/package.json index 9a59d8a..cba1f54 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "jsonwebtoken": "8.5.1", "nanoid": "3", "pg": "^8.13.3", + "pg-format": "^1.0.4", "pg-hstore": "^2.3.4", "sequelize": "^6.37.5", "uuid": "^11.0.5" diff --git a/src/tests/dml.test.ts b/src/tests/dml.test.ts index 91007ce..235df17 100644 --- a/src/tests/dml.test.ts +++ b/src/tests/dml.test.ts @@ -223,9 +223,13 @@ describe('DML Operations', () => { condition: { $and: [ { - email: { - $eq: "' OR 1=1; --", - }, + $and: [ + { + email: { + $eq: "' OR 1=1; --", + }, + }, + ], }, ], }, @@ -239,9 +243,9 @@ describe('DML Operations', () => { }, ]; - await expect( - DMLExecutor.execute(dmlPayload, transaction), - ).rejects.toThrow(); + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Possible SQL injection detected in column email', + ); }); // INSERT @@ -274,15 +278,16 @@ describe('DML Operations', () => { name: 'data', data: { external_id: "user1'; DROP TABLE test_dml; --", - email: "admin@admin.com'); SELECT * FROM users; --", + email: 'admin@admin.com', + count: 10, }, }, }, ]; - await expect( - DMLExecutor.execute(dmlPayload, transaction), - ).rejects.toThrow(); + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Possible SQL injection detected in column external_id', + ); }); test('Insert invalid type: number into text column', async () => { @@ -301,9 +306,9 @@ describe('DML Operations', () => { }, ]; - await expect( - DMLExecutor.execute(dmlPayload, transaction), - ).rejects.toThrow(); + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Invalid type for column external_id: expected TEXT, got number', + ); }); // UPDATE @@ -347,7 +352,7 @@ describe('DML Operations', () => { $and: [ { external_id: { - $eq: "'; DROP TABLE test_dml; --", + $eq: 'user1', }, }, ], @@ -360,9 +365,9 @@ describe('DML Operations', () => { }, ]; - await expect( - DMLExecutor.execute(dmlPayload, transaction), - ).rejects.toThrow(); + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Possible SQL injection detected in column email', + ); }); test('Update invalid type: string into integer column', async () => { @@ -389,9 +394,9 @@ describe('DML Operations', () => { }, ]; - await expect( - DMLExecutor.execute(dmlPayload, transaction), - ).rejects.toThrow(); + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Invalid type for column count: expected INTEGER, got string', + ); }); // DELETE @@ -442,9 +447,9 @@ describe('DML Operations', () => { }, ]; - await expect( - DMLExecutor.execute(dmlPayload, transaction), - ).rejects.toThrow(); + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Possible SQL injection detected in column external_id', + ); }); test('Delete with invalid type in condition: object instead of string', async () => { @@ -468,9 +473,9 @@ describe('DML Operations', () => { }, ]; - await expect( - DMLExecutor.execute(dmlPayload, transaction), - ).rejects.toThrow(); + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Invalid type for column external_id: expected text, got number', + ); }); }); @@ -716,7 +721,7 @@ describe('Test all condition operator on DML Operations', () => { $and: [ { count: { - $gte: 10, + $gte: 30, }, }, ], @@ -732,7 +737,7 @@ describe('Test all condition operator on DML Operations', () => { ]; const result = await DMLExecutor.execute(dmlPayload, transaction); - expect(result[result.length - 1].length).toBe(3); + expect(result[result.length - 1].length).toBe(1); }); test('Select rows where count < 20', async () => { @@ -900,14 +905,30 @@ describe('Test all condition operator on DML Operations', () => { condition: { $or: [ { - count: { - $eq: 10, - }, - }, - { - external_id: { - $eq: 'user2', - }, + $or: [ + { + $or: [ + { + $or: [ + { + $or: [ + { + count: { + $eq: 10, + }, + }, + { + external_id: { + $eq: 'user2', + }, + }, + ], + }, + ], + }, + ], + }, + ], }, ], }, diff --git a/src/types/dml.ts b/src/types/dml.ts index 7a5ca22..85b8543 100644 --- a/src/types/dml.ts +++ b/src/types/dml.ts @@ -4,24 +4,37 @@ export type DMLOperations = | { operation: 'Update'; instruction: UpdateInstruction } | { operation: 'Delete'; instruction: DeleteInstruction }; +export const ConditionOperatorType = { + EQ: '$eq', + NEQ: '$neq', + GT: '$gt', + GTE: '$gte', + LT: '$lt', + LTE: '$lte', + IN: '$in', + NIN: '$nin', +} as const; + +export const OperatorSymbol = { + $eq: '=', + $neq: '!=', + $gt: '>', + $gte: '>=', + $lt: '<', + $lte: '<=', + $in: 'IN', + $nin: 'NOT IN', +} as const; + export type PrimitiveType = string | number | boolean | null; -export type ConditionOperator = - | { $eq: PrimitiveType } - | { $neq: PrimitiveType } - | { $gt: number } - | { $gte: number } - | { $lt: number } - | { $lte: number } - | { $in: PrimitiveType[] } - | { $nin: PrimitiveType[] }; - -export type LogicalOperator = - | { $and: Condition[] } - | { $or: Condition[] } - | { [column: string]: ConditionOperator }; - -export type Condition = LogicalOperator; +export type ConditionOperator = Partial< + Record +>; + +export type LogicalOperator = { $and: Condition[] } | { $or: Condition[] }; + +export type Condition = LogicalOperator | {}; export interface SelectInstruction { table: string; diff --git a/src/utils/condition-parser.ts b/src/utils/condition-parser.ts index 9f37526..aa410fa 100644 --- a/src/utils/condition-parser.ts +++ b/src/utils/condition-parser.ts @@ -1,18 +1,23 @@ -import { Condition, ConditionOperator, PrimitiveType } from '../types/dml'; +import { + Condition, + ConditionOperator, + ConditionOperatorType, + OperatorSymbol, + PrimitiveType, +} from '../types/dml'; export function parseConditionForQuery( cond: Condition, - replacements: any[], + replacements: Record[], params?: Record, ): string { if (!cond || Object.keys(cond).length === 0) return '1=1'; const clauses: string[] = []; - let index = replacements.length + 1; for (const [key, value] of Object.entries(cond)) { switch (key) { - case '$or': + case '$or': { if (Array.isArray(value)) { const orClauses = value .map((v) => `(${parseConditionForQuery(v, replacements, params)})`) @@ -20,8 +25,9 @@ export function parseConditionForQuery( clauses.push(`(${orClauses})`); } break; + } - case '$and': + case '$and': { if (Array.isArray(value)) { const andClauses = value .map((v) => `(${parseConditionForQuery(v, replacements, params)})`) @@ -29,36 +35,17 @@ export function parseConditionForQuery( clauses.push(`(${andClauses})`); } break; + } - default: - if ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) - ) { - const operatorKey = Object.keys(value)[0] as keyof ConditionOperator; - const conditionValue = (value as ConditionOperator)[operatorKey]; - - if (conditionValue !== undefined) { - const sqlOperator = getSQLOperator(operatorKey); - - if (Array.isArray(conditionValue)) { - const paramPlaceholder = `$${index}`; - const sqlArrayType = - typeof conditionValue[0] === 'number' ? 'integer[]' : 'text[]'; - - clauses.push( - `"${key}" ${sqlOperator} (${paramPlaceholder}::${sqlArrayType})`, - ); - replacements[paramPlaceholder] = conditionValue; - } else { - clauses.push(`"${key}" ${getSQLOperator(operatorKey)} $${index}`); - replacements.push(conditionValue); - } - index++; - } - } + default: { + const conditionClause = parseComparisonCondition( + key, + value, + replacements, + ); + if (conditionClause) clauses.push(conditionClause); break; + } } } @@ -88,17 +75,16 @@ export function parseConditionForNamedParams( const value = cond[key as keyof Condition]; if (typeof value === 'object' && value !== null) { - const operatorKey = Object.keys(value)[0] as keyof ConditionOperator; - let paramValue: PrimitiveType = (value as ConditionOperator)[ - operatorKey - ]; + const operatorKey = Object.keys( + value, + )[0] as keyof typeof ConditionOperatorType; + let paramValue: PrimitiveType = ( + value as Record + )[operatorKey]; if (typeof paramValue === 'string') { - if ( - (paramValue as string).startsWith('{{') && - (paramValue as string).endsWith('}}') - ) { - const paramKey = (paramValue as string).slice(2, -2).trim(); + if (paramValue.startsWith('{{') && paramValue.endsWith('}}')) { + const paramKey = paramValue.slice(2, -2).trim(); paramValue = params?.[paramKey] ?? null; } } @@ -125,17 +111,39 @@ export function parseConditionForNamedParams( return buildClause(condition); } -function getSQLOperator(operator: keyof ConditionOperator): string { - const operatorMap: Record = { - $eq: '=', - $neq: '!=', - $gt: '>', - $gte: '>=', - $lt: '<', - $lte: '<=', - $in: `IN`, - $nin: `NOT IN`, - }; +export function getSQLOperator(operator: keyof ConditionOperator): string { + return OperatorSymbol[operator as keyof typeof OperatorSymbol] || '='; +} - return operatorMap[operator] || '='; +function parseComparisonCondition( + column: string, + value: any, + replacements: any[], +): string | null { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const operatorKey = Object.keys( + value, + )[0] as keyof typeof ConditionOperatorType; + const conditionValue = ( + value as Record + )[operatorKey]; + + if (conditionValue !== undefined) { + const sqlOperator = getSQLOperator(operatorKey); + const index = replacements.length + 1; + + if (Array.isArray(conditionValue)) { + const paramPlaceholder = `$${index}`; + const sqlArrayType = + typeof conditionValue[0] === 'number' ? 'integer[]' : 'text[]'; + + replacements.push(conditionValue); + return `"${column}" ${sqlOperator} (${paramPlaceholder}::${sqlArrayType})`; + } else { + replacements.push(conditionValue); + return `"${column}" ${sqlOperator} $${index}`; + } + } + } + return null; } diff --git a/src/utils/validation.ts b/src/utils/validation.ts index ff2f53e..8246c2b 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -1,7 +1,13 @@ import { Transaction } from 'sequelize'; import MetadataColumnRepository from '../repositories/metadata-column-repository'; import MetadataTableRepository from '../repositories/metadata-table-repository'; -import { Condition, ConditionOperator, LogicalOperator } from '../types/dml'; +import { + Condition, + ConditionOperator, + ConditionOperatorType, + LogicalOperator, +} from '../types/dml'; +import format from 'pg-format'; export function validIdentifier(name: string): boolean { return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); @@ -31,10 +37,15 @@ export async function parseAndValidateData( for (const [key, value] of Object.entries(data)) { if (typeof value === 'string') { + const sanitizedValue = format.literal(value); + const normalizedValue = sanitizedValue.replace(/\s+/g, ' ').trim(); + if ( - /(['";])+|(\b(OR|AND|DROP|DELETE|INSERT|UPDATE|SELECT)\b\s+.+\s*=?\s*.+)/i.test( - value, - ) + normalizedValue.includes(';') || + /\bOR\b/i.test(normalizedValue) || + /\bAND\b/i.test(normalizedValue) || + normalizedValue.includes('--') || + normalizedValue.includes('#') ) { throw new Error(`Possible SQL injection detected in column ${key}`); } @@ -64,59 +75,100 @@ export async function parseAndValidateData( export function parseAndValidateCondition( condition: Condition, metadataColumns?: any[], + hasLogicalOperator = false, ): Condition { + const isRoot = hasLogicalOperator === false; + if (isRoot) { + if (Object.keys(condition).length === 0) { + return {}; + } + if (!isLogicalOperator(condition)) { + throw new Error('Invalid condition structure.'); + } + } + if (isLogicalOperator(condition)) { if ('$and' in condition && Array.isArray(condition.$and)) { - condition.$and = condition.$and.map((subCond) => - parseAndValidateCondition(subCond, metadataColumns), - ); + condition.$and = condition.$and + .map((subCond) => + parseAndValidateCondition(subCond, metadataColumns, true), + ) + .filter((subCond) => Object.keys(subCond).length > 0); } if ('$or' in condition && Array.isArray(condition.$or)) { - condition.$or = condition.$or.map((subCond) => - parseAndValidateCondition(subCond, metadataColumns), - ); + condition.$or = condition.$or + .map((subCond) => + parseAndValidateCondition(subCond, metadataColumns, true), + ) + .filter((subCond) => Object.keys(subCond).length > 0); } } else { - const conditionObject = condition as Record; - for (const key in conditionObject) { - const columnCondition = conditionObject[key]; - - if (isConditionOperator(columnCondition)) { - const operator = Object.keys(columnCondition)[0]; - const value = columnCondition[operator as keyof ConditionOperator]; - - if (typeof value === 'string') { - if ( - /(['";])+|(\b(OR|AND|DROP|DELETE|INSERT|UPDATE|SELECT)\b\s+.+\s*=?\s*.+)/i.test( - value, - ) - ) { - throw new Error(`Possible SQL injection detected in column ${key}`); - } + validateAndSanitizeCondition(condition, metadataColumns); + } + + return condition; +} + +function validateAndSanitizeCondition( + condition: Condition, + metadataColumns?: any[], +): Condition { + const conditionObject = condition as Record; + + for (const key in conditionObject) { + const columnCondition = conditionObject[key]; + + if (isConditionOperator(columnCondition)) { + const operator = Object.keys(columnCondition)[0]; + const value = columnCondition[operator as keyof ConditionOperator]; + + if (typeof value === 'string') { + const sanitizedValue = format.literal(value); + const normalizedValue = sanitizedValue.replace(/\s+/g, ' ').trim(); + + if ( + normalizedValue.includes(';') || + /\bOR\b/i.test(normalizedValue) || + /\bAND\b/i.test(normalizedValue) || + normalizedValue.includes('--') || + normalizedValue.includes('#') + ) { + throw new Error(`Possible SQL injection detected in column ${key}`); } + } - if (metadataColumns) { - const column = metadataColumns.find((col) => col.column_name === key); - if (column && !isValidType(column.data_type.toUpperCase(), value)) { - throw new Error( - `Invalid type for column ${key}: expected ${column.data_type}, got ${typeof value}`, - ); - } + if (metadataColumns) { + const column = metadataColumns.find((col) => col.column_name === key); + if (column && !isValidType(column.data_type.toUpperCase(), value)) { + throw new Error( + `Invalid type for column ${key}: expected ${column.data_type}, got ${typeof value}`, + ); } } } } + return condition; } +// function isConditionOperator(obj: any): obj is ConditionOperatorType { +// return ( +// typeof obj === 'object' && +// obj !== null && +// Object.keys(obj).some((key) => +// ['$eq', '$neq', '$gt', '$gte', '$lt', '$lte', '$in', '$nin'].includes( +// key, +// ), +// ) +// ); +// } + function isConditionOperator(obj: any): obj is ConditionOperator { return ( typeof obj === 'object' && obj !== null && Object.keys(obj).some((key) => - ['$eq', '$neq', '$gt', '$gte', '$lt', '$lte', '$in', '$nin'].includes( - key, - ), + (Object.values(ConditionOperatorType) as string[]).includes(key), ) ); } diff --git a/yarn.lock b/yarn.lock index 43f299f..a801ded 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3555,6 +3555,11 @@ pg-connection-string@^2.6.1, pg-connection-string@^2.7.0: resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.7.0.tgz#f1d3489e427c62ece022dba98d5262efcb168b37" integrity sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA== +pg-format@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/pg-format/-/pg-format-1.0.4.tgz#27734236c2ad3f4e5064915a59334e20040a828e" + integrity sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw== + pg-hstore@^2.3.4: version "2.3.4" resolved "https://registry.yarnpkg.com/pg-hstore/-/pg-hstore-2.3.4.tgz#4425e3e2a3e15d2a334c35581186c27cf2e9b8dd" From 7bd77e1d2ede91345a3025aa995be75015a59c32 Mon Sep 17 00:00:00 2001 From: Rifa Sania Date: Mon, 24 Mar 2025 07:34:33 +0700 Subject: [PATCH 6/6] fixed code --- src/utils/validation.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 8246c2b..dc5f042 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -151,18 +151,6 @@ function validateAndSanitizeCondition( return condition; } -// function isConditionOperator(obj: any): obj is ConditionOperatorType { -// return ( -// typeof obj === 'object' && -// obj !== null && -// Object.keys(obj).some((key) => -// ['$eq', '$neq', '$gt', '$gte', '$lt', '$lte', '$in', '$nin'].includes( -// key, -// ), -// ) -// ); -// } - function isConditionOperator(obj: any): obj is ConditionOperator { return ( typeof obj === 'object' &&