Skip to content

Commit 70d20f2

Browse files
authored
feat(column-operation): Implement common pattern for column operations (#171)
1 parent fb8eff6 commit 70d20f2

11 files changed

Lines changed: 569 additions & 122 deletions

File tree

backend/src/api/project/index.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import {
55
ProjectParams,
66
ProjectResponseSchema,
77
ReplaceOperationSchema,
8+
TrimWhitespaceSchema,
89
type Project,
910
} from '@backend/api/project/schemas'
1011
import { databasePlugin } from '@backend/plugins/database'
1112
import { errorHandlerPlugin } from '@backend/plugins/error-handler'
1213
import { ReplaceOperationService } from '@backend/services/replace-operation.service'
14+
import { TrimWhitespaceService } from '@backend/services/trim-whitespace.service'
1315
import { ApiErrorHandler } from '@backend/types/error-handler'
1416
import { ApiErrors } from '@backend/types/error-schemas'
1517
import { enhanceSchemaWithTypes, type DuckDBTablePragma } from '@backend/utils/duckdb-types'
@@ -558,7 +560,7 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' })
558560
const replaceService = new ReplaceOperationService(db())
559561

560562
try {
561-
const affectedRows = await replaceService.performReplace({
563+
const affectedRows = await replaceService.performOperation({
562564
table,
563565
column,
564566
find,
@@ -584,7 +586,7 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' })
584586
body: ReplaceOperationSchema,
585587
response: {
586588
200: t.Object({
587-
affectedRows: t.Number(),
589+
affectedRows: t.Integer(),
588590
}),
589591
400: ApiErrors,
590592
404: ApiErrors,
@@ -598,3 +600,65 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' })
598600
},
599601
},
600602
)
603+
604+
.post(
605+
'/:projectId/trim_whitespace',
606+
async ({ db, params: { projectId }, body: { column }, status }) => {
607+
const table = `project_${projectId}`
608+
609+
// Check if column exists
610+
const columnExistsReader = await db().runAndReadAll(
611+
'SELECT 1 FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
612+
[table, column],
613+
)
614+
615+
if (columnExistsReader.getRows().length === 0) {
616+
return status(
617+
400,
618+
ApiErrorHandler.validationErrorWithData('Column not found', [
619+
`Column '${column}' does not exist in table '${table}'`,
620+
]),
621+
)
622+
}
623+
624+
const trimWhitespaceService = new TrimWhitespaceService(db())
625+
626+
try {
627+
const affectedRows = await trimWhitespaceService.performOperation({
628+
table,
629+
column,
630+
})
631+
632+
return {
633+
affectedRows,
634+
}
635+
} catch (error) {
636+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
637+
return status(
638+
500,
639+
ApiErrorHandler.internalServerErrorWithData(
640+
'Failed to perform trim whitespace operation',
641+
[errorMessage],
642+
),
643+
)
644+
}
645+
},
646+
{
647+
body: TrimWhitespaceSchema,
648+
response: {
649+
200: t.Object({
650+
affectedRows: t.Integer(),
651+
}),
652+
400: ApiErrors,
653+
404: ApiErrors,
654+
422: ApiErrors,
655+
500: ApiErrors,
656+
},
657+
detail: {
658+
summary: 'Trim leading and trailing whitespace from a column',
659+
description:
660+
'Remove leading and trailing whitespace characters from all values in a specific column',
661+
tags,
662+
},
663+
},
664+
)

backend/src/api/project/schemas.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,11 @@ export const ReplaceOperationSchema = t.Object({
7070
default: false,
7171
}),
7272
})
73+
74+
// Trim whitespace operation schema
75+
export const TrimWhitespaceSchema = t.Object({
76+
column: t.String({
77+
minLength: 1,
78+
error: 'Column name is required and must be at least 1 character long',
79+
}),
80+
})
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import type { DuckDBConnection, DuckDBValue } from '@duckdb/node-api'
2+
3+
export interface ColumnOperationParams {
4+
table: string
5+
column: string
6+
}
7+
8+
export abstract class ColumnOperationService {
9+
constructor(protected db: DuckDBConnection) {}
10+
11+
/**
12+
* Abstract method that must be implemented by subclasses to perform the specific operation
13+
* This will be the entry point called from the API endpoints
14+
*/
15+
public abstract performOperation(params: ColumnOperationParams): Promise<number>
16+
17+
/**
18+
* Common pattern for column operations:
19+
* 1. Ensure column is string type if needed
20+
* 2. Count affected rows before operation
21+
* 3. Perform operation if rows affected
22+
* 4. Rollback if no rows affected or operation failed
23+
*/
24+
protected async executeColumnOperation(
25+
table: string,
26+
column: string,
27+
operation: () => { query: string; params: DuckDBValue[] },
28+
countAffectedRows: () => Promise<number>,
29+
): Promise<number> {
30+
await this.db.run('BEGIN TRANSACTION')
31+
32+
try {
33+
// Check if column is string-like, if not, convert it first
34+
await this.ensureColumnIsStringType(table, column)
35+
36+
// Count rows that will be affected before the update
37+
const affectedRows = await countAffectedRows()
38+
39+
// Only proceed if there are rows to update
40+
if (affectedRows === 0) {
41+
await this.db.run('ROLLBACK')
42+
43+
return affectedRows
44+
}
45+
46+
// Build and execute the parameterized UPDATE query
47+
const { query, params } = operation()
48+
await this.db.run(query, params)
49+
await this.db.run('COMMIT')
50+
51+
return affectedRows
52+
} catch (error) {
53+
await this.db.run('ROLLBACK')
54+
throw error
55+
}
56+
}
57+
58+
/**
59+
* Changes the column type using ALTER TABLE
60+
*/
61+
protected async changeColumnType(table: string, column: string, newType: string): Promise<void> {
62+
await this.db.run(`ALTER TABLE "${table}" ALTER "${column}" TYPE ${newType}`)
63+
}
64+
65+
/**
66+
* Escapes special regex characters in a string
67+
*/
68+
protected escapeRegex(str: string): string {
69+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
70+
}
71+
72+
protected async getCount(query: string, params: DuckDBValue[]): Promise<number> {
73+
const result = (await this.db.runAndReadAll(query, params)).getRowObjectsJson() as Array<{
74+
count: number
75+
}>
76+
77+
return Number(result[0]!.count)
78+
}
79+
80+
/**
81+
* Checks if a column type is string-like (VARCHAR, TEXT, CHAR, BPCHAR)
82+
*/
83+
private isStringLikeType(columnType: string): boolean {
84+
return ['VARCHAR', 'TEXT', 'CHAR', 'BPCHAR'].some((type) => columnType.includes(type))
85+
}
86+
87+
/**
88+
* Ensures the column is a string-like type, converting it if necessary
89+
*/
90+
private async ensureColumnIsStringType(table: string, column: string): Promise<void> {
91+
const columnType = await this.getColumnType(table, column)
92+
93+
if (!this.isStringLikeType(columnType)) {
94+
// Convert the column to VARCHAR
95+
await this.changeColumnType(table, column, 'VARCHAR')
96+
}
97+
}
98+
99+
/**
100+
* Gets the column type from the table schema
101+
*/
102+
private async getColumnType(table: string, column: string): Promise<string> {
103+
const result = await this.db.runAndReadAll(`PRAGMA table_info("${table}")`)
104+
const columns = result.getRowObjectsJson() as Array<{
105+
name: string
106+
type: string
107+
}>
108+
109+
const columnInfo = columns.find((col) => col.name === column)
110+
if (!columnInfo) {
111+
throw new Error(`Column '${column}' not found in table '${table}'`)
112+
}
113+
114+
return columnInfo.type.toUpperCase()
115+
}
116+
}
Lines changed: 12 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,25 @@
1-
import type { DuckDBConnection, DuckDBValue } from '@duckdb/node-api'
1+
import type { ColumnOperationParams } from '@backend/services/column-operation.service'
2+
import { ColumnOperationService } from '@backend/services/column-operation.service'
3+
import type { DuckDBValue } from '@duckdb/node-api'
24

3-
export interface ReplaceOperationParams {
4-
table: string
5-
column: string
5+
interface ReplaceOperationParams extends ColumnOperationParams {
66
find: string
77
replace: string
88
caseSensitive: boolean
99
wholeWord: boolean
1010
}
1111

12-
export class ReplaceOperationService {
13-
constructor(private db: DuckDBConnection) {}
14-
15-
/**
16-
* Performs a replace operation on a column in a project table
17-
*/
18-
async performReplace(params: ReplaceOperationParams): Promise<number> {
12+
export class ReplaceOperationService extends ColumnOperationService {
13+
public async performOperation(params: ReplaceOperationParams): Promise<number> {
1914
const { table, column, find, replace, caseSensitive, wholeWord } = params
2015

21-
// Get the original column type before any modifications
22-
const originalColumnType = await this.getColumnType(table, column)
23-
24-
// Check if column is string-like, if not, convert it first
25-
const wasConverted = await this.ensureColumnIsStringType(table, column)
26-
27-
// Count rows that will be affected before the update
28-
const affectedRows = await this.countAffectedRows(table, column, find, caseSensitive, wholeWord)
29-
30-
// Only proceed if there are rows to update
31-
if (affectedRows === 0) {
32-
// Revert column type if it was converted and no rows were affected
33-
if (wasConverted) {
34-
await this.changeColumnType(table, column, originalColumnType)
35-
}
36-
return 0
37-
}
38-
39-
// Build and execute the parameterized UPDATE query
40-
const { query, params: queryParams } = this.buildParameterizedUpdateQuery(
16+
return this.executeColumnOperation(
4117
table,
4218
column,
43-
find,
44-
replace,
45-
caseSensitive,
46-
wholeWord,
19+
() =>
20+
this.buildParameterizedUpdateQuery(table, column, find, replace, caseSensitive, wholeWord),
21+
() => this.countAffectedRows(table, column, find, caseSensitive, wholeWord),
4722
)
48-
49-
await this.db.run(query, queryParams)
50-
51-
return affectedRows
5223
}
5324

5425
/**
@@ -113,7 +84,7 @@ export class ReplaceOperationService {
11384
/**
11485
* Counts the number of rows that will be affected by the replace operation
11586
*/
116-
private async countAffectedRows(
87+
private countAffectedRows(
11788
table: string,
11889
column: string,
11990
find: string,
@@ -152,70 +123,6 @@ export class ReplaceOperationService {
152123
}
153124
}
154125

155-
const countBeforeReader = await this.db.runAndReadAll(query, params)
156-
const countBeforeResult = countBeforeReader.getRowObjectsJson()
157-
158-
return Number(countBeforeResult[0]?.count ?? 0)
159-
}
160-
161-
/**
162-
* Escapes special regex characters in a string
163-
*/
164-
private escapeRegex(str: string): string {
165-
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
166-
}
167-
168-
/**
169-
* Gets the column type from the table schema
170-
*/
171-
private async getColumnType(table: string, column: string): Promise<string> {
172-
const result = await this.db.runAndReadAll(`PRAGMA table_info("${table}")`)
173-
const columns = result.getRowObjectsJson() as Array<{
174-
cid: number
175-
name: string
176-
type: string
177-
pk: boolean
178-
notnull: boolean
179-
dflt_value: string | null
180-
}>
181-
182-
const columnInfo = columns.find((col) => col.name === column)
183-
if (!columnInfo) {
184-
throw new Error(`Column '${column}' not found in table '${table}'`)
185-
}
186-
187-
return columnInfo.type.toUpperCase()
188-
}
189-
190-
/**
191-
* Checks if a column type is string-like (VARCHAR, TEXT, BLOB)
192-
*/
193-
private isStringLikeType(columnType: string): boolean {
194-
const stringTypes = ['VARCHAR', 'TEXT', 'CHAR', 'BPCHAR']
195-
196-
return stringTypes.some((type) => columnType.includes(type))
197-
}
198-
199-
/**
200-
* Ensures the column is a string-like type, converting it if necessary
201-
* Returns true if the column was converted, false otherwise
202-
*/
203-
private async ensureColumnIsStringType(table: string, column: string): Promise<boolean> {
204-
const columnType = await this.getColumnType(table, column)
205-
206-
if (!this.isStringLikeType(columnType)) {
207-
// Convert the column to VARCHAR
208-
await this.changeColumnType(table, column, 'VARCHAR')
209-
return true
210-
}
211-
212-
return false
213-
}
214-
215-
/**
216-
* Changes the column type to the specified type
217-
*/
218-
private async changeColumnType(table: string, column: string, newType: string): Promise<void> {
219-
await this.db.run(`ALTER TABLE "${table}" ALTER "${column}" TYPE ${newType}`)
126+
return this.getCount(query, params)
220127
}
221128
}

0 commit comments

Comments
 (0)