Skip to content

Commit 393fdea

Browse files
committed
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.
1 parent 47d67c3 commit 393fdea

File tree

5 files changed

+741
-0
lines changed

5 files changed

+741
-0
lines changed

backend/src/api/project/index.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import {
44
PaginationQuery,
55
ProjectParams,
66
ProjectResponseSchema,
7+
ReplaceOperationResponseSchema,
8+
ReplaceOperationSchema,
79
type Project,
810
} from '@backend/api/project/schemas'
911
import { databasePlugin } from '@backend/plugins/database'
1012
import { errorHandlerPlugin } from '@backend/plugins/error-handler'
13+
import { ReplaceOperationService } from '@backend/services/replace-operation.service'
1114
import { ApiErrorHandler } from '@backend/types/error-handler'
1215
import { ApiErrors } from '@backend/types/error-schemas'
1316
import { enhanceSchemaWithTypes, type DuckDBTablePragma } from '@backend/utils/duckdb-types'
@@ -527,3 +530,68 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' })
527530
},
528531
},
529532
)
533+
534+
.post(
535+
'/:projectId/replace',
536+
async ({
537+
db,
538+
params: { projectId },
539+
body: { column, find, replace, caseSensitive, wholeWord },
540+
status,
541+
}) => {
542+
const table = `project_${projectId}`
543+
544+
// Check if column exists
545+
const columnExistsReader = await db().runAndReadAll(
546+
'SELECT 1 FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
547+
[table, column],
548+
)
549+
550+
if (columnExistsReader.getRows().length === 0) {
551+
return status(
552+
400,
553+
ApiErrorHandler.validationErrorWithData('Column not found', [
554+
`Column '${column}' does not exist in table '${table}'`,
555+
]),
556+
)
557+
}
558+
559+
const replaceService = new ReplaceOperationService(db())
560+
561+
try {
562+
const result = await replaceService.performReplace({
563+
table,
564+
column,
565+
find,
566+
replace,
567+
caseSensitive,
568+
wholeWord,
569+
})
570+
571+
return result
572+
} catch (error) {
573+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
574+
return status(
575+
500,
576+
ApiErrorHandler.internalServerErrorWithData('Failed to perform replace operation', [
577+
errorMessage,
578+
]),
579+
)
580+
}
581+
},
582+
{
583+
body: ReplaceOperationSchema,
584+
response: {
585+
200: ReplaceOperationResponseSchema,
586+
400: ApiErrors,
587+
404: ApiErrors,
588+
422: ApiErrors,
589+
500: ApiErrors,
590+
},
591+
detail: {
592+
summary: 'Perform replace operation on a column',
593+
description: 'Replace text in a specific column of a project table',
594+
tags,
595+
},
596+
},
597+
)

backend/src/api/project/schemas.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,31 @@ export const GetProjectByIdResponse = t.Object({
4949
}),
5050
})
5151
export type GetProjectByIdResponse = typeof GetProjectByIdResponse.static
52+
53+
// Replace operation schema
54+
export const ReplaceOperationSchema = t.Object({
55+
column: t.String({
56+
minLength: 1,
57+
error: 'Column name is required and must be at least 1 character long',
58+
}),
59+
find: t.String({
60+
minLength: 1,
61+
error: 'Find value is required and must be at least 1 character long',
62+
}),
63+
replace: t.String({
64+
default: '',
65+
}),
66+
caseSensitive: t.BooleanString({
67+
default: false,
68+
}),
69+
wholeWord: t.BooleanString({
70+
default: false,
71+
}),
72+
})
73+
export type ReplaceOperationSchema = typeof ReplaceOperationSchema.static
74+
75+
export const ReplaceOperationResponseSchema = t.Object({
76+
message: t.String(),
77+
affectedRows: t.Number(),
78+
})
79+
export type ReplaceOperationResponseSchema = typeof ReplaceOperationResponseSchema.static
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import type { DuckDBConnection } from '@duckdb/node-api'
2+
3+
export interface ReplaceOperationParams {
4+
table: string
5+
column: string
6+
find: string
7+
replace: string
8+
caseSensitive: boolean
9+
wholeWord: boolean
10+
}
11+
12+
export interface ReplaceOperationResult {
13+
message: string
14+
affectedRows: number
15+
}
16+
17+
export class ReplaceOperationService {
18+
constructor(private db: DuckDBConnection) {}
19+
20+
/**
21+
* Performs a replace operation on a column in a project table
22+
*/
23+
async performReplace(params: ReplaceOperationParams): Promise<ReplaceOperationResult> {
24+
const { table, column, find, replace, caseSensitive, wholeWord } = params
25+
26+
// Build the REPLACE operation based on parameters
27+
const replaceExpression = this.buildReplaceExpression(
28+
column,
29+
find,
30+
replace,
31+
caseSensitive,
32+
wholeWord,
33+
)
34+
35+
// Count rows that will be affected before the update
36+
const affectedRows = await this.countAffectedRows(table, column, find, caseSensitive, wholeWord)
37+
38+
// Execute the update
39+
await this.db.run(
40+
`UPDATE "${table}" SET "${column}" = ${replaceExpression} WHERE "${column}" IS NOT NULL`,
41+
)
42+
43+
return {
44+
message: `Successfully replaced '${find}' with '${replace}' in column '${column}'`,
45+
affectedRows,
46+
}
47+
}
48+
49+
/**
50+
* Builds the appropriate replace expression based on the operation parameters
51+
*/
52+
private buildReplaceExpression(
53+
column: string,
54+
find: string,
55+
replace: string,
56+
caseSensitive: boolean,
57+
wholeWord: boolean,
58+
): string {
59+
if (wholeWord) {
60+
// For whole word replacement, use regex with word boundaries
61+
const flags = caseSensitive ? 'g' : 'gi'
62+
const pattern = `\\b${this.escapeRegex(find)}\\b`
63+
return `regexp_replace("${column}", '${pattern}', '${this.escapeSql(replace)}', '${flags}')`
64+
}
65+
66+
// For partial replacement, use simple replace or regex
67+
if (caseSensitive) {
68+
return `replace("${column}", '${this.escapeSql(find)}', '${this.escapeSql(replace)}')`
69+
}
70+
71+
// Case-insensitive replacement using regex
72+
const flags = 'gi'
73+
const pattern = this.escapeRegex(find)
74+
return `regexp_replace("${column}", '${pattern}', '${this.escapeSql(replace)}', '${flags}')`
75+
}
76+
77+
/**
78+
* Counts the number of rows that will be affected by the replace operation
79+
*/
80+
private async countAffectedRows(
81+
table: string,
82+
column: string,
83+
find: string,
84+
caseSensitive: boolean,
85+
wholeWord: boolean,
86+
): Promise<number> {
87+
let countQuery: string
88+
89+
if (wholeWord) {
90+
// For whole word matching, count rows where the word appears as a whole word
91+
const flags = caseSensitive ? '' : 'i'
92+
const pattern = `\\b${this.escapeRegex(find)}\\b`
93+
countQuery = `SELECT COUNT(*) as count FROM "${table}" WHERE "${column}" IS NOT NULL AND regexp_matches("${column}", '${pattern}', '${flags}')`
94+
} else {
95+
if (caseSensitive) {
96+
countQuery = `SELECT COUNT(*) as count FROM "${table}" WHERE "${column}" IS NOT NULL AND position('${this.escapeSql(find)}' in "${column}") > 0`
97+
} else {
98+
const pattern = this.escapeRegex(find)
99+
countQuery = `SELECT COUNT(*) as count FROM "${table}" WHERE "${column}" IS NOT NULL AND regexp_matches("${column}", '${pattern}', 'i')`
100+
}
101+
}
102+
103+
const countBeforeReader = await this.db.runAndReadAll(countQuery)
104+
const countBeforeResult = countBeforeReader.getRowObjectsJson()
105+
106+
return Number(countBeforeResult[0]?.count ?? 0)
107+
}
108+
109+
/**
110+
* Escapes special regex characters in a string
111+
*/
112+
private escapeRegex(str: string): string {
113+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/'/g, "''")
114+
}
115+
116+
/**
117+
* Escapes single quotes for SQL
118+
*/
119+
private escapeSql(str: string): string {
120+
return str.replace(/'/g, "''")
121+
}
122+
}

0 commit comments

Comments
 (0)