Skip to content

Commit 705814d

Browse files
committed
refactor: use parameterised query pattern
1 parent 393fdea commit 705814d

File tree

5 files changed

+174
-183
lines changed

5 files changed

+174
-183
lines changed

backend/src/api/project/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
PaginationQuery,
55
ProjectParams,
66
ProjectResponseSchema,
7-
ReplaceOperationResponseSchema,
87
ReplaceOperationSchema,
98
type Project,
109
} from '@backend/api/project/schemas'
@@ -559,7 +558,7 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' })
559558
const replaceService = new ReplaceOperationService(db())
560559

561560
try {
562-
const result = await replaceService.performReplace({
561+
const affectedRows = await replaceService.performReplace({
563562
table,
564563
column,
565564
find,
@@ -568,7 +567,9 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' })
568567
wholeWord,
569568
})
570569

571-
return result
570+
return {
571+
affectedRows,
572+
}
572573
} catch (error) {
573574
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
574575
return status(
@@ -582,7 +583,9 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' })
582583
{
583584
body: ReplaceOperationSchema,
584585
response: {
585-
200: ReplaceOperationResponseSchema,
586+
200: t.Object({
587+
affectedRows: t.Number(),
588+
}),
586589
400: ApiErrors,
587590
404: ApiErrors,
588591
422: ApiErrors,

backend/src/api/project/schemas.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,3 @@ export const ReplaceOperationSchema = t.Object({
7070
default: false,
7171
}),
7272
})
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: 76 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DuckDBConnection } from '@duckdb/node-api'
1+
import type { DuckDBConnection, DuckDBValue } from '@duckdb/node-api'
22

33
export interface ReplaceOperationParams {
44
table: string
@@ -9,69 +9,95 @@ export interface ReplaceOperationParams {
99
wholeWord: boolean
1010
}
1111

12-
export interface ReplaceOperationResult {
13-
message: string
14-
affectedRows: number
15-
}
16-
1712
export class ReplaceOperationService {
1813
constructor(private db: DuckDBConnection) {}
1914

2015
/**
2116
* Performs a replace operation on a column in a project table
2217
*/
23-
async performReplace(params: ReplaceOperationParams): Promise<ReplaceOperationResult> {
18+
async performReplace(params: ReplaceOperationParams): Promise<number> {
2419
const { table, column, find, replace, caseSensitive, wholeWord } = params
2520

26-
// Build the REPLACE operation based on parameters
27-
const replaceExpression = this.buildReplaceExpression(
21+
// Count rows that will be affected before the update
22+
const affectedRows = await this.countAffectedRows(table, column, find, caseSensitive, wholeWord)
23+
24+
// Only proceed if there are rows to update
25+
if (affectedRows === 0) {
26+
return 0
27+
}
28+
29+
// Build and execute the parameterized UPDATE query
30+
const { query, params: queryParams } = this.buildParameterizedUpdateQuery(
31+
table,
2832
column,
2933
find,
3034
replace,
3135
caseSensitive,
3236
wholeWord,
3337
)
3438

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-
)
39+
await this.db.run(query, queryParams)
4240

43-
return {
44-
message: `Successfully replaced '${find}' with '${replace}' in column '${column}'`,
45-
affectedRows,
46-
}
41+
return affectedRows
4742
}
4843

4944
/**
50-
* Builds the appropriate replace expression based on the operation parameters
45+
* Builds a parameterized UPDATE query to safely perform replace operations
5146
*/
52-
private buildReplaceExpression(
47+
private buildParameterizedUpdateQuery(
48+
table: string,
5349
column: string,
5450
find: string,
5551
replace: string,
5652
caseSensitive: boolean,
5753
wholeWord: boolean,
58-
): string {
54+
) {
55+
const params: DuckDBValue[] = []
56+
5957
if (wholeWord) {
6058
// For whole word replacement, use regex with word boundaries
61-
const flags = caseSensitive ? 'g' : 'gi'
59+
const replaceFlags = caseSensitive ? 'g' : 'gi'
60+
const matchFlags = caseSensitive ? '' : 'i'
6261
const pattern = `\\b${this.escapeRegex(find)}\\b`
63-
return `regexp_replace("${column}", '${pattern}', '${this.escapeSql(replace)}', '${flags}')`
62+
params.push(pattern, replace, replaceFlags)
63+
64+
const query = `
65+
UPDATE "${table}"
66+
SET "${column}" = regexp_replace("${column}", $1, $2, $3)
67+
WHERE "${column}" IS NOT NULL
68+
AND regexp_matches("${column}", $1, '${matchFlags}')
69+
`
70+
71+
return { query, params }
6472
}
6573

6674
// For partial replacement, use simple replace or regex
6775
if (caseSensitive) {
68-
return `replace("${column}", '${this.escapeSql(find)}', '${this.escapeSql(replace)}')`
76+
params.push(find, replace)
77+
78+
const query = `
79+
UPDATE "${table}"
80+
SET "${column}" = replace("${column}", $1, $2)
81+
WHERE "${column}" IS NOT NULL
82+
AND position($1 in "${column}") > 0
83+
`
84+
85+
return { query, params }
6986
}
7087

7188
// Case-insensitive replacement using regex
72-
const flags = 'gi'
89+
const replaceFlags = 'gi'
7390
const pattern = this.escapeRegex(find)
74-
return `regexp_replace("${column}", '${pattern}', '${this.escapeSql(replace)}', '${flags}')`
91+
params.push(pattern, replace, replaceFlags)
92+
93+
const query = `
94+
UPDATE "${table}"
95+
SET "${column}" = regexp_replace("${column}", $1, $2, $3)
96+
WHERE "${column}" IS NOT NULL
97+
AND regexp_matches("${column}", $1, 'i')
98+
`
99+
100+
return { query, params }
75101
}
76102

77103
/**
@@ -84,23 +110,39 @@ export class ReplaceOperationService {
84110
caseSensitive: boolean,
85111
wholeWord: boolean,
86112
): Promise<number> {
87-
let countQuery: string
113+
let query: string
114+
const params: DuckDBValue[] = []
88115

89116
if (wholeWord) {
90117
// For whole word matching, count rows where the word appears as a whole word
91118
const flags = caseSensitive ? '' : 'i'
92119
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}')`
120+
params.push(pattern, flags)
121+
query = `
122+
SELECT COUNT(*) as count FROM "${table}"
123+
WHERE "${column}" IS NOT NULL
124+
AND regexp_matches("${column}", $1, $2)
125+
`
94126
} else {
95127
if (caseSensitive) {
96-
countQuery = `SELECT COUNT(*) as count FROM "${table}" WHERE "${column}" IS NOT NULL AND position('${this.escapeSql(find)}' in "${column}") > 0`
128+
params.push(find)
129+
query = `
130+
SELECT COUNT(*) as count FROM "${table}"
131+
WHERE "${column}" IS NOT NULL
132+
AND position($1 in "${column}") > 0
133+
`
97134
} else {
98135
const pattern = this.escapeRegex(find)
99-
countQuery = `SELECT COUNT(*) as count FROM "${table}" WHERE "${column}" IS NOT NULL AND regexp_matches("${column}", '${pattern}', 'i')`
136+
params.push(pattern)
137+
query = `
138+
SELECT COUNT(*) as count FROM "${table}"
139+
WHERE "${column}" IS NOT NULL
140+
AND regexp_matches("${column}", $1, 'i')
141+
`
100142
}
101143
}
102144

103-
const countBeforeReader = await this.db.runAndReadAll(countQuery)
145+
const countBeforeReader = await this.db.runAndReadAll(query, params)
104146
const countBeforeResult = countBeforeReader.getRowObjectsJson()
105147

106148
return Number(countBeforeResult[0]?.count ?? 0)
@@ -110,13 +152,6 @@ export class ReplaceOperationService {
110152
* Escapes special regex characters in a string
111153
*/
112154
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, "''")
155+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
121156
}
122157
}

backend/tests/api/project/project.replace.test.ts

Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ describe('Project API - find and replace', () => {
8181
expect(status).toBe(200)
8282
expect(error).toBeNull()
8383
expect(data).toEqual({
84-
message: "Successfully replaced 'New York' with 'San Francisco' in column 'city'",
8584
affectedRows: 3,
8685
})
8786

@@ -112,7 +111,6 @@ describe('Project API - find and replace', () => {
112111
replace: 'company.com',
113112
caseSensitive: true,
114113
wholeWord: false,
115-
expectedMessage: "Successfully replaced 'example.com' with 'company.com' in column 'email'",
116114
expectedAffectedRows: 4,
117115
},
118116
{
@@ -122,20 +120,11 @@ describe('Project API - find and replace', () => {
122120
replace: 'company.com',
123121
caseSensitive: false,
124122
wholeWord: false,
125-
expectedMessage: "Successfully replaced 'EXAMPLE.COM' with 'company.com' in column 'email'",
126123
expectedAffectedRows: 4,
127124
},
128125
])(
129126
'$description',
130-
async ({
131-
column,
132-
find,
133-
replace,
134-
caseSensitive,
135-
wholeWord,
136-
expectedMessage,
137-
expectedAffectedRows,
138-
}) => {
127+
async ({ column, find, replace, caseSensitive, wholeWord, expectedAffectedRows }) => {
139128
const { data, status, error } = await api.project({ projectId }).replace.post({
140129
column,
141130
find,
@@ -147,7 +136,6 @@ describe('Project API - find and replace', () => {
147136
expect(status).toBe(200)
148137
expect(error).toBeNull()
149138
expect(data).toEqual({
150-
message: expectedMessage,
151139
affectedRows: expectedAffectedRows,
152140
})
153141
},
@@ -163,7 +151,6 @@ describe('Project API - find and replace', () => {
163151
replace: 'Jonathan',
164152
caseSensitive: false,
165153
wholeWord: true,
166-
expectedMessage: "Successfully replaced 'John' with 'Jonathan' in column 'name'",
167154
expectedAffectedRows: 1,
168155
},
169156
{
@@ -173,20 +160,11 @@ describe('Project API - find and replace', () => {
173160
replace: 'jonathan',
174161
caseSensitive: true,
175162
wholeWord: true,
176-
expectedMessage: "Successfully replaced 'john' with 'jonathan' in column 'name'",
177163
expectedAffectedRows: 0,
178164
},
179165
])(
180166
'$description',
181-
async ({
182-
column,
183-
find,
184-
replace,
185-
caseSensitive,
186-
wholeWord,
187-
expectedMessage,
188-
expectedAffectedRows,
189-
}) => {
167+
async ({ column, find, replace, caseSensitive, wholeWord, expectedAffectedRows }) => {
190168
const { data, status, error } = await api.project({ projectId }).replace.post({
191169
column,
192170
find,
@@ -198,7 +176,6 @@ describe('Project API - find and replace', () => {
198176
expect(status).toBe(200)
199177
expect(error).toBeNull()
200178
expect(data).toEqual({
201-
message: expectedMessage,
202179
affectedRows: expectedAffectedRows,
203180
})
204181
},
@@ -212,18 +189,16 @@ describe('Project API - find and replace', () => {
212189
column: 'city',
213190
find: 'New York',
214191
replace: '',
215-
expectedMessage: "Successfully replaced 'New York' with '' in column 'city'",
216192
expectedAffectedRows: 3,
217193
},
218194
{
219195
description: 'empty string replace with whole word',
220196
column: 'name',
221197
find: 'John',
222198
replace: '',
223-
expectedMessage: "Successfully replaced 'John' with '' in column 'name'",
224199
expectedAffectedRows: 2,
225200
},
226-
])('$description', async ({ column, find, replace, expectedMessage, expectedAffectedRows }) => {
201+
])('$description', async ({ column, find, replace, expectedAffectedRows }) => {
227202
const { data, status, error } = await api.project({ projectId }).replace.post({
228203
column,
229204
find,
@@ -235,7 +210,6 @@ describe('Project API - find and replace', () => {
235210
expect(status).toBe(200)
236211
expect(error).toBeNull()
237212
expect(data).toEqual({
238-
message: expectedMessage,
239213
affectedRows: expectedAffectedRows,
240214
})
241215
})
@@ -282,15 +256,13 @@ describe('Project API - find and replace', () => {
282256
column: 'email',
283257
find: '@',
284258
replace: '[AT]',
285-
expectedMessage: "Successfully replaced '@' with '[AT]' in column 'email'",
286259
expectedAffectedRows: 5,
287260
},
288261
{
289262
description: 'handle single quotes',
290263
column: 'name',
291264
find: "John's",
292265
replace: "Jonathan's",
293-
expectedMessage: "Successfully replaced 'John\\'s' with 'Jonathan\\'s' in column 'name'",
294266
expectedAffectedRows: 0, // No data with John's in test data
295267
},
296268
])('$description', async ({ column, find, replace }) => {

0 commit comments

Comments
 (0)