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/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..7184216 --- /dev/null +++ b/src/controllers/dml-controller.ts @@ -0,0 +1,26 @@ +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(); + + 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..704fe0d --- /dev/null +++ b/src/controllers/schema-controller.ts @@ -0,0 +1,14 @@ +import { Request, Response } from 'express'; +import SchemaRepository from '../repositories/schema-repository'; + +export const schema = async (req: Request, res: Response) => { + try { + const tables = await SchemaRepository.getSchemas(); + + return res.json({ success: true, tables }); + } catch (error) { + 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..18d6508 100644 --- a/src/models/metadata-table.ts +++ b/src/models/metadata-table.ts @@ -6,7 +6,6 @@ class MetadataTable extends Model { public table_name!: string; public readonly createdAt!: Date; public readonly updatedAt!: Date; - primaryKey: any; } 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..c55628e --- /dev/null +++ b/src/operations/dml/delete.ts @@ -0,0 +1,48 @@ +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 { + parseAndValidateCondition, + validIdentifier, +} from '../../utils/validation'; + +export class DeleteOperation { + static async execute( + instruction: DeleteInstruction, + transaction: Transaction, + ) { + const { table, condition, params } = instruction; + + if (!validIdentifier(table)) + throw new Error(`Invalid table name: ${table}`); + + 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, + ); + + const parsedCondition = condition + ? parseAndValidateCondition(condition, metadataColumns) + : {}; + const result = await DMLRepository.delete( + table, + parsedCondition, + 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..e30b0d3 --- /dev/null +++ b/src/operations/dml/insert.ts @@ -0,0 +1,30 @@ +import { Transaction } from 'sequelize'; +import { InsertInstruction } from '../../types/dml'; +import { DMLRepository } from '../../repositories/dml-repository'; +import { parseAndValidateData, validIdentifier } from '../../utils/validation'; + +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 parsedData = await parseAndValidateData(table, data, transaction); + const result = await DMLRepository.insert(table, parsedData, 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 new file mode 100644 index 0000000..ce2162e --- /dev/null +++ b/src/operations/dml/select.ts @@ -0,0 +1,43 @@ +import { Transaction } from 'sequelize'; +import { SelectInstruction } from '../../types/dml'; +import { DMLRepository } from '../../repositories/dml-repository'; +import MetadataTableRepository from '../../repositories/metadata-table-repository'; +import { + parseAndValidateCondition, + validIdentifier, +} from '../../utils/validation'; + +export class SelectOperation { + static async execute( + instruction: SelectInstruction, + transaction: Transaction, + ) { + const { table, condition, orderBy, limit, offset, params } = instruction; + + if (!validIdentifier(table)) + throw new Error(`Invalid table name: ${table}`); + + const metadataTable = await MetadataTableRepository.findOne( + { table_name: table }, + transaction, + ); + if (!metadataTable) { + throw new Error(`Table ${table} does not exist`); + } + + const parsedCondition = condition + ? parseAndValidateCondition(condition) + : {}; + const result = await DMLRepository.select( + table, + parsedCondition, + orderBy, + limit, + offset, + params, + transaction, + ); + + return result; + } +} diff --git a/src/operations/dml/update.ts b/src/operations/dml/update.ts new file mode 100644 index 0000000..993d88f --- /dev/null +++ b/src/operations/dml/update.ts @@ -0,0 +1,42 @@ +import { Transaction } from 'sequelize'; +import { UpdateInstruction } from '../../types/dml'; +import { DMLRepository } from '../../repositories/dml-repository'; +import { + parseAndValidateCondition, + parseAndValidateData, + validIdentifier, +} from '../../utils/validation'; + +export class UpdateOperation { + static async execute( + instruction: UpdateInstruction, + 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'); + + const parsedSet = await parseAndValidateData(table, set, transaction); + const parsedCondition = condition + ? parseAndValidateCondition(condition) + : {}; + + const result = await DMLRepository.update( + table, + parsedSet, + parsedCondition, + 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 new file mode 100644 index 0000000..8956db6 --- /dev/null +++ b/src/operations/execute.ts @@ -0,0 +1,58 @@ +import { Transaction } from 'sequelize'; +import { DMLOperations } 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, + transaction, + ); + results.push(selectResult); + break; + } + case 'Insert': { + const insertResult = await InsertOperation.execute( + instruction, + transaction, + ); + results.push(insertResult); + break; + } + case 'Update': { + const updateResult = await UpdateOperation.execute( + instruction, + transaction, + ); + if (updateResult) { + results.push(updateResult); + } + break; + } + case 'Delete': { + const deleteResult = await DeleteOperation.execute( + instruction, + transaction, + ); + results.push(deleteResult); + 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..f1171e0 --- /dev/null +++ b/src/repositories/dml-repository.ts @@ -0,0 +1,149 @@ +import { sequelize } from '../config/database'; +import { QueryTypes, Transaction } from 'sequelize'; +import { validIdentifier } from '../utils/validation'; +import { Condition } from '../types/dml'; +import { + parseConditionForNamedParams, + parseConditionForQuery, +} from '../utils/condition-parser'; + +export class DMLRepository { + static async insert( + table: string, + data: Record, + transaction?: Transaction, + ) { + if (!validIdentifier(table)) + throw new Error(`Invalid table name: ${table}`); + + const keys = Object.keys(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 *`; + + const result = await sequelize.query(query, { + type: QueryTypes.INSERT, + bind: values, + transaction, + }); + return result; + } + + static async update( + table: string, + set: Record, + condition: Condition, + 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'); + + 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[] = []; + 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} RETURNING id;`; + + const result = await sequelize.query(query, { + type: QueryTypes.UPDATE, + bind: replacements, + transaction, + }); + return result[0]; + } + + static async delete( + table: string, + condition: Condition, + 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} RETURNING id;`; + + const result = await sequelize.query(query, { + type: QueryTypes.DELETE, + bind: replacements, + transaction, + }); + return result[0]; + } + + public static async select( + table: string, + condition: any, + orderBy?: Record, + limit?: number, + offset?: number, + 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 = {}; + + 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..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/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..235df17 --- /dev/null +++ b/src/tests/dml.test.ts @@ -0,0 +1,948 @@ +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, + }, + }, + }, + }, + { + 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); + }); + + 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', + count: 6, + }, + }, + }, + { + operation: 'Update', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + $and: [ + { + external_id: { + $eq: 'user1', + }, + }, + ], + }, + set: { + external_id: 'admin1', + }, + params: {}, + }, + }, + { + operation: 'Delete', + instruction: { + 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); + }); + + // SELECT + test('Prevent SQL injection on select', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + name: 'data', + table: 'test_dml', + condition: { + $and: [ + { + $and: [ + { + email: { + $eq: "' OR 1=1; --", + }, + }, + ], + }, + ], + }, + orderBy: { + created_at: 'ASC', + }, + limit: 10, + offset: 0, + params: {}, + }, + }, + ]; + + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Possible SQL injection detected in column email', + ); + }); + + // 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', + ); + }); + + 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', + count: 10, + }, + }, + }, + ]; + + 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 () => { + 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( + 'Invalid type for column external_id: expected TEXT, got number', + ); + }); + + // UPDATE + test(' Fail update row into non-existent table', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Update', + instruction: { + table: 'non_existent_table', + name: 'data', + condition: { + $and: [ + { + external_id: { + $eq: 'user1', + }, + }, + ], + }, + set: { + external_id: 'admin1', + }, + params: {}, + }, + }, + ]; + + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Table non_existent_table does not exist', + ); + }); + + test('Prevent SQL injection on update', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Update', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + $and: [ + { + external_id: { + $eq: 'user1', + }, + }, + ], + }, + set: { + email: "admin@admin.com'); SELECT * FROM users; --", + }, + params: {}, + }, + }, + ]; + + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Possible SQL injection detected in column email', + ); + }); + + 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( + 'Invalid type for column count: expected INTEGER, got string', + ); + }); + + // DELETE + test('Fail delete non-existent table', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Delete', + instruction: { + table: 'non_existent_table', + name: 'data', + condition: { + $and: [ + { + external_id: { + $eq: 'user1', + }, + }, + ], + }, + params: {}, + }, + }, + ]; + + await expect(DMLExecutor.execute(dmlPayload, transaction)).rejects.toThrow( + 'Table non_existent_table does not exist', + ); + }); + + test('Prevent SQL injection on delete', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Delete', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + $and: [ + { + external_id: { + $eq: "' OR 1=1; --", + }, + }, + ], + }, + params: {}, + }, + }, + ]; + + 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 () => { + 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( + 'Invalid type for column external_id: expected text, got number', + ); + }); +}); + +// 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: { + $and: [ + { + count: { + $gte: 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 where count < 20', async () => { + const dmlPayload: DMLOperations[] = [ + { + operation: 'Select', + instruction: { + table: 'test_dml', + name: 'data', + condition: { + $and: [ + { + 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: { + $and: [ + { + 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: { + $and: [ + { + 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: { + $and: [ + { + 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: [ + { + $or: [ + { + $or: [ + { + $or: [ + { + $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/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..85b8543 --- /dev/null +++ b/src/types/dml.ts @@ -0,0 +1,68 @@ +export type DMLOperations = + | { operation: 'Select'; instruction: SelectInstruction } + | { operation: 'Insert'; instruction: InsertInstruction } + | { 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 = Partial< + Record +>; + +export type LogicalOperator = { $and: Condition[] } | { $or: Condition[] }; + +export type Condition = LogicalOperator | {}; + +export interface SelectInstruction { + table: string; + name: string; + orderBy: Record; + condition: Condition; + limit: number; + offset: number; + params: Record; +} + +export interface InsertInstruction { + table: string; + name: string; + data: Record; +} + +export interface UpdateInstruction { + table: string; + name: string; + condition: Condition; + set: Record; + params: Record; +} + +export interface DeleteInstruction { + table: string; + name: 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..aa410fa --- /dev/null +++ b/src/utils/condition-parser.ts @@ -0,0 +1,149 @@ +import { + Condition, + ConditionOperator, + ConditionOperatorType, + OperatorSymbol, + PrimitiveType, +} from '../types/dml'; + +export function parseConditionForQuery( + cond: Condition, + replacements: Record[], + params?: Record, +): string { + if (!cond || Object.keys(cond).length === 0) return '1=1'; + + const clauses: string[] = []; + + for (const [key, value] of Object.entries(cond)) { + 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: { + const conditionClause = parseComparisonCondition( + key, + value, + replacements, + ); + if (conditionClause) clauses.push(conditionClause); + break; + } + } + } + + return clauses.length > 0 ? clauses.join(' AND ') : '1=1'; +} + +export function parseConditionForNamedParams( + condition: Condition, + 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 { + const value = cond[key as keyof Condition]; + + if (typeof value === 'object' && value !== null) { + const operatorKey = Object.keys( + value, + )[0] as keyof typeof ConditionOperatorType; + let paramValue: PrimitiveType = ( + value as Record + )[operatorKey]; + + if (typeof paramValue === 'string') { + if (paramValue.startsWith('{{') && paramValue.endsWith('}}')) { + const paramKey = paramValue.slice(2, -2).trim(); + paramValue = params?.[paramKey] ?? null; + } + } + + const paramPlaceholder = `param_${depth}_${key}`; + replacements[paramPlaceholder] = paramValue; + + if (Array.isArray(paramValue)) { + conditions.push( + `"${key}" ${getSQLOperator(operatorKey)} (:${paramPlaceholder})`, + ); + } else { + conditions.push( + `"${key}" ${getSQLOperator(operatorKey)} :${paramPlaceholder}`, + ); + } + } + } + } + + return conditions.length > 0 ? conditions.join(' AND ') : ''; + }; + + return buildClause(condition); +} + +export function getSQLOperator(operator: keyof ConditionOperator): string { + return OperatorSymbol[operator as keyof typeof OperatorSymbol] || '='; +} + +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 b2b0995..dc5f042 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -1,3 +1,187 @@ +import { Transaction } from 'sequelize'; +import MetadataColumnRepository from '../repositories/metadata-column-repository'; +import MetadataTableRepository from '../repositories/metadata-table-repository'; +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); } + +export async function parseAndValidateData( + 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, value] of Object.entries(data)) { + 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}`); + } + } + + const column = columnMap.get(key); + if (!column) + throw new Error(`Column ${key} does not exist in table ${table}`); + + const expectedType = column.data_type.toUpperCase(); + + if (value === null) { + if (!column.is_nullable) throw new Error(`Column ${key} cannot be NULL`); + continue; + } + + if (!isValidType(expectedType, value)) { + throw new Error( + `Invalid type for column ${key}: expected ${expectedType}, got ${typeof value}`, + ); + } + } + + return data; +} + +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, 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, true), + ) + .filter((subCond) => Object.keys(subCond).length > 0); + } + } else { + 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}`, + ); + } + } + } + } + + return condition; +} + +function isConditionOperator(obj: any): obj is ConditionOperator { + return ( + typeof obj === 'object' && + obj !== null && + Object.keys(obj).some((key) => + (Object.values(ConditionOperatorType) as string[]).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; +} 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"