diff --git a/backend/src/api/project/index.ts b/backend/src/api/project/index.ts index 8eacd0c..e53738d 100644 --- a/backend/src/api/project/index.ts +++ b/backend/src/api/project/index.ts @@ -4,10 +4,12 @@ import { PaginationQuery, ProjectParams, ProjectResponseSchema, + 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 +529,72 @@ 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 affectedRows = await replaceService.performReplace({ + table, + column, + find, + replace, + caseSensitive, + wholeWord, + }) + + return { + affectedRows, + } + } 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: t.Object({ + affectedRows: t.Number(), + }), + 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..369f9d6 100644 --- a/backend/src/api/project/schemas.ts +++ b/backend/src/api/project/schemas.ts @@ -49,3 +49,24 @@ 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, + }), +}) diff --git a/backend/src/services/replace-operation.service.ts b/backend/src/services/replace-operation.service.ts new file mode 100644 index 0000000..6379a84 --- /dev/null +++ b/backend/src/services/replace-operation.service.ts @@ -0,0 +1,157 @@ +import type { DuckDBConnection, DuckDBValue } from '@duckdb/node-api' + +export interface ReplaceOperationParams { + table: string + column: string + find: string + replace: string + caseSensitive: boolean + wholeWord: boolean +} + +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 + + // 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, + caseSensitive, + wholeWord, + ) + + await this.db.run(query, queryParams) + + return affectedRows + } + + /** + * Builds a parameterized UPDATE query to safely perform replace operations + */ + private buildParameterizedUpdateQuery( + table: string, + column: string, + find: string, + replace: string, + caseSensitive: boolean, + wholeWord: boolean, + ) { + const params: DuckDBValue[] = [] + + if (wholeWord) { + // For whole word replacement, use regex with word boundaries + const replaceFlags = caseSensitive ? 'g' : 'gi' + const matchFlags = caseSensitive ? '' : 'i' + const pattern = `\\b${this.escapeRegex(find)}\\b` + 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) { + 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 replaceFlags = 'gi' + const pattern = this.escapeRegex(find) + 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 } + } + + /** + * 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 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` + 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) { + 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) + 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(query, params) + 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, '\\$&') + } +} 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..488d780 --- /dev/null +++ b/backend/tests/api/project/project.replace.test.ts @@ -0,0 +1,282 @@ +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({ + 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, + expectedAffectedRows: 4, + }, + { + description: 'case-insensitive replace operation', + column: 'email', + find: 'EXAMPLE.COM', + replace: 'company.com', + caseSensitive: false, + wholeWord: false, + expectedAffectedRows: 4, + }, + ])( + '$description', + async ({ column, find, replace, caseSensitive, wholeWord, 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({ + affectedRows: expectedAffectedRows, + }) + }, + ) + }) + + describe('whole word tests', () => { + test.each([ + { + description: 'whole word replace operation', + column: 'name', + find: 'John', + replace: 'Jonathan', + caseSensitive: false, + wholeWord: true, + expectedAffectedRows: 1, + }, + { + description: 'whole word with case sensitivity', + column: 'name', + find: 'john', + replace: 'jonathan', + caseSensitive: true, + wholeWord: true, + expectedAffectedRows: 0, + }, + ])( + '$description', + async ({ column, find, replace, caseSensitive, wholeWord, 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({ + affectedRows: expectedAffectedRows, + }) + }, + ) + }) + + describe('replace with empty string', () => { + test.each([ + { + description: 'basic empty string replace', + column: 'city', + find: 'New York', + replace: '', + expectedAffectedRows: 3, + }, + { + description: 'empty string replace with whole word', + column: 'name', + find: 'John', + replace: '', + expectedAffectedRows: 2, + }, + ])('$description', async ({ column, find, replace, 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({ + 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]', + expectedAffectedRows: 5, + }, + { + description: 'handle single quotes', + column: 'name', + find: "John's", + replace: "Jonathan's", + 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..ceba8b6 --- /dev/null +++ b/backend/tests/services/replace-operation.service.test.ts @@ -0,0 +1,201 @@ +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', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + 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 () => { + // 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')`, + ) + + 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 () => { + // 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')`, + ) + + 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', () => { + expect( + service.performReplace({ + table, + column: 'nonexistent_column', + find: 'test', + replace: 'replacement', + caseSensitive: false, + wholeWord: false, + }), + ).rejects.toThrow() + }) + + test('should handle non-existent project table', () => { + expect( + service.performReplace({ + table: 'nonexistent_table', + column: 'name', + find: 'John', + replace: 'Jonathan', + caseSensitive: false, + wholeWord: false, + }), + ).rejects.toThrow() + }) + + test('should handle no matching rows', () => { + expect( + service.performReplace({ + table, + column: 'city', + find: 'NonExistentCity', + replace: 'NewCity', + caseSensitive: false, + wholeWord: false, + }), + ).resolves.toBe(0) + }) + }) +})