Skip to content

Commit b49c52f

Browse files
authored
feat(uppercase-conversion): Implement lowercase conversion service (#173)
This commit introduces a new `LowercaseConversionService` that provides functionality to perform lowercase conversion operations on a specified table and column. The key changes are: - Implement the `LowercaseConversionService` class that extends the `ColumnOperationService` base class - Provide the `performOperation` method to execute the lowercase conversion on the specified table and column - Implement the `buildParameterizedUpdateQuery` method to construct a parameterized UPDATE query for the lowercase conversion - Implement the `countAffectedRows` method to count the number of rows that would be affected by the lowercase conversion These changes enable the application to perform efficient and safe lowercase conversion operations on database columns.
1 parent a44a8a1 commit b49c52f

File tree

8 files changed

+665
-46
lines changed

8 files changed

+665
-46
lines changed

backend/src/api/project/index.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@backend/api/project/schemas'
1212
import { databasePlugin } from '@backend/plugins/database'
1313
import { errorHandlerPlugin } from '@backend/plugins/error-handler'
14+
import { LowercaseConversionService } from '@backend/services/lowercase-conversion.service'
1415
import { ReplaceOperationService } from '@backend/services/replace-operation.service'
1516
import { TrimWhitespaceService } from '@backend/services/trim-whitespace.service'
1617
import { UppercaseConversionService } from '@backend/services/uppercase-conversion.service'
@@ -714,8 +715,66 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' })
714715
},
715716
detail: {
716717
summary: 'Convert text to uppercase in a column',
717-
description:
718-
'Convert all text values in a specific column to uppercase',
718+
description: 'Convert all text values in a specific column to uppercase',
719+
tags,
720+
},
721+
},
722+
)
723+
724+
.post(
725+
'/:projectId/lowercase',
726+
async ({ db, params: { projectId }, body: { column }, status }) => {
727+
const table = `project_${projectId}`
728+
729+
// Check if column exists
730+
const columnExistsReader = await db().runAndReadAll(
731+
'SELECT 1 FROM information_schema.columns WHERE table_name = ? AND column_name = ?',
732+
[table, column],
733+
)
734+
735+
if (columnExistsReader.getRows().length === 0) {
736+
return status(
737+
400,
738+
ApiErrorHandler.validationErrorWithData('Column not found', [
739+
`Column '${column}' does not exist in table '${table}'`,
740+
]),
741+
)
742+
}
743+
744+
const lowercaseConversionService = new LowercaseConversionService(db())
745+
746+
try {
747+
const affectedRows = await lowercaseConversionService.performOperation({
748+
table,
749+
column,
750+
})
751+
752+
return {
753+
affectedRows,
754+
}
755+
} catch (error) {
756+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
757+
return status(
758+
500,
759+
ApiErrorHandler.internalServerErrorWithData(
760+
'Failed to perform lowercase conversion operation',
761+
[errorMessage],
762+
),
763+
)
764+
}
765+
},
766+
{
767+
body: ColumnNameSchema,
768+
response: {
769+
200: AffectedRowsSchema,
770+
400: ApiErrors,
771+
404: ApiErrors,
772+
422: ApiErrors,
773+
500: ApiErrors,
774+
},
775+
detail: {
776+
summary: 'Convert text to lowercase in a column',
777+
description: 'Convert all text values in a specific column to lowercase',
719778
tags,
720779
},
721780
},
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 LowercaseConversionService 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 lowercase conversion operations
18+
*/
19+
private buildParameterizedUpdateQuery(table: string, column: string) {
20+
const query = `
21+
UPDATE "${table}"
22+
SET "${column}" = LOWER("${column}")
23+
WHERE "${column}" IS NOT NULL
24+
AND "${column}" != LOWER("${column}")
25+
`
26+
27+
return { query, params: [] }
28+
}
29+
30+
/**
31+
* Counts the number of rows that would be affected by the lowercase 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}" != LOWER("${column}")
39+
`
40+
41+
return this.getCount(query, [])
42+
}
43+
}

backend/src/services/uppercase-conversion.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,4 @@ export class UppercaseConversionService extends ColumnOperationService {
4040

4141
return this.getCount(query, [])
4242
}
43-
}
43+
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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 - Lowercase 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 lowercase',
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 lowercase conversion', async () => {
62+
const { data, status, error } = await api.project({ projectId }).lowercase.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 }).lowercase.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 }).lowercase.post({
108+
column: '',
109+
})
110+
111+
expect(status).toBe(422)
112+
expect(data).toBeNull()
113+
expect(error).toHaveProperty('status', 422)
114+
expect(error).toHaveProperty(
115+
'value',
116+
expect.arrayContaining([
117+
expect.objectContaining({
118+
message: 'Expected string length greater or equal to 1',
119+
path: '/column',
120+
}),
121+
]),
122+
)
123+
})
124+
125+
test('should handle mixed case data with some already lowercase', async () => {
126+
// Create a new project for this test to avoid import conflicts
127+
const {
128+
data: newProjectData,
129+
status: newProjectStatus,
130+
error: newProjectError,
131+
} = await api.project.post({
132+
name: 'Test Project for lowercase - mixed case data',
133+
})
134+
expect(newProjectError).toBeNull()
135+
expect(newProjectStatus).toBe(201)
136+
const newProjectId = (newProjectData as any)!.data!.id as string
137+
138+
// Create test data with mixed case
139+
const mixedData = [
140+
{ name: 'JOHN DOE', email: 'john@example.com', city: 'NEW YORK' },
141+
{ name: 'jane smith', email: 'jane@example.com', city: 'los angeles' },
142+
{ name: 'Bob Johnson', email: 'bob@example.com', city: 'New York' },
143+
{ name: 'alice brown', email: 'alice@test.com', city: 'chicago' },
144+
{ name: 'CHARLIE DAVIS', email: 'charlie@example.com', city: 'CHICAGO' },
145+
]
146+
147+
await Bun.write(tempFilePath, JSON.stringify(mixedData))
148+
149+
// Import the mixed data
150+
const { status, error } = await api.project({ projectId: newProjectId }).import.post({
151+
filePath: tempFilePath,
152+
})
153+
154+
expect(error).toBeNull()
155+
expect(status).toBe(201)
156+
157+
// Perform lowercase conversion
158+
const {
159+
data,
160+
status: lowercaseStatus,
161+
error: lowercaseError,
162+
} = await api.project({ projectId: newProjectId }).lowercase.post({
163+
column: 'name',
164+
})
165+
166+
expect(lowercaseStatus).toBe(200)
167+
expect(lowercaseError).toBeNull()
168+
expect(data).toEqual({
169+
affectedRows: 3, // JOHN DOE, Bob Johnson, CHARLIE DAVIS should be affected
170+
})
171+
172+
// Verify the data was converted correctly
173+
const { data: projectData } = await api.project({ projectId: newProjectId }).get({
174+
query: { offset: 0, limit: 25 },
175+
})
176+
177+
expect(projectData).toHaveProperty(
178+
'data',
179+
expect.arrayContaining([
180+
expect.objectContaining({ name: 'john doe' }), // 'JOHN DOE' -> 'john doe'
181+
expect.objectContaining({ name: 'jane smith' }), // 'jane smith' -> unchanged (already lowercase)
182+
expect.objectContaining({ name: 'bob johnson' }), // 'Bob Johnson' -> 'bob johnson'
183+
expect.objectContaining({ name: 'alice brown' }), // 'alice brown' -> unchanged (already lowercase)
184+
expect.objectContaining({ name: 'charlie davis' }), // 'CHARLIE DAVIS' -> 'charlie davis'
185+
]),
186+
)
187+
})
188+
189+
test('should return 0 when no rows need conversion', async () => {
190+
// Create a new project for this test to avoid import conflicts
191+
const {
192+
data: newProjectData,
193+
status: newProjectStatus,
194+
error: newProjectError,
195+
} = await api.project.post({
196+
name: 'Test Project for lowercase - already lowercase data',
197+
})
198+
expect(newProjectError).toBeNull()
199+
expect(newProjectStatus).toBe(201)
200+
const newProjectId = (newProjectData as any)!.data!.id as string
201+
202+
// Create test data that's already lowercase
203+
const lowercaseData = [
204+
{ name: 'john doe', email: 'john@example.com', city: 'new york' },
205+
{ name: 'jane smith', email: 'jane@example.com', city: 'los angeles' },
206+
{ name: 'bob johnson', email: 'bob@example.com', city: 'new york' },
207+
]
208+
209+
await Bun.write(tempFilePath, JSON.stringify(lowercaseData))
210+
211+
// Import the lowercase data
212+
const { status, error } = await api.project({ projectId: newProjectId }).import.post({
213+
filePath: tempFilePath,
214+
})
215+
216+
expect(error).toBeNull()
217+
expect(status).toBe(201)
218+
219+
// Perform lowercase conversion
220+
const {
221+
data,
222+
status: lowercaseStatus,
223+
error: lowercaseError,
224+
} = await api.project({ projectId: newProjectId }).lowercase.post({
225+
column: 'name',
226+
})
227+
228+
expect(lowercaseStatus).toBe(200)
229+
expect(lowercaseError).toBeNull()
230+
expect(data).toEqual({
231+
affectedRows: 0, // No rows should be affected as all text is already lowercase
232+
})
233+
})
234+
})

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,14 @@ describe('Project API - Uppercase Conversion', () => {
111111
expect(status).toBe(422)
112112
expect(data).toBeNull()
113113
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-
]))
114+
expect(error).toHaveProperty(
115+
'value',
116+
expect.arrayContaining([
117+
expect.objectContaining({
118+
message: 'Expected string length greater or equal to 1',
119+
path: '/column',
120+
}),
121+
]),
122+
)
120123
})
121124
})

0 commit comments

Comments
 (0)