Skip to content

Commit a44a8a1

Browse files
authored
feat(column-operations): add uppercase conversion functionality (#172)
This commit adds a new feature to the data processing component that allows users to convert the contents of a selected column to uppercase. The changes include: - Implement the `handleUpperCase` function in the `ColumnHeaderMenu.vue` component to handle the uppercase conversion operation. - Add a new test case in `project.replace.test.ts` to verify the basic find and replace functionality. - Implement the `UppercaseConversionService` in `uppercase-conversion.service.ts` to handle the database operations for the uppercase conversion. - Add a new test suite in `uppercase.test.ts` to cover the uppercase conversion API endpoint. These changes provide users with an additional data transformation option, making the application more versatile and user-friendly.
1 parent 70d20f2 commit a44a8a1

9 files changed

Lines changed: 527 additions & 32 deletions

File tree

backend/src/api/project/index.ts

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import { cleanupProject, generateProjectName } from '@backend/api/project/project.import-file'
22
import {
3+
AffectedRowsSchema,
4+
ColumnNameSchema,
35
GetProjectByIdResponse,
46
PaginationQuery,
57
ProjectParams,
68
ProjectResponseSchema,
79
ReplaceOperationSchema,
8-
TrimWhitespaceSchema,
910
type Project,
1011
} from '@backend/api/project/schemas'
1112
import { databasePlugin } from '@backend/plugins/database'
1213
import { errorHandlerPlugin } from '@backend/plugins/error-handler'
1314
import { ReplaceOperationService } from '@backend/services/replace-operation.service'
1415
import { TrimWhitespaceService } from '@backend/services/trim-whitespace.service'
16+
import { UppercaseConversionService } from '@backend/services/uppercase-conversion.service'
1517
import { ApiErrorHandler } from '@backend/types/error-handler'
1618
import { ApiErrors } from '@backend/types/error-schemas'
1719
import { enhanceSchemaWithTypes, type DuckDBTablePragma } from '@backend/utils/duckdb-types'
@@ -585,9 +587,7 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' })
585587
{
586588
body: ReplaceOperationSchema,
587589
response: {
588-
200: t.Object({
589-
affectedRows: t.Integer(),
590-
}),
590+
200: AffectedRowsSchema,
591591
400: ApiErrors,
592592
404: ApiErrors,
593593
422: ApiErrors,
@@ -644,11 +644,9 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' })
644644
}
645645
},
646646
{
647-
body: TrimWhitespaceSchema,
647+
body: ColumnNameSchema,
648648
response: {
649-
200: t.Object({
650-
affectedRows: t.Integer(),
651-
}),
649+
200: AffectedRowsSchema,
652650
400: ApiErrors,
653651
404: ApiErrors,
654652
422: ApiErrors,
@@ -662,3 +660,63 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' })
662660
},
663661
},
664662
)
663+
664+
.post(
665+
'/:projectId/uppercase',
666+
async ({ db, params: { projectId }, body: { column }, status }) => {
667+
const table = `project_${projectId}`
668+
669+
// Check if column exists
670+
const columnExistsReader = await db().runAndReadAll(
671+
'SELECT 1 FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
672+
[table, column],
673+
)
674+
675+
if (columnExistsReader.getRows().length === 0) {
676+
return status(
677+
400,
678+
ApiErrorHandler.validationErrorWithData('Column not found', [
679+
`Column '${column}' does not exist in table '${table}'`,
680+
]),
681+
)
682+
}
683+
684+
const uppercaseConversionService = new UppercaseConversionService(db())
685+
686+
try {
687+
const affectedRows = await uppercaseConversionService.performOperation({
688+
table,
689+
column,
690+
})
691+
692+
return {
693+
affectedRows,
694+
}
695+
} catch (error) {
696+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
697+
return status(
698+
500,
699+
ApiErrorHandler.internalServerErrorWithData(
700+
'Failed to perform uppercase conversion operation',
701+
[errorMessage],
702+
),
703+
)
704+
}
705+
},
706+
{
707+
body: ColumnNameSchema,
708+
response: {
709+
200: AffectedRowsSchema,
710+
400: ApiErrors,
711+
404: ApiErrors,
712+
422: ApiErrors,
713+
500: ApiErrors,
714+
},
715+
detail: {
716+
summary: 'Convert text to uppercase in a column',
717+
description:
718+
'Convert all text values in a specific column to uppercase',
719+
tags,
720+
},
721+
},
722+
)

backend/src/api/project/schemas.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,21 @@ export const GetProjectByIdResponse = t.Object({
5050
})
5151
export type GetProjectByIdResponse = typeof GetProjectByIdResponse.static
5252

53-
// Replace operation schema
54-
export const ReplaceOperationSchema = t.Object({
53+
// Column operation schema
54+
export const ColumnNameSchema = t.Object({
5555
column: t.String({
5656
minLength: 1,
5757
error: 'Column name is required and must be at least 1 character long',
5858
}),
59+
})
60+
61+
export const AffectedRowsSchema = t.Object({
62+
affectedRows: t.Integer(),
63+
})
64+
65+
// Replace operation schema
66+
export const ReplaceOperationSchema = t.Object({
67+
column: ColumnNameSchema.properties.column,
5968
find: t.String({
6069
minLength: 1,
6170
error: 'Find value is required and must be at least 1 character long',
@@ -70,11 +79,3 @@ export const ReplaceOperationSchema = t.Object({
7079
default: false,
7180
}),
7281
})
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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { ColumnOperationParams } from '@backend/services/column-operation.service'
2+
import { ColumnOperationService } from '@backend/services/column-operation.service'
3+
4+
export class UppercaseConversionService extends ColumnOperationService {
5+
public async performOperation(params: ColumnOperationParams): Promise<number> {
6+
const { table, column } = params
7+
8+
return this.executeColumnOperation(
9+
table,
10+
column,
11+
() => this.buildParameterizedUpdateQuery(table, column),
12+
() => this.countAffectedRows(table, column),
13+
)
14+
}
15+
16+
/**
17+
* Builds a parameterized UPDATE query to safely perform uppercase conversion operations
18+
*/
19+
private buildParameterizedUpdateQuery(table: string, column: string) {
20+
const query = `
21+
UPDATE "${table}"
22+
SET "${column}" = UPPER("${column}")
23+
WHERE "${column}" IS NOT NULL
24+
AND "${column}" != UPPER("${column}")
25+
`
26+
27+
return { query, params: [] }
28+
}
29+
30+
/**
31+
* Counts the number of rows that would be affected by the uppercase conversion operation
32+
*/
33+
private countAffectedRows(table: string, column: string): Promise<number> {
34+
const query = `
35+
SELECT COUNT(*) as count
36+
FROM "${table}"
37+
WHERE "${column}" IS NOT NULL
38+
AND "${column}" != UPPER("${column}")
39+
`
40+
41+
return this.getCount(query, [])
42+
}
43+
}

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe('Project API - find and replace', () => {
6969
await closeDb()
7070
})
7171

72-
test('should perform basic replace operation', async () => {
72+
test('should perform basic find and replace operation', async () => {
7373
const { data, status, error } = await api.project({ projectId }).replace.post({
7474
column: 'city',
7575
find: 'New York',
@@ -265,7 +265,7 @@ describe('Project API - find and replace', () => {
265265
replace: "Jonathan's",
266266
expectedAffectedRows: 0, // No data with John's in test data
267267
},
268-
])('$description', async ({ column, find, replace }) => {
268+
])('$description', async ({ column, find, replace, expectedAffectedRows }) => {
269269
const { data, status, error } = await api.project({ projectId }).replace.post({
270270
column,
271271
find,
@@ -276,7 +276,9 @@ describe('Project API - find and replace', () => {
276276

277277
expect(status).toBe(200)
278278
expect(error).toBeNull()
279-
expect(data!.affectedRows).toBeGreaterThanOrEqual(0)
279+
expect(data).toEqual({
280+
affectedRows: expectedAffectedRows,
281+
})
280282
})
281283
})
282284
})
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { projectRoutes } from '@backend/api/project'
2+
import { closeDb, initializeDb } from '@backend/plugins/database'
3+
import { treaty } from '@elysiajs/eden'
4+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
5+
import { Elysia } from 'elysia'
6+
import { tmpdir } from 'node:os'
7+
8+
interface TestData {
9+
name: string
10+
email: string
11+
city: string
12+
}
13+
14+
const TEST_DATA: TestData[] = [
15+
{ name: 'John Doe', email: 'john@example.com', city: 'New York' },
16+
{ name: 'Jane Smith', email: 'jane@example.com', city: 'Los Angeles' },
17+
{ name: 'Bob Johnson', email: 'bob@example.com', city: 'New York' },
18+
{ name: 'Alice Brown', email: 'alice@test.com', city: 'Chicago' },
19+
{ name: 'Charlie Davis', email: 'charlie@example.com', city: 'New York' },
20+
]
21+
22+
const createTestApi = () => {
23+
return treaty(new Elysia().use(projectRoutes)).api
24+
}
25+
26+
const tempFilePath = tmpdir() + '/test-data.json'
27+
28+
describe('Project API - Uppercase Conversion', () => {
29+
let api: ReturnType<typeof createTestApi>
30+
let projectId: string
31+
32+
const importTestData = async () => {
33+
await Bun.write(tempFilePath, JSON.stringify(TEST_DATA))
34+
35+
const { status, error } = await api.project({ projectId }).import.post({
36+
filePath: tempFilePath,
37+
})
38+
39+
expect(error).toBeNull()
40+
expect(status).toBe(201)
41+
}
42+
43+
beforeEach(async () => {
44+
await initializeDb(':memory:')
45+
api = createTestApi()
46+
47+
const { data, status, error } = await api.project.post({
48+
name: 'Test Project for uppercase',
49+
})
50+
expect(error).toBeNull()
51+
expect(status).toBe(201)
52+
projectId = (data as any)!.data!.id as string
53+
54+
await importTestData()
55+
})
56+
57+
afterEach(async () => {
58+
await closeDb()
59+
})
60+
61+
test('should perform basic uppercase conversion', async () => {
62+
const { data, status, error } = await api.project({ projectId }).uppercase.post({
63+
column: 'name',
64+
})
65+
66+
expect(status).toBe(200)
67+
expect(error).toBeNull()
68+
expect(data).toEqual({
69+
affectedRows: 5,
70+
})
71+
72+
// Verify the data was actually changed
73+
const { data: projectData } = await api.project({ projectId }).get({
74+
query: { offset: 0, limit: 25 },
75+
})
76+
77+
expect(projectData).toHaveProperty(
78+
'data',
79+
expect.arrayContaining([
80+
expect.objectContaining({ name: 'JOHN DOE' }),
81+
expect.objectContaining({ name: 'JANE SMITH' }),
82+
expect.objectContaining({ name: 'BOB JOHNSON' }),
83+
expect.objectContaining({ name: 'ALICE BROWN' }),
84+
expect.objectContaining({ name: 'CHARLIE DAVIS' }),
85+
]),
86+
)
87+
})
88+
89+
test('should return 400 for non-existent column', async () => {
90+
const { data, status, error } = await api.project({ projectId }).uppercase.post({
91+
column: 'nonexistent_column',
92+
})
93+
94+
expect(status).toBe(400)
95+
expect(data).toBeNull()
96+
expect(error).toHaveProperty('status', 400)
97+
expect(error).toHaveProperty('value', [
98+
{
99+
code: 'VALIDATION',
100+
message: 'Column not found',
101+
details: [`Column 'nonexistent_column' does not exist in table 'project_${projectId}'`],
102+
},
103+
])
104+
})
105+
106+
test('should return 422 for missing required fields', async () => {
107+
const { data, status, error } = await api.project({ projectId }).uppercase.post({
108+
column: '',
109+
})
110+
111+
expect(status).toBe(422)
112+
expect(data).toBeNull()
113+
expect(error).toHaveProperty('status', 422)
114+
expect(error).toHaveProperty('value', expect.arrayContaining([
115+
expect.objectContaining({
116+
message: 'Expected string length greater or equal to 1',
117+
path: '/column',
118+
}),
119+
]))
120+
})
121+
})

0 commit comments

Comments
 (0)