From 393fdea95a8dfadd06a808e2fe0901d6e44dcb68 Mon Sep 17 00:00:00 2001 From: DaxServer Date: Sun, 28 Sep 2025 13:45:21 +0200 Subject: [PATCH 1/2] feat(replace-operation): Implement replace operation service This commit introduces a new `ReplaceOperationService` that provides functionality to perform replace operations on a column in a project table. The service supports case-sensitive and whole-word replacement, and it calculates the number of affected rows before executing the update. The changes include: - Implement the `ReplaceOperationService` class with methods to build the appropriate replace expression, count the affected rows, and execute the update. - Add new API endpoint `/projects/:projectId/replace` that allows users to perform a replace operation on a project table. - Introduce new request and response schemas for the replace operation endpoint. These changes enable users to easily replace values in project table columns, which is a common requirement for data manipulation tasks. --- backend/src/api/project/index.ts | 68 ++++ backend/src/api/project/schemas.ts | 28 ++ .../src/services/replace-operation.service.ts | 122 +++++++ .../tests/api/project/project.replace.test.ts | 310 ++++++++++++++++++ .../replace-operation.service.test.ts | 213 ++++++++++++ 5 files changed, 741 insertions(+) create mode 100644 backend/src/services/replace-operation.service.ts create mode 100644 backend/tests/api/project/project.replace.test.ts create mode 100644 backend/tests/services/replace-operation.service.test.ts diff --git a/backend/src/api/project/index.ts b/backend/src/api/project/index.ts index 8eacd0c..45722fe 100644 --- a/backend/src/api/project/index.ts +++ b/backend/src/api/project/index.ts @@ -4,10 +4,13 @@ import { PaginationQuery, ProjectParams, ProjectResponseSchema, + ReplaceOperationResponseSchema, + ReplaceOperationSchema, type Project, } from '@backend/api/project/schemas' import { databasePlugin } from '@backend/plugins/database' import { errorHandlerPlugin } from '@backend/plugins/error-handler' +import { ReplaceOperationService } from '@backend/services/replace-operation.service' import { ApiErrorHandler } from '@backend/types/error-handler' import { ApiErrors } from '@backend/types/error-schemas' import { enhanceSchemaWithTypes, type DuckDBTablePragma } from '@backend/utils/duckdb-types' @@ -527,3 +530,68 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' }) }, }, ) + + .post( + '/:projectId/replace', + async ({ + db, + params: { projectId }, + body: { column, find, replace, caseSensitive, wholeWord }, + status, + }) => { + const table = `project_${projectId}` + + // Check if column exists + const columnExistsReader = await db().runAndReadAll( + 'SELECT 1 FROM information_schema.columns WHERE table_name = ? AND column_name = ?', + [table, column], + ) + + if (columnExistsReader.getRows().length === 0) { + return status( + 400, + ApiErrorHandler.validationErrorWithData('Column not found', [ + `Column '${column}' does not exist in table '${table}'`, + ]), + ) + } + + const replaceService = new ReplaceOperationService(db()) + + try { + const result = await replaceService.performReplace({ + table, + column, + find, + replace, + caseSensitive, + wholeWord, + }) + + return result + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + return status( + 500, + ApiErrorHandler.internalServerErrorWithData('Failed to perform replace operation', [ + errorMessage, + ]), + ) + } + }, + { + body: ReplaceOperationSchema, + response: { + 200: ReplaceOperationResponseSchema, + 400: ApiErrors, + 404: ApiErrors, + 422: ApiErrors, + 500: ApiErrors, + }, + detail: { + summary: 'Perform replace operation on a column', + description: 'Replace text in a specific column of a project table', + tags, + }, + }, + ) diff --git a/backend/src/api/project/schemas.ts b/backend/src/api/project/schemas.ts index a74c81d..7e12abb 100644 --- a/backend/src/api/project/schemas.ts +++ b/backend/src/api/project/schemas.ts @@ -49,3 +49,31 @@ export const GetProjectByIdResponse = t.Object({ }), }) export type GetProjectByIdResponse = typeof GetProjectByIdResponse.static + +// Replace operation schema +export const ReplaceOperationSchema = t.Object({ + column: t.String({ + minLength: 1, + error: 'Column name is required and must be at least 1 character long', + }), + find: t.String({ + minLength: 1, + error: 'Find value is required and must be at least 1 character long', + }), + replace: t.String({ + default: '', + }), + caseSensitive: t.BooleanString({ + default: false, + }), + wholeWord: t.BooleanString({ + default: false, + }), +}) +export type ReplaceOperationSchema = typeof ReplaceOperationSchema.static + +export const ReplaceOperationResponseSchema = t.Object({ + message: t.String(), + affectedRows: t.Number(), +}) +export type ReplaceOperationResponseSchema = typeof ReplaceOperationResponseSchema.static diff --git a/backend/src/services/replace-operation.service.ts b/backend/src/services/replace-operation.service.ts new file mode 100644 index 0000000..997e335 --- /dev/null +++ b/backend/src/services/replace-operation.service.ts @@ -0,0 +1,122 @@ +import type { DuckDBConnection } from '@duckdb/node-api' + +export interface ReplaceOperationParams { + table: string + column: string + find: string + replace: string + caseSensitive: boolean + wholeWord: boolean +} + +export interface ReplaceOperationResult { + message: string + affectedRows: number +} + +export class ReplaceOperationService { + constructor(private db: DuckDBConnection) {} + + /** + * Performs a replace operation on a column in a project table + */ + async performReplace(params: ReplaceOperationParams): Promise { + const { table, column, find, replace, caseSensitive, wholeWord } = params + + // Build the REPLACE operation based on parameters + const replaceExpression = this.buildReplaceExpression( + column, + find, + replace, + caseSensitive, + wholeWord, + ) + + // Count rows that will be affected before the update + const affectedRows = await this.countAffectedRows(table, column, find, caseSensitive, wholeWord) + + // Execute the update + await this.db.run( + `UPDATE "${table}" SET "${column}" = ${replaceExpression} WHERE "${column}" IS NOT NULL`, + ) + + return { + message: `Successfully replaced '${find}' with '${replace}' in column '${column}'`, + affectedRows, + } + } + + /** + * Builds the appropriate replace expression based on the operation parameters + */ + private buildReplaceExpression( + column: string, + find: string, + replace: string, + caseSensitive: boolean, + wholeWord: boolean, + ): string { + if (wholeWord) { + // For whole word replacement, use regex with word boundaries + const flags = caseSensitive ? 'g' : 'gi' + const pattern = `\\b${this.escapeRegex(find)}\\b` + return `regexp_replace("${column}", '${pattern}', '${this.escapeSql(replace)}', '${flags}')` + } + + // For partial replacement, use simple replace or regex + if (caseSensitive) { + return `replace("${column}", '${this.escapeSql(find)}', '${this.escapeSql(replace)}')` + } + + // Case-insensitive replacement using regex + const flags = 'gi' + const pattern = this.escapeRegex(find) + return `regexp_replace("${column}", '${pattern}', '${this.escapeSql(replace)}', '${flags}')` + } + + /** + * Counts the number of rows that will be affected by the replace operation + */ + private async countAffectedRows( + table: string, + column: string, + find: string, + caseSensitive: boolean, + wholeWord: boolean, + ): Promise { + let countQuery: string + + if (wholeWord) { + // For whole word matching, count rows where the word appears as a whole word + const flags = caseSensitive ? '' : 'i' + const pattern = `\\b${this.escapeRegex(find)}\\b` + countQuery = `SELECT COUNT(*) as count FROM "${table}" WHERE "${column}" IS NOT NULL AND regexp_matches("${column}", '${pattern}', '${flags}')` + } else { + if (caseSensitive) { + countQuery = `SELECT COUNT(*) as count FROM "${table}" WHERE "${column}" IS NOT NULL AND position('${this.escapeSql(find)}' in "${column}") > 0` + } else { + const pattern = this.escapeRegex(find) + countQuery = `SELECT COUNT(*) as count FROM "${table}" WHERE "${column}" IS NOT NULL AND regexp_matches("${column}", '${pattern}', 'i')` + } + } + + const countBeforeReader = await this.db.runAndReadAll(countQuery) + const countBeforeResult = countBeforeReader.getRowObjectsJson() + + return Number(countBeforeResult[0]?.count ?? 0) + } + + /** + * Escapes special regex characters in a string + */ + private escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/'/g, "''") + } + + /** + * Escapes single quotes for SQL + */ + private escapeSql(str: string): string { + return str.replace(/'/g, "''") + } +} diff --git a/backend/tests/api/project/project.replace.test.ts b/backend/tests/api/project/project.replace.test.ts new file mode 100644 index 0000000..21a87d7 --- /dev/null +++ b/backend/tests/api/project/project.replace.test.ts @@ -0,0 +1,310 @@ +import { projectRoutes } from '@backend/api/project' +import { closeDb, initializeDb } from '@backend/plugins/database' +import { treaty } from '@elysiajs/eden' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { Elysia } from 'elysia' +import { tmpdir } from 'node:os' + +interface TestData { + name: string + email: string + city: string +} + +const TEST_DATA: TestData[] = [ + { name: 'John Doe', email: 'john@example.com', city: 'New York' }, + { name: 'Jane Smith', email: 'jane@example.com', city: 'Los Angeles' }, + { name: 'Bob Johnson', email: 'bob@example.com', city: 'New York' }, + { name: 'Alice Brown', email: 'alice@test.com', city: 'Chicago' }, + { name: 'Charlie Davis', email: 'charlie@example.com', city: 'New York' }, +] + +const createTestApi = () => { + return treaty(new Elysia().use(projectRoutes)).api +} +const tempFilePath = tmpdir() + '/test-data.json' + +describe('Project API - find and replace', () => { + let api: ReturnType + let projectId: string + + const cleanupTestData = async () => { + expect(projectId).toBeDefined() + expect(api).toBeDefined() + + const { error } = await api.project({ projectId }).delete() + expect(error).toBeNull() + + const tempFile = Bun.file(tempFilePath) + await tempFile.delete() + } + + const importTestData = async () => { + await Bun.write(tempFilePath, JSON.stringify(TEST_DATA)) + + const { status, error } = await api.project({ projectId }).import.post({ + filePath: tempFilePath, + }) + + expect(error).toBeNull() + expect(status).toBe(201) + } + + beforeEach(async () => { + await initializeDb(':memory:') + api = createTestApi() + + const { data, status, error } = await api.project.post({ + name: 'Test Project for replace', + }) + expect(error).toBeNull() + expect(status).toBe(201) + projectId = (data as any)!.data!.id as string + + await importTestData() + }) + + afterEach(async () => { + await cleanupTestData() + await closeDb() + }) + + test('should perform basic replace operation', async () => { + const { data, status, error } = await api.project({ projectId }).replace.post({ + column: 'city', + find: 'New York', + replace: 'San Francisco', + caseSensitive: false, + wholeWord: false, + }) + + expect(status).toBe(200) + expect(error).toBeNull() + expect(data).toEqual({ + message: "Successfully replaced 'New York' with 'San Francisco' in column 'city'", + affectedRows: 3, + }) + + // Verify the data was actually changed + const { data: projectData } = await api.project({ projectId }).get({ + query: { offset: 0, limit: 25 }, + }) + + expect(projectData).toHaveProperty( + 'data', + expect.arrayContaining([ + expect.objectContaining({ city: 'San Francisco' }), + expect.objectContaining({ city: 'Los Angeles' }), + expect.objectContaining({ city: 'Chicago' }), + ]), + ) + const cities = projectData!.data?.map((row: TestData) => row.city) + expect(cities.filter((city: string) => city === 'San Francisco')).toHaveLength(3) + expect(cities.filter((city: string) => city === 'New York')).toHaveLength(0) + }) + + describe('case sensitivity tests', () => { + test.each([ + { + description: 'case-sensitive replace operation', + column: 'email', + find: 'example.com', + replace: 'company.com', + caseSensitive: true, + wholeWord: false, + expectedMessage: "Successfully replaced 'example.com' with 'company.com' in column 'email'", + expectedAffectedRows: 4, + }, + { + description: 'case-insensitive replace operation', + column: 'email', + find: 'EXAMPLE.COM', + replace: 'company.com', + caseSensitive: false, + wholeWord: false, + expectedMessage: "Successfully replaced 'EXAMPLE.COM' with 'company.com' in column 'email'", + expectedAffectedRows: 4, + }, + ])( + '$description', + async ({ + column, + find, + replace, + caseSensitive, + wholeWord, + expectedMessage, + expectedAffectedRows, + }) => { + const { data, status, error } = await api.project({ projectId }).replace.post({ + column, + find, + replace, + caseSensitive, + wholeWord, + }) + + expect(status).toBe(200) + expect(error).toBeNull() + expect(data).toEqual({ + message: expectedMessage, + affectedRows: expectedAffectedRows, + }) + }, + ) + }) + + describe('whole word tests', () => { + test.each([ + { + description: 'whole word replace operation', + column: 'name', + find: 'John', + replace: 'Jonathan', + caseSensitive: false, + wholeWord: true, + expectedMessage: "Successfully replaced 'John' with 'Jonathan' in column 'name'", + expectedAffectedRows: 1, + }, + { + description: 'whole word with case sensitivity', + column: 'name', + find: 'john', + replace: 'jonathan', + caseSensitive: true, + wholeWord: true, + expectedMessage: "Successfully replaced 'john' with 'jonathan' in column 'name'", + expectedAffectedRows: 0, + }, + ])( + '$description', + async ({ + column, + find, + replace, + caseSensitive, + wholeWord, + expectedMessage, + expectedAffectedRows, + }) => { + const { data, status, error } = await api.project({ projectId }).replace.post({ + column, + find, + replace, + caseSensitive, + wholeWord, + }) + + expect(status).toBe(200) + expect(error).toBeNull() + expect(data).toEqual({ + message: expectedMessage, + affectedRows: expectedAffectedRows, + }) + }, + ) + }) + + describe('replace with empty string', () => { + test.each([ + { + description: 'basic empty string replace', + column: 'city', + find: 'New York', + replace: '', + expectedMessage: "Successfully replaced 'New York' with '' in column 'city'", + expectedAffectedRows: 3, + }, + { + description: 'empty string replace with whole word', + column: 'name', + find: 'John', + replace: '', + expectedMessage: "Successfully replaced 'John' with '' in column 'name'", + expectedAffectedRows: 2, + }, + ])('$description', async ({ column, find, replace, expectedMessage, expectedAffectedRows }) => { + const { data, status, error } = await api.project({ projectId }).replace.post({ + column, + find, + replace, + caseSensitive: false, + wholeWord: false, + }) + + expect(status).toBe(200) + expect(error).toBeNull() + expect(data).toEqual({ + message: expectedMessage, + affectedRows: expectedAffectedRows, + }) + }) + }) + + test('should return 400 for non-existent column', async () => { + const { data, status, error } = await api.project({ projectId }).replace.post({ + column: 'nonexistent_column', + find: 'value', + replace: 'new_value', + caseSensitive: false, + wholeWord: false, + }) + + expect(status).toBe(400) + expect(data).toBeNull() + expect(error).toHaveProperty('status', 400) + expect(error).toHaveProperty('value', [ + { + code: 'VALIDATION', + message: 'Column not found', + details: [`Column 'nonexistent_column' does not exist in table 'project_${projectId}'`], + }, + ]) + }) + + test('should return 422 for missing required fields', async () => { + // @ts-expect-error testing invalid payload + const { data, status, error } = await api.project({ projectId }).replace.post({ + column: '', + find: '', + replace: 'new_value', + }) + + expect(status).toBe(422) + expect(data).toBeNull() + expect(error).toHaveProperty('status', 422) + }) + + describe('special characters tests', () => { + test.each([ + { + description: 'handle @ symbol', + column: 'email', + find: '@', + replace: '[AT]', + expectedMessage: "Successfully replaced '@' with '[AT]' in column 'email'", + expectedAffectedRows: 5, + }, + { + description: 'handle single quotes', + column: 'name', + find: "John's", + replace: "Jonathan's", + expectedMessage: "Successfully replaced 'John\\'s' with 'Jonathan\\'s' in column 'name'", + expectedAffectedRows: 0, // No data with John's in test data + }, + ])('$description', async ({ column, find, replace }) => { + const { data, status, error } = await api.project({ projectId }).replace.post({ + column, + find, + replace, + caseSensitive: false, + wholeWord: false, + }) + + expect(status).toBe(200) + expect(error).toBeNull() + expect(data!.affectedRows).toBeGreaterThanOrEqual(0) + }) + }) +}) diff --git a/backend/tests/services/replace-operation.service.test.ts b/backend/tests/services/replace-operation.service.test.ts new file mode 100644 index 0000000..b6471a5 --- /dev/null +++ b/backend/tests/services/replace-operation.service.test.ts @@ -0,0 +1,213 @@ +import { closeDb, getDb, initializeDb } from '@backend/plugins/database' +import { ReplaceOperationService } from '@backend/services/replace-operation.service' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +interface TestData { + name: string + email: string + city: string +} + +const TEST_DATA: TestData[] = [ + { name: 'John Doe', email: 'john@example.com', city: 'New York' }, + { name: 'Jane Smith', email: 'jane@example.com', city: 'Los Angeles' }, + { name: 'Bob Johnson', email: 'bob@example.com', city: 'New York' }, + { name: 'Alice Brown', email: 'alice@test.com', city: 'Chicago' }, + { name: 'Charlie Davis', email: 'charlie@example.com', city: 'New York' }, +] + +describe('ReplaceOperationService', () => { + let service: ReplaceOperationService + let table: string + + beforeEach(async () => { + await initializeDb(':memory:') + const db = getDb() + service = new ReplaceOperationService(db) + + // Create a test table name + table = 'project_test_replace' + + // Create the project table and insert test data + await db.run(` + CREATE TABLE "${table}" ( + name VARCHAR, + email VARCHAR, + city VARCHAR + ) + `) + + // Insert test data + for (const row of TEST_DATA) { + await db.run( + ` + INSERT INTO "${table}" (name, email, city) + VALUES (?, ?, ?) + `, + [row.name, row.email, row.city], + ) + } + }) + + afterEach(async () => { + await closeDb() + }) + + describe('performReplace', () => { + test('should perform basic replace operation', async () => { + const result = await service.performReplace({ + table, + column: 'city', + find: 'New York', + replace: 'NYC', + caseSensitive: false, + wholeWord: false, + }) + + expect(result.message).toBe("Successfully replaced 'New York' with 'NYC' in column 'city'") + expect(result.affectedRows).toBe(3) + }) + + test('should perform case-sensitive replace operation', async () => { + const result = await service.performReplace({ + table, + column: 'name', + find: 'John', + replace: 'Jonathan', + caseSensitive: true, + wholeWord: false, + }) + + expect(result.message).toBe("Successfully replaced 'John' with 'Jonathan' in column 'name'") + expect(result.affectedRows).toBe(2) // John Doe and John Johnson + }) + + test('should perform case-insensitive replace operation', async () => { + const result = await service.performReplace({ + table, + column: 'email', + find: 'JOHN', + replace: 'JONATHAN', + caseSensitive: false, + wholeWord: false, + }) + + expect(result.message).toBe("Successfully replaced 'JOHN' with 'JONATHAN' in column 'email'") + expect(result.affectedRows).toBe(1) // john@example.com + }) + + test('should perform whole word replace operation', async () => { + const result = await service.performReplace({ + table, + column: 'name', + find: 'John', + replace: 'Jonathan', + caseSensitive: false, + wholeWord: true, + }) + + expect(result.message).toBe("Successfully replaced 'John' with 'Jonathan' in column 'name'") + expect(result.affectedRows).toBe(1) // Only "John Doe" should match (whole word "John") + }) + + test('should handle replace with empty string', async () => { + const result = await service.performReplace({ + table, + column: 'city', + find: 'New York', + replace: '', + caseSensitive: false, + wholeWord: false, + }) + + expect(result.message).toBe("Successfully replaced 'New York' with '' in column 'city'") + expect(result.affectedRows).toBe(3) + }) + + test('should handle special characters in find and replace', async () => { + // Add a row with special characters + const db = getDb() + await db.run( + `INSERT INTO "${table}" (name, email, city) VALUES ('Test@User', 'test@user.com', 'City')`, + ) + + const result = await service.performReplace({ + table, + column: 'email', + find: '@', + replace: '[AT]', + caseSensitive: false, + wholeWord: false, + }) + + expect(result.message).toBe("Successfully replaced '@' with '[AT]' in column 'email'") + expect(result.affectedRows).toBe(6) // All 6 emails contain @ + }) + + test('should handle single quotes in find and replace', async () => { + // Add a row with single quotes + const db = getDb() + await db.run( + `INSERT INTO "${table}" (name, email, city) VALUES ('John''s Cafe', 'johnscafe@example.com', 'Boston')`, + ) + + const result = await service.performReplace({ + table, + column: 'name', + find: "John's", + replace: "Jonathan's", + caseSensitive: false, + wholeWord: false, + }) + + expect(result.message).toBe( + "Successfully replaced 'John\'s' with 'Jonathan\'s' in column 'name'", + ) + expect(result.affectedRows).toBe(1) + }) + }) + + describe('edge cases and error handling', () => { + test('should throw error for non-existent column', async () => { + expect( + service.performReplace({ + table, + column: 'nonexistent_column', + find: 'test', + replace: 'replacement', + caseSensitive: false, + wholeWord: false, + }), + ).rejects.toThrow() + }) + + test('should handle non-existent project table', async () => { + expect( + service.performReplace({ + table: 'nonexistent_table', + column: 'name', + find: 'John', + replace: 'Jonathan', + caseSensitive: false, + wholeWord: false, + }), + ).rejects.toThrow() + }) + + test('should handle no matching rows', async () => { + const result = await service.performReplace({ + table, + column: 'city', + find: 'NonExistentCity', + replace: 'NewCity', + caseSensitive: false, + wholeWord: false, + }) + + expect(result.message).toBe( + "Successfully replaced 'NonExistentCity' with 'NewCity' in column 'city'", + ) + expect(result.affectedRows).toBe(0) + }) + }) +}) From 705814db2da220b7b1f90be2ef0669b1d3b8cdb8 Mon Sep 17 00:00:00 2001 From: DaxServer Date: Sun, 28 Sep 2025 14:15:06 +0200 Subject: [PATCH 2/2] refactor: use parameterised query pattern --- backend/src/api/project/index.ts | 11 +- backend/src/api/project/schemas.ts | 7 - .../src/services/replace-operation.service.ts | 117 +++++++---- .../tests/api/project/project.replace.test.ts | 34 +--- .../replace-operation.service.test.ts | 188 ++++++++---------- 5 files changed, 174 insertions(+), 183 deletions(-) diff --git a/backend/src/api/project/index.ts b/backend/src/api/project/index.ts index 45722fe..e53738d 100644 --- a/backend/src/api/project/index.ts +++ b/backend/src/api/project/index.ts @@ -4,7 +4,6 @@ import { PaginationQuery, ProjectParams, ProjectResponseSchema, - ReplaceOperationResponseSchema, ReplaceOperationSchema, type Project, } from '@backend/api/project/schemas' @@ -559,7 +558,7 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' }) const replaceService = new ReplaceOperationService(db()) try { - const result = await replaceService.performReplace({ + const affectedRows = await replaceService.performReplace({ table, column, find, @@ -568,7 +567,9 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' }) wholeWord, }) - return result + return { + affectedRows, + } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' return status( @@ -582,7 +583,9 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' }) { body: ReplaceOperationSchema, response: { - 200: ReplaceOperationResponseSchema, + 200: t.Object({ + affectedRows: t.Number(), + }), 400: ApiErrors, 404: ApiErrors, 422: ApiErrors, diff --git a/backend/src/api/project/schemas.ts b/backend/src/api/project/schemas.ts index 7e12abb..369f9d6 100644 --- a/backend/src/api/project/schemas.ts +++ b/backend/src/api/project/schemas.ts @@ -70,10 +70,3 @@ export const ReplaceOperationSchema = t.Object({ default: false, }), }) -export type ReplaceOperationSchema = typeof ReplaceOperationSchema.static - -export const ReplaceOperationResponseSchema = t.Object({ - message: t.String(), - affectedRows: t.Number(), -}) -export type ReplaceOperationResponseSchema = typeof ReplaceOperationResponseSchema.static diff --git a/backend/src/services/replace-operation.service.ts b/backend/src/services/replace-operation.service.ts index 997e335..6379a84 100644 --- a/backend/src/services/replace-operation.service.ts +++ b/backend/src/services/replace-operation.service.ts @@ -1,4 +1,4 @@ -import type { DuckDBConnection } from '@duckdb/node-api' +import type { DuckDBConnection, DuckDBValue } from '@duckdb/node-api' export interface ReplaceOperationParams { table: string @@ -9,22 +9,26 @@ export interface ReplaceOperationParams { wholeWord: boolean } -export interface ReplaceOperationResult { - message: string - affectedRows: number -} - export class ReplaceOperationService { constructor(private db: DuckDBConnection) {} /** * Performs a replace operation on a column in a project table */ - async performReplace(params: ReplaceOperationParams): Promise { + async performReplace(params: ReplaceOperationParams): Promise { const { table, column, find, replace, caseSensitive, wholeWord } = params - // Build the REPLACE operation based on parameters - const replaceExpression = this.buildReplaceExpression( + // Count rows that will be affected before the update + const affectedRows = await this.countAffectedRows(table, column, find, caseSensitive, wholeWord) + + // Only proceed if there are rows to update + if (affectedRows === 0) { + return 0 + } + + // Build and execute the parameterized UPDATE query + const { query, params: queryParams } = this.buildParameterizedUpdateQuery( + table, column, find, replace, @@ -32,46 +36,68 @@ export class ReplaceOperationService { wholeWord, ) - // Count rows that will be affected before the update - const affectedRows = await this.countAffectedRows(table, column, find, caseSensitive, wholeWord) - - // Execute the update - await this.db.run( - `UPDATE "${table}" SET "${column}" = ${replaceExpression} WHERE "${column}" IS NOT NULL`, - ) + await this.db.run(query, queryParams) - return { - message: `Successfully replaced '${find}' with '${replace}' in column '${column}'`, - affectedRows, - } + return affectedRows } /** - * Builds the appropriate replace expression based on the operation parameters + * Builds a parameterized UPDATE query to safely perform replace operations */ - private buildReplaceExpression( + private buildParameterizedUpdateQuery( + table: string, column: string, find: string, replace: string, caseSensitive: boolean, wholeWord: boolean, - ): string { + ) { + const params: DuckDBValue[] = [] + if (wholeWord) { // For whole word replacement, use regex with word boundaries - const flags = caseSensitive ? 'g' : 'gi' + const replaceFlags = caseSensitive ? 'g' : 'gi' + const matchFlags = caseSensitive ? '' : 'i' const pattern = `\\b${this.escapeRegex(find)}\\b` - return `regexp_replace("${column}", '${pattern}', '${this.escapeSql(replace)}', '${flags}')` + params.push(pattern, replace, replaceFlags) + + const query = ` + UPDATE "${table}" + SET "${column}" = regexp_replace("${column}", $1, $2, $3) + WHERE "${column}" IS NOT NULL + AND regexp_matches("${column}", $1, '${matchFlags}') + ` + + return { query, params } } // For partial replacement, use simple replace or regex if (caseSensitive) { - return `replace("${column}", '${this.escapeSql(find)}', '${this.escapeSql(replace)}')` + params.push(find, replace) + + const query = ` + UPDATE "${table}" + SET "${column}" = replace("${column}", $1, $2) + WHERE "${column}" IS NOT NULL + AND position($1 in "${column}") > 0 + ` + + return { query, params } } // Case-insensitive replacement using regex - const flags = 'gi' + const replaceFlags = 'gi' const pattern = this.escapeRegex(find) - return `regexp_replace("${column}", '${pattern}', '${this.escapeSql(replace)}', '${flags}')` + params.push(pattern, replace, replaceFlags) + + const query = ` + UPDATE "${table}" + SET "${column}" = regexp_replace("${column}", $1, $2, $3) + WHERE "${column}" IS NOT NULL + AND regexp_matches("${column}", $1, 'i') + ` + + return { query, params } } /** @@ -84,23 +110,39 @@ export class ReplaceOperationService { caseSensitive: boolean, wholeWord: boolean, ): Promise { - let countQuery: string + let query: string + const params: DuckDBValue[] = [] if (wholeWord) { // For whole word matching, count rows where the word appears as a whole word const flags = caseSensitive ? '' : 'i' const pattern = `\\b${this.escapeRegex(find)}\\b` - countQuery = `SELECT COUNT(*) as count FROM "${table}" WHERE "${column}" IS NOT NULL AND regexp_matches("${column}", '${pattern}', '${flags}')` + params.push(pattern, flags) + query = ` + SELECT COUNT(*) as count FROM "${table}" + WHERE "${column}" IS NOT NULL + AND regexp_matches("${column}", $1, $2) + ` } else { if (caseSensitive) { - countQuery = `SELECT COUNT(*) as count FROM "${table}" WHERE "${column}" IS NOT NULL AND position('${this.escapeSql(find)}' in "${column}") > 0` + params.push(find) + query = ` + SELECT COUNT(*) as count FROM "${table}" + WHERE "${column}" IS NOT NULL + AND position($1 in "${column}") > 0 + ` } else { const pattern = this.escapeRegex(find) - countQuery = `SELECT COUNT(*) as count FROM "${table}" WHERE "${column}" IS NOT NULL AND regexp_matches("${column}", '${pattern}', 'i')` + params.push(pattern) + query = ` + SELECT COUNT(*) as count FROM "${table}" + WHERE "${column}" IS NOT NULL + AND regexp_matches("${column}", $1, 'i') + ` } } - const countBeforeReader = await this.db.runAndReadAll(countQuery) + const countBeforeReader = await this.db.runAndReadAll(query, params) const countBeforeResult = countBeforeReader.getRowObjectsJson() return Number(countBeforeResult[0]?.count ?? 0) @@ -110,13 +152,6 @@ export class ReplaceOperationService { * Escapes special regex characters in a string */ private escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/'/g, "''") - } - - /** - * Escapes single quotes for SQL - */ - private escapeSql(str: string): string { - return str.replace(/'/g, "''") + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } } diff --git a/backend/tests/api/project/project.replace.test.ts b/backend/tests/api/project/project.replace.test.ts index 21a87d7..488d780 100644 --- a/backend/tests/api/project/project.replace.test.ts +++ b/backend/tests/api/project/project.replace.test.ts @@ -81,7 +81,6 @@ describe('Project API - find and replace', () => { expect(status).toBe(200) expect(error).toBeNull() expect(data).toEqual({ - message: "Successfully replaced 'New York' with 'San Francisco' in column 'city'", affectedRows: 3, }) @@ -112,7 +111,6 @@ describe('Project API - find and replace', () => { replace: 'company.com', caseSensitive: true, wholeWord: false, - expectedMessage: "Successfully replaced 'example.com' with 'company.com' in column 'email'", expectedAffectedRows: 4, }, { @@ -122,20 +120,11 @@ describe('Project API - find and replace', () => { replace: 'company.com', caseSensitive: false, wholeWord: false, - expectedMessage: "Successfully replaced 'EXAMPLE.COM' with 'company.com' in column 'email'", expectedAffectedRows: 4, }, ])( '$description', - async ({ - column, - find, - replace, - caseSensitive, - wholeWord, - expectedMessage, - expectedAffectedRows, - }) => { + async ({ column, find, replace, caseSensitive, wholeWord, expectedAffectedRows }) => { const { data, status, error } = await api.project({ projectId }).replace.post({ column, find, @@ -147,7 +136,6 @@ describe('Project API - find and replace', () => { expect(status).toBe(200) expect(error).toBeNull() expect(data).toEqual({ - message: expectedMessage, affectedRows: expectedAffectedRows, }) }, @@ -163,7 +151,6 @@ describe('Project API - find and replace', () => { replace: 'Jonathan', caseSensitive: false, wholeWord: true, - expectedMessage: "Successfully replaced 'John' with 'Jonathan' in column 'name'", expectedAffectedRows: 1, }, { @@ -173,20 +160,11 @@ describe('Project API - find and replace', () => { replace: 'jonathan', caseSensitive: true, wholeWord: true, - expectedMessage: "Successfully replaced 'john' with 'jonathan' in column 'name'", expectedAffectedRows: 0, }, ])( '$description', - async ({ - column, - find, - replace, - caseSensitive, - wholeWord, - expectedMessage, - expectedAffectedRows, - }) => { + async ({ column, find, replace, caseSensitive, wholeWord, expectedAffectedRows }) => { const { data, status, error } = await api.project({ projectId }).replace.post({ column, find, @@ -198,7 +176,6 @@ describe('Project API - find and replace', () => { expect(status).toBe(200) expect(error).toBeNull() expect(data).toEqual({ - message: expectedMessage, affectedRows: expectedAffectedRows, }) }, @@ -212,7 +189,6 @@ describe('Project API - find and replace', () => { column: 'city', find: 'New York', replace: '', - expectedMessage: "Successfully replaced 'New York' with '' in column 'city'", expectedAffectedRows: 3, }, { @@ -220,10 +196,9 @@ describe('Project API - find and replace', () => { column: 'name', find: 'John', replace: '', - expectedMessage: "Successfully replaced 'John' with '' in column 'name'", expectedAffectedRows: 2, }, - ])('$description', async ({ column, find, replace, expectedMessage, expectedAffectedRows }) => { + ])('$description', async ({ column, find, replace, expectedAffectedRows }) => { const { data, status, error } = await api.project({ projectId }).replace.post({ column, find, @@ -235,7 +210,6 @@ describe('Project API - find and replace', () => { expect(status).toBe(200) expect(error).toBeNull() expect(data).toEqual({ - message: expectedMessage, affectedRows: expectedAffectedRows, }) }) @@ -282,7 +256,6 @@ describe('Project API - find and replace', () => { column: 'email', find: '@', replace: '[AT]', - expectedMessage: "Successfully replaced '@' with '[AT]' in column 'email'", expectedAffectedRows: 5, }, { @@ -290,7 +263,6 @@ describe('Project API - find and replace', () => { column: 'name', find: "John's", replace: "Jonathan's", - expectedMessage: "Successfully replaced 'John\\'s' with 'Jonathan\\'s' in column 'name'", expectedAffectedRows: 0, // No data with John's in test data }, ])('$description', async ({ column, find, replace }) => { diff --git a/backend/tests/services/replace-operation.service.test.ts b/backend/tests/services/replace-operation.service.test.ts index b6471a5..ceba8b6 100644 --- a/backend/tests/services/replace-operation.service.test.ts +++ b/backend/tests/services/replace-operation.service.test.ts @@ -54,74 +54,69 @@ describe('ReplaceOperationService', () => { }) describe('performReplace', () => { - test('should perform basic replace operation', async () => { - const result = await service.performReplace({ - table, - column: 'city', - find: 'New York', - replace: 'NYC', - caseSensitive: false, - wholeWord: false, - }) - - expect(result.message).toBe("Successfully replaced 'New York' with 'NYC' in column 'city'") - expect(result.affectedRows).toBe(3) + test('should perform basic replace operation', () => { + expect( + service.performReplace({ + table, + column: 'city', + find: 'New York', + replace: 'NYC', + caseSensitive: false, + wholeWord: false, + }), + ).resolves.toBe(3) }) - test('should perform case-sensitive replace operation', async () => { - const result = await service.performReplace({ - table, - column: 'name', - find: 'John', - replace: 'Jonathan', - caseSensitive: true, - wholeWord: false, - }) - - expect(result.message).toBe("Successfully replaced 'John' with 'Jonathan' in column 'name'") - expect(result.affectedRows).toBe(2) // John Doe and John Johnson + test('should perform case-sensitive replace operation', () => { + expect( + service.performReplace({ + table, + column: 'name', + find: 'John', + replace: 'Jonathan', + caseSensitive: true, + wholeWord: false, + }), + ).resolves.toBe(2) // John Doe and John Johnson }) - test('should perform case-insensitive replace operation', async () => { - const result = await service.performReplace({ - table, - column: 'email', - find: 'JOHN', - replace: 'JONATHAN', - caseSensitive: false, - wholeWord: false, - }) - - expect(result.message).toBe("Successfully replaced 'JOHN' with 'JONATHAN' in column 'email'") - expect(result.affectedRows).toBe(1) // john@example.com + test('should perform case-insensitive replace operation', () => { + expect( + service.performReplace({ + table, + column: 'email', + find: 'JOHN', + replace: 'JONATHAN', + caseSensitive: false, + wholeWord: false, + }), + ).resolves.toBe(1) // john@example.com }) - test('should perform whole word replace operation', async () => { - const result = await service.performReplace({ - table, - column: 'name', - find: 'John', - replace: 'Jonathan', - caseSensitive: false, - wholeWord: true, - }) - - expect(result.message).toBe("Successfully replaced 'John' with 'Jonathan' in column 'name'") - expect(result.affectedRows).toBe(1) // Only "John Doe" should match (whole word "John") + test('should perform whole word replace operation', () => { + expect( + service.performReplace({ + table, + column: 'name', + find: 'John', + replace: 'Jonathan', + caseSensitive: false, + wholeWord: true, + }), + ).resolves.toBe(1) // Only "John Doe" should match (whole word "John") }) - test('should handle replace with empty string', async () => { - const result = await service.performReplace({ - table, - column: 'city', - find: 'New York', - replace: '', - caseSensitive: false, - wholeWord: false, - }) - - expect(result.message).toBe("Successfully replaced 'New York' with '' in column 'city'") - expect(result.affectedRows).toBe(3) + test('should handle replace with empty string', () => { + expect( + service.performReplace({ + table, + column: 'city', + find: 'New York', + replace: '', + caseSensitive: false, + wholeWord: false, + }), + ).resolves.toBe(3) }) test('should handle special characters in find and replace', async () => { @@ -131,17 +126,16 @@ describe('ReplaceOperationService', () => { `INSERT INTO "${table}" (name, email, city) VALUES ('Test@User', 'test@user.com', 'City')`, ) - const result = await service.performReplace({ - table, - column: 'email', - find: '@', - replace: '[AT]', - caseSensitive: false, - wholeWord: false, - }) - - expect(result.message).toBe("Successfully replaced '@' with '[AT]' in column 'email'") - expect(result.affectedRows).toBe(6) // All 6 emails contain @ + expect( + service.performReplace({ + table, + column: 'email', + find: '@', + replace: '[AT]', + caseSensitive: false, + wholeWord: false, + }), + ).resolves.toBe(6) // All 6 emails contain @ }) test('should handle single quotes in find and replace', async () => { @@ -151,24 +145,21 @@ describe('ReplaceOperationService', () => { `INSERT INTO "${table}" (name, email, city) VALUES ('John''s Cafe', 'johnscafe@example.com', 'Boston')`, ) - const result = await service.performReplace({ - table, - column: 'name', - find: "John's", - replace: "Jonathan's", - caseSensitive: false, - wholeWord: false, - }) - - expect(result.message).toBe( - "Successfully replaced 'John\'s' with 'Jonathan\'s' in column 'name'", - ) - expect(result.affectedRows).toBe(1) + expect( + service.performReplace({ + table, + column: 'name', + find: "John's", + replace: "Jonathan's", + caseSensitive: false, + wholeWord: false, + }), + ).resolves.toBe(1) }) }) describe('edge cases and error handling', () => { - test('should throw error for non-existent column', async () => { + test('should throw error for non-existent column', () => { expect( service.performReplace({ table, @@ -181,7 +172,7 @@ describe('ReplaceOperationService', () => { ).rejects.toThrow() }) - test('should handle non-existent project table', async () => { + test('should handle non-existent project table', () => { expect( service.performReplace({ table: 'nonexistent_table', @@ -194,20 +185,17 @@ describe('ReplaceOperationService', () => { ).rejects.toThrow() }) - test('should handle no matching rows', async () => { - const result = await service.performReplace({ - table, - column: 'city', - find: 'NonExistentCity', - replace: 'NewCity', - caseSensitive: false, - wholeWord: false, - }) - - expect(result.message).toBe( - "Successfully replaced 'NonExistentCity' with 'NewCity' in column 'city'", - ) - expect(result.affectedRows).toBe(0) + test('should handle no matching rows', () => { + expect( + service.performReplace({ + table, + column: 'city', + find: 'NonExistentCity', + replace: 'NewCity', + caseSensitive: false, + wholeWord: false, + }), + ).resolves.toBe(0) }) }) })