Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions backend/src/api/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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}'`,
]),
)
}
Comment on lines +543 to +556
Copy link

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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 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 duckdb_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}'`,
]),
)
}
🤖 Prompt for AI Agents
In backend/src/api/project/index.ts around lines 544 to 557, add an explicit
check that the target table exists before checking for the column: first query
information_schema.tables (e.g. SELECT 1 FROM information_schema.tables WHERE
table_name = ?) with the table name and if no rows returned return status(404,
ApiErrorHandler.notFound('Table not found', [`Table '${table}' does not
exist`])); only then run the existing information_schema.columns query to
validate the column and keep the existing 400 Column not found response if the
column is missing.


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,
},
},
)
21 changes: 21 additions & 0 deletions backend/src/api/project/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
})
157 changes: 157 additions & 0 deletions backend/src/services/replace-operation.service.ts
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid embedding regex flags; keep them parameterized or omit when empty.

You interpolate matchFlags into SQL and sometimes pass ''. Prefer either (a) omit the 3rd arg when case‑sensitive, or (b) bind 'i' as a param. The diff above opts to omit for case‑sensitive paths.

🤖 Prompt for AI Agents
In backend/src/services/replace-operation.service.ts around lines 59 to 71, the
SQL currently interpolates matchFlags directly and sometimes inserts an empty
string; instead build the query so the regexp_matches call either omits the
flags argument when caseSensitive (i.e. don't interpolate ''), or pass the flag
as a bound parameter—preferably omit it: when caseSensitive, generate WHERE ...
regexp_matches("${column}", $1) and when not caseSensitive generate WHERE ...
regexp_matches("${column}", $1, $3) while adjusting params to only include
pattern, replace, replaceFlags (and adding matchFlag param only for the
case-insensitive branch), ensuring no empty string is injected into the SQL.

Comment on lines +64 to +71
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Blocker: Unsafe identifier interpolation (table/column) enables SQL injection via identifiers.

"${table}" and "$ {column}" are string‑interpolated without escaping. Even with value params bound, identifiers remain attacker-controlled (e.g., crafted projectId/column). Quote/validate identifiers server‑side and reuse the sanitized form in queries.

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, '\\$&')
}
}
Loading