-
Notifications
You must be signed in to change notification settings - Fork 0
feat(replace-operation): Implement replace operation service #168
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<number> { | ||
| 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 } | ||
|
Comment on lines
+59
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid embedding regex flags; keep them parameterized or omit when empty. You interpolate 🤖 Prompt for AI Agents
Comment on lines
+64
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker: Unsafe identifier interpolation (table/column) enables SQL injection via identifiers.
Apply this diff to introduce a safe identifier helper and use it in all queries: @@
private buildParameterizedUpdateQuery(
@@
- if (wholeWord) {
+ const tableIdent = this.quoteIdent(table)
+ const columnIdent = this.quoteIdent(column)
+ if (wholeWord) {
@@
- const query = `
- UPDATE "${table}"
- SET "${column}" = regexp_replace("${column}", $1, $2, $3)
- WHERE "${column}" IS NOT NULL
- AND regexp_matches("${column}", $1, '${matchFlags}')
- `
+ const query =
+ caseSensitive
+ ? `
+ UPDATE ${tableIdent}
+ SET ${columnIdent} = regexp_replace(${columnIdent}, $1, $2, $3)
+ WHERE ${columnIdent} IS NOT NULL
+ AND regexp_matches(${columnIdent}, $1)
+ `
+ : `
+ UPDATE ${tableIdent}
+ SET ${columnIdent} = regexp_replace(${columnIdent}, $1, $2, $3)
+ WHERE ${columnIdent} IS NOT NULL
+ AND regexp_matches(${columnIdent}, $1, 'i')
+ `
@@
- if (caseSensitive) {
+ if (caseSensitive) {
@@
- const query = `
- UPDATE "${table}"
- SET "${column}" = replace("${column}", $1, $2)
- WHERE "${column}" IS NOT NULL
- AND position($1 in "${column}") > 0
- `
+ const query = `
+ UPDATE ${tableIdent}
+ SET ${columnIdent} = replace(${columnIdent}, $1, $2)
+ WHERE ${columnIdent} IS NOT NULL
+ AND position($1 in ${columnIdent}) > 0
+ `
@@
- const query = `
- UPDATE "${table}"
- SET "${column}" = regexp_replace("${column}", $1, $2, $3)
- WHERE "${column}" IS NOT NULL
- AND regexp_matches("${column}", $1, 'i')
- `
+ const query = `
+ UPDATE ${tableIdent}
+ SET ${columnIdent} = regexp_replace(${columnIdent}, $1, $2, $3)
+ WHERE ${columnIdent} IS NOT NULL
+ AND regexp_matches(${columnIdent}, $1, 'i')
+ `
@@
private async countAffectedRows(
@@
- if (wholeWord) {
+ const tableIdent = this.quoteIdent(table)
+ const columnIdent = this.quoteIdent(column)
+ if (wholeWord) {
@@
- query = `
- SELECT COUNT(*) as count FROM "${table}"
- WHERE "${column}" IS NOT NULL
- AND regexp_matches("${column}", $1, $2)
- `
+ query = `
+ SELECT COUNT(*) as count FROM ${tableIdent}
+ WHERE ${columnIdent} IS NOT NULL
+ AND regexp_matches(${columnIdent}, $1, $2)
+ `
@@
- query = `
- SELECT COUNT(*) as count FROM "${table}"
- WHERE "${column}" IS NOT NULL
- AND position($1 in "${column}") > 0
- `
+ query = `
+ SELECT COUNT(*) as count FROM ${tableIdent}
+ WHERE ${columnIdent} IS NOT NULL
+ AND position($1 in ${columnIdent}) > 0
+ `
@@
- query = `
- SELECT COUNT(*) as count FROM "${table}"
- WHERE "${column}" IS NOT NULL
- AND regexp_matches("${column}", $1, 'i')
- `
+ query = `
+ SELECT COUNT(*) as count FROM ${tableIdent}
+ WHERE ${columnIdent} IS NOT NULL
+ AND regexp_matches(${columnIdent}, $1, 'i')
+ `
@@
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
+
+ /**
+ * Safely quotes a SQL identifier and enforces a conservative allowlist.
+ */
+ private quoteIdent(ident: string): string {
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ident)) {
+ throw new Error(`Invalid identifier: ${ident}`)
+ }
+ return `"${ident.replace(/"/g, '""')}"`
+ }Also applies to: 78-85, 93-100, 121-126, 129-134, 138-141 |
||
| } | ||
|
|
||
| // 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<number> { | ||
| 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, '\\$&') | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Add explicit table existence check before column validation.
If the project table hasn’t been created yet, this returns a misleading “Column not found” (400). Check table existence first and return 404 to match the declared response types.
const table = `project_${projectId}` + // Ensure project table exists + const tableExistsReader = await db().runAndReadAll( + 'SELECT 1 FROM duckdb_tables() WHERE table_name = ?', + [table], + ) + if (tableExistsReader.getRows().length === 0) { + return status(404, ApiErrorHandler.notFoundErrorWithData('Project table', table)) + } + // Check if column exists - const columnExistsReader = await db().runAndReadAll( - 'SELECT 1 FROM information_schema.columns WHERE table_name = ? AND column_name = ?', - [table, column], - ) + const columnExistsReader = await db().runAndReadAll( + `SELECT 1 + FROM duckdb_columns() + WHERE table_name = ? AND column_name = ?`, + [table, column], + )📝 Committable suggestion
🤖 Prompt for AI Agents