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
64 changes: 64 additions & 0 deletions backend/src/services/replace-operation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,21 @@ export class ReplaceOperationService {
async performReplace(params: ReplaceOperationParams): Promise<number> {
const { table, column, find, replace, caseSensitive, wholeWord } = params

// Get the original column type before any modifications
const originalColumnType = await this.getColumnType(table, column)

// Check if column is string-like, if not, convert it first
const wasConverted = await this.ensureColumnIsStringType(table, column)

// 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) {
// Revert column type if it was converted and no rows were affected
if (wasConverted) {
await this.changeColumnType(table, column, originalColumnType)
}
return 0
}

Expand Down Expand Up @@ -154,4 +164,58 @@ export class ReplaceOperationService {
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

/**
* Gets the column type from the table schema
*/
private async getColumnType(table: string, column: string): Promise<string> {
const result = await this.db.runAndReadAll(`PRAGMA table_info("${table}")`)
const columns = result.getRowObjectsJson() as Array<{
cid: number
name: string
type: string
pk: boolean
notnull: boolean
dflt_value: string | null
}>

const columnInfo = columns.find((col) => col.name === column)
if (!columnInfo) {
throw new Error(`Column '${column}' not found in table '${table}'`)
}

return columnInfo.type.toUpperCase()
}
Comment on lines +171 to +188
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

Escape identifiers to prevent SQL injection via table/column names.

Directly interpolating table/column without escaping can break queries or allow injection if names contain quotes. Double-quote and escape inner quotes consistently.

Add helper and use everywhere identifiers are interpolated:

// helper
private q(id: string): string {
  return `"${id.replace(/"/g, '""')}"`
}

Then replace occurrences of "${table}"/"${column}" with ${this.q(table)} / ${this.q(column)} across queries.

Also applies to: 199-220

🤖 Prompt for AI Agents
In backend/src/services/replace-operation.service.ts around lines 171 to 188
(and also update usages at 199-220), identifiers are interpolated directly into
PRAGMA/SQL which can break queries or allow injection; add a private helper
q(id: string): string that returns the identifier wrapped in double-quotes with
inner double-quotes escaped by doubling, then replace occurrences like
`"${table}"` and `"${column}"` with `${this.q(table)}` / `${this.q(column)}` in
all SQL/PRAGMA string interpolations (including the areas flagged at 199-220) so
table and column names are safely escaped before use.


/**
* Checks if a column type is string-like (VARCHAR, TEXT, BLOB)
*/
private isStringLikeType(columnType: string): boolean {
const stringTypes = ['VARCHAR', 'TEXT', 'CHAR', 'BPCHAR']

return stringTypes.some((type) => columnType.includes(type))
}

/**
* Ensures the column is a string-like type, converting it if necessary
* Returns true if the column was converted, false otherwise
*/
private async ensureColumnIsStringType(table: string, column: string): Promise<boolean> {
const columnType = await this.getColumnType(table, column)

if (!this.isStringLikeType(columnType)) {
// Convert the column to VARCHAR
await this.changeColumnType(table, column, 'VARCHAR')
return true
}

return false
}

/**
* Changes the column type to the specified type
*/
private async changeColumnType(table: string, column: string, newType: string): Promise<void> {
await this.db.run(`ALTER TABLE "${table}" ALTER "${column}" TYPE ${newType}`)
}
}
307 changes: 307 additions & 0 deletions backend/tests/services/replace-operation.alter-table.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
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'

describe('non-string column datatype conversion', () => {
beforeEach(async () => {
await initializeDb(':memory:')
})

afterEach(async () => {
await closeDb()
})

const testCases = [
{
name: 'INTEGER',
tableSql: `
CREATE TABLE test (
id INTEGER,
age INTEGER,
name VARCHAR
)
`,
insertSql: [
`INSERT INTO test (id, age, name) VALUES (1, 25, 'John')`,
`INSERT INTO test (id, age, name) VALUES (2, 30, 'Jane')`,
`INSERT INTO test (id, age, name) VALUES (3, 25, 'Bob')`,
],
columnName: 'age',
initialType: 'INTEGER',
find: '25',
replace: '35',
expectedAffectedRows: 2,
expectedResults: ['35', '30', '35'],
},
{
name: 'DOUBLE',
tableSql: `
CREATE TABLE test (
id INTEGER,
price DOUBLE,
product VARCHAR
)
`,
insertSql: [
`INSERT INTO test (id, price, product) VALUES (1, 19.99, 'Apple')`,
`INSERT INTO test (id, price, product) VALUES (2, 25.50, 'Banana')`,
`INSERT INTO test (id, price, product) VALUES (3, 19.99, 'Cherry')`,
],
columnName: 'price',
initialType: 'DOUBLE',
find: '19.99',
replace: '29.99',
expectedAffectedRows: 2,
expectedResults: ['29.99', '25.5', '29.99'],
},
{
name: 'BOOLEAN',
tableSql: `
CREATE TABLE test (
id INTEGER,
is_active BOOLEAN,
username VARCHAR
)
`,
insertSql: [
`INSERT INTO test (id, is_active, username) VALUES (1, true, 'user1')`,
`INSERT INTO test (id, is_active, username) VALUES (2, false, 'user2')`,
`INSERT INTO test (id, is_active, username) VALUES (3, true, 'user3')`,
],
columnName: 'is_active',
initialType: 'BOOLEAN',
find: 'true',
replace: 'active',
expectedAffectedRows: 2,
expectedResults: ['active', 'false', 'active'],
},
{
name: 'DATE',
tableSql: `
CREATE TABLE test (
id INTEGER,
created_date DATE,
status VARCHAR
)
`,
insertSql: [
`INSERT INTO test (id, created_date, status) VALUES (1, '2023-01-15', 'active')`,
`INSERT INTO test (id, created_date, status) VALUES (2, '2023-02-20', 'inactive')`,
`INSERT INTO test (id, created_date, status) VALUES (3, '2023-01-15', 'pending')`,
],
columnName: 'created_date',
initialType: 'DATE',
find: '2023-01-15',
replace: '2023-03-01',
expectedAffectedRows: 2,
expectedResults: ['2023-03-01', '2023-02-20', '2023-03-01'],
},
{
name: 'VARCHAR',
tableSql: `
CREATE TABLE test (
id INTEGER,
description VARCHAR(255),
category VARCHAR
)
`,
insertSql: [
`INSERT INTO test (id, description, category) VALUES (1, 'test item', 'A')`,
`INSERT INTO test (id, description, category) VALUES (2, 'another test', 'B')`,
],
columnName: 'description',
initialType: 'VARCHAR',
find: 'test',
replace: 'sample',
expectedAffectedRows: 2,
expectedResults: ['sample item', 'another sample'],
},
{
name: 'JSON',
tableSql: `
CREATE TABLE test (
id INTEGER,
metadata JSON,
name VARCHAR
)
`,
insertSql: [
`INSERT INTO test (id, metadata, name) VALUES (1, '{"status": "active", "priority": "high"}', 'Item1')`,
`INSERT INTO test (id, metadata, name) VALUES (2, '{"status": "inactive", "priority": "low"}', 'Item2')`,
`INSERT INTO test (id, metadata, name) VALUES (3, '{"status": "active", "priority": "medium"}', 'Item3')`,
],
columnName: 'metadata',
initialType: 'JSON',
find: '"active"',
replace: '"pending"',
expectedAffectedRows: 2,
expectedResults: [
'{"status": "pending", "priority": "high"}',
'{"status": "inactive", "priority": "low"}',
'{"status": "pending", "priority": "medium"}',
],
},
{
name: 'JSON',
tableSql: `
CREATE TABLE test (
id INTEGER,
metadata JSON,
name VARCHAR
)
`,
insertSql: [
`INSERT INTO test (id, metadata, name) VALUES (1, '{"status": "active", "priority": "high"}', 'Item1')`,
`INSERT INTO test (id, metadata, name) VALUES (2, '{"status": "inactive", "priority": "low"}', 'Item2')`,
`INSERT INTO test (id, metadata, name) VALUES (3, '{"status": "active", "priority": "medium"}', 'Item3')`,
],
columnName: 'metadata',
initialType: 'JSON',
find: 'active',
replace: 'pending',
expectedAffectedRows: 3,
expectedResults: [
'{"status": "pending", "priority": "high"}',
'{"status": "inpending", "priority": "low"}',
'{"status": "pending", "priority": "medium"}',
],
},
// Test cases for zero affected rows - column type should be reverted
{
name: 'INTEGER with zero affected rows',
tableSql: `
CREATE TABLE test (
id INTEGER,
age INTEGER,
name VARCHAR
)
`,
insertSql: [
`INSERT INTO test (id, age, name) VALUES (1, 25, 'John')`,
`INSERT INTO test (id, age, name) VALUES (2, 30, 'Jane')`,
`INSERT INTO test (id, age, name) VALUES (3, 35, 'Bob')`,
],
columnName: 'age',
initialType: 'INTEGER',
find: '99', // This value doesn't exist in the data
replace: '100',
expectedAffectedRows: 0,
expectedResults: [25, 30, 35], // Data should remain unchanged
expectTypeReverted: true, // Special flag to indicate type should be reverted
},
{
name: 'DOUBLE with zero affected rows',
tableSql: `
CREATE TABLE test (
id INTEGER,
price DOUBLE,
product VARCHAR
)
`,
insertSql: [
`INSERT INTO test (id, price, product) VALUES (1, 19.99, 'Apple')`,
`INSERT INTO test (id, price, product) VALUES (2, 25.50, 'Banana')`,
`INSERT INTO test (id, price, product) VALUES (3, 35.00, 'Cherry')`,
],
columnName: 'price',
initialType: 'DOUBLE',
find: '99.99', // This value doesn't exist in the data
replace: '100.00',
expectedAffectedRows: 0,
expectedResults: [19.99, 25.5, 35.0], // Data should remain unchanged
expectTypeReverted: true, // Special flag to indicate type should be reverted
},
{
name: 'BOOLEAN with zero affected rows',
tableSql: `
CREATE TABLE test (
id INTEGER,
is_active BOOLEAN,
username VARCHAR
)
`,
insertSql: [
`INSERT INTO test (id, is_active, username) VALUES (1, true, 'user1')`,
`INSERT INTO test (id, is_active, username) VALUES (2, false, 'user2')`,
`INSERT INTO test (id, is_active, username) VALUES (3, true, 'user3')`,
],
columnName: 'is_active',
initialType: 'BOOLEAN',
find: 'maybe', // This value doesn't exist in boolean data
replace: 'possibly',
expectedAffectedRows: 0,
expectedResults: [true, false, true], // Data should remain unchanged
expectTypeReverted: true, // Special flag to indicate type should be reverted
},
]

test.each(testCases)(
'should $name column and perform replace',
async ({
tableSql,
insertSql,
columnName,
initialType,
find,
replace,
expectedAffectedRows,
expectedResults,
expectTypeReverted,
}) => {
const db = getDb()
const service = new ReplaceOperationService(db)

await db.run(tableSql)

// Insert test data
for (const sql of insertSql) {
await db.run(sql)
}

// Verify initial column type
const initialTypeResult = (
await db.runAndReadAll(`PRAGMA table_info("test")`)
).getRowObjectsJson() as Array<{
name: string
type: string
}>
const column = initialTypeResult.find((col) => col.name === columnName)
expect(column).toBeDefined()
expect(column!.type).toBe(initialType)

// Perform replace operation
const affectedRows = await service.performReplace({
table: 'test',
column: columnName,
find,
replace,
caseSensitive: false,
wholeWord: false,
})

expect(affectedRows).toBe(expectedAffectedRows)

// Verify column type after operation
const finalType = (
await db.runAndReadAll(`PRAGMA table_info("test")`)
).getRowObjectsJson() as Array<{
name: string
type: string
}>
const columnAfter = finalType.find((col) => col.name === columnName)
expect(columnAfter).toBeDefined()

// For zero affected rows with non-string types, expect type to be reverted
if (expectTypeReverted) {
expect(columnAfter!.type).toBe(initialType)
} else {
expect(columnAfter!.type).toBe('VARCHAR')
}

// Verify data was replaced correctly (or unchanged for zero affected rows)
const result = await db.runAndReadAll(`SELECT ${columnName} FROM "test" ORDER BY id`)
const values = result.getRowObjectsJson().map((row) => row[columnName])
expect(values).toEqual(expectedResults)
},
)
})
Loading