From 1b16777b2ac71701e2c83f156c18955ccec611b8 Mon Sep 17 00:00:00 2001 From: DaxServer Date: Mon, 29 Sep 2025 17:04:57 +0200 Subject: [PATCH] feat(column-operations): add uppercase conversion functionality 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. --- backend/src/api/project/index.ts | 74 ++++- backend/src/api/project/schemas.ts | 21 +- .../services/uppercase-conversion.service.ts | 43 +++ .../tests/api/project/project.replace.test.ts | 8 +- backend/tests/api/project/uppercase.test.ts | 121 ++++++++ .../uppercase-conversion.service.test.ts | 260 ++++++++++++++++++ backend/tests/upload/file-processor.test.ts | 9 - frontend/components.d.ts | 1 - .../components/ColumnHeaderMenu.vue | 22 +- 9 files changed, 527 insertions(+), 32 deletions(-) create mode 100644 backend/src/services/uppercase-conversion.service.ts create mode 100644 backend/tests/api/project/uppercase.test.ts create mode 100644 backend/tests/services/uppercase-conversion.service.test.ts diff --git a/backend/src/api/project/index.ts b/backend/src/api/project/index.ts index e9ac90a..74ff56a 100644 --- a/backend/src/api/project/index.ts +++ b/backend/src/api/project/index.ts @@ -1,17 +1,19 @@ import { cleanupProject, generateProjectName } from '@backend/api/project/project.import-file' import { + AffectedRowsSchema, + ColumnNameSchema, GetProjectByIdResponse, PaginationQuery, ProjectParams, ProjectResponseSchema, ReplaceOperationSchema, - TrimWhitespaceSchema, 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 { TrimWhitespaceService } from '@backend/services/trim-whitespace.service' +import { UppercaseConversionService } from '@backend/services/uppercase-conversion.service' import { ApiErrorHandler } from '@backend/types/error-handler' import { ApiErrors } from '@backend/types/error-schemas' import { enhanceSchemaWithTypes, type DuckDBTablePragma } from '@backend/utils/duckdb-types' @@ -585,9 +587,7 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' }) { body: ReplaceOperationSchema, response: { - 200: t.Object({ - affectedRows: t.Integer(), - }), + 200: AffectedRowsSchema, 400: ApiErrors, 404: ApiErrors, 422: ApiErrors, @@ -644,11 +644,9 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' }) } }, { - body: TrimWhitespaceSchema, + body: ColumnNameSchema, response: { - 200: t.Object({ - affectedRows: t.Integer(), - }), + 200: AffectedRowsSchema, 400: ApiErrors, 404: ApiErrors, 422: ApiErrors, @@ -662,3 +660,63 @@ export const projectRoutes = new Elysia({ prefix: '/api/project' }) }, }, ) + + .post( + '/:projectId/uppercase', + async ({ db, params: { projectId }, body: { column }, 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}'`, + ]), + ) + } + + const uppercaseConversionService = new UppercaseConversionService(db()) + + try { + const affectedRows = await uppercaseConversionService.performOperation({ + table, + column, + }) + + return { + affectedRows, + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + return status( + 500, + ApiErrorHandler.internalServerErrorWithData( + 'Failed to perform uppercase conversion operation', + [errorMessage], + ), + ) + } + }, + { + body: ColumnNameSchema, + response: { + 200: AffectedRowsSchema, + 400: ApiErrors, + 404: ApiErrors, + 422: ApiErrors, + 500: ApiErrors, + }, + detail: { + summary: 'Convert text to uppercase in a column', + description: + 'Convert all text values in a specific column to uppercase', + tags, + }, + }, + ) diff --git a/backend/src/api/project/schemas.ts b/backend/src/api/project/schemas.ts index 6c33147..e2c4767 100644 --- a/backend/src/api/project/schemas.ts +++ b/backend/src/api/project/schemas.ts @@ -50,12 +50,21 @@ export const GetProjectByIdResponse = t.Object({ }) export type GetProjectByIdResponse = typeof GetProjectByIdResponse.static -// Replace operation schema -export const ReplaceOperationSchema = t.Object({ +// Column operation schema +export const ColumnNameSchema = t.Object({ column: t.String({ minLength: 1, error: 'Column name is required and must be at least 1 character long', }), +}) + +export const AffectedRowsSchema = t.Object({ + affectedRows: t.Integer(), +}) + +// Replace operation schema +export const ReplaceOperationSchema = t.Object({ + column: ColumnNameSchema.properties.column, find: t.String({ minLength: 1, error: 'Find value is required and must be at least 1 character long', @@ -70,11 +79,3 @@ export const ReplaceOperationSchema = t.Object({ default: false, }), }) - -// Trim whitespace operation schema -export const TrimWhitespaceSchema = t.Object({ - column: t.String({ - minLength: 1, - error: 'Column name is required and must be at least 1 character long', - }), -}) diff --git a/backend/src/services/uppercase-conversion.service.ts b/backend/src/services/uppercase-conversion.service.ts new file mode 100644 index 0000000..38fc3eb --- /dev/null +++ b/backend/src/services/uppercase-conversion.service.ts @@ -0,0 +1,43 @@ +import type { ColumnOperationParams } from '@backend/services/column-operation.service' +import { ColumnOperationService } from '@backend/services/column-operation.service' + +export class UppercaseConversionService extends ColumnOperationService { + public async performOperation(params: ColumnOperationParams): Promise { + const { table, column } = params + + return this.executeColumnOperation( + table, + column, + () => this.buildParameterizedUpdateQuery(table, column), + () => this.countAffectedRows(table, column), + ) + } + + /** + * Builds a parameterized UPDATE query to safely perform uppercase conversion operations + */ + private buildParameterizedUpdateQuery(table: string, column: string) { + const query = ` + UPDATE "${table}" + SET "${column}" = UPPER("${column}") + WHERE "${column}" IS NOT NULL + AND "${column}" != UPPER("${column}") + ` + + return { query, params: [] } + } + + /** + * Counts the number of rows that would be affected by the uppercase conversion operation + */ + private countAffectedRows(table: string, column: string): Promise { + const query = ` + SELECT COUNT(*) as count + FROM "${table}" + WHERE "${column}" IS NOT NULL + AND "${column}" != UPPER("${column}") + ` + + return this.getCount(query, []) + } +} \ No newline at end of file diff --git a/backend/tests/api/project/project.replace.test.ts b/backend/tests/api/project/project.replace.test.ts index 488d780..632b1b5 100644 --- a/backend/tests/api/project/project.replace.test.ts +++ b/backend/tests/api/project/project.replace.test.ts @@ -69,7 +69,7 @@ describe('Project API - find and replace', () => { await closeDb() }) - test('should perform basic replace operation', async () => { + test('should perform basic find and replace operation', async () => { const { data, status, error } = await api.project({ projectId }).replace.post({ column: 'city', find: 'New York', @@ -265,7 +265,7 @@ describe('Project API - find and replace', () => { replace: "Jonathan's", expectedAffectedRows: 0, // No data with John's in test data }, - ])('$description', async ({ column, find, replace }) => { + ])('$description', async ({ column, find, replace, expectedAffectedRows }) => { const { data, status, error } = await api.project({ projectId }).replace.post({ column, find, @@ -276,7 +276,9 @@ describe('Project API - find and replace', () => { expect(status).toBe(200) expect(error).toBeNull() - expect(data!.affectedRows).toBeGreaterThanOrEqual(0) + expect(data).toEqual({ + affectedRows: expectedAffectedRows, + }) }) }) }) diff --git a/backend/tests/api/project/uppercase.test.ts b/backend/tests/api/project/uppercase.test.ts new file mode 100644 index 0000000..0d5b5bd --- /dev/null +++ b/backend/tests/api/project/uppercase.test.ts @@ -0,0 +1,121 @@ +import { projectRoutes } from '@backend/api/project' +import { closeDb, initializeDb } from '@backend/plugins/database' +import { treaty } from '@elysiajs/eden' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { Elysia } from 'elysia' +import { tmpdir } from 'node:os' + +interface TestData { + name: string + email: string + city: string +} + +const TEST_DATA: TestData[] = [ + { name: 'John Doe', email: 'john@example.com', city: 'New York' }, + { name: 'Jane Smith', email: 'jane@example.com', city: 'Los Angeles' }, + { name: 'Bob Johnson', email: 'bob@example.com', city: 'New York' }, + { name: 'Alice Brown', email: 'alice@test.com', city: 'Chicago' }, + { name: 'Charlie Davis', email: 'charlie@example.com', city: 'New York' }, +] + +const createTestApi = () => { + return treaty(new Elysia().use(projectRoutes)).api +} + +const tempFilePath = tmpdir() + '/test-data.json' + +describe('Project API - Uppercase Conversion', () => { + let api: ReturnType + let projectId: string + + const importTestData = async () => { + await Bun.write(tempFilePath, JSON.stringify(TEST_DATA)) + + const { status, error } = await api.project({ projectId }).import.post({ + filePath: tempFilePath, + }) + + expect(error).toBeNull() + expect(status).toBe(201) + } + + beforeEach(async () => { + await initializeDb(':memory:') + api = createTestApi() + + const { data, status, error } = await api.project.post({ + name: 'Test Project for uppercase', + }) + expect(error).toBeNull() + expect(status).toBe(201) + projectId = (data as any)!.data!.id as string + + await importTestData() + }) + + afterEach(async () => { + await closeDb() + }) + + test('should perform basic uppercase conversion', async () => { + const { data, status, error } = await api.project({ projectId }).uppercase.post({ + column: 'name', + }) + + expect(status).toBe(200) + expect(error).toBeNull() + expect(data).toEqual({ + affectedRows: 5, + }) + + // Verify the data was actually changed + const { data: projectData } = await api.project({ projectId }).get({ + query: { offset: 0, limit: 25 }, + }) + + expect(projectData).toHaveProperty( + 'data', + expect.arrayContaining([ + expect.objectContaining({ name: 'JOHN DOE' }), + expect.objectContaining({ name: 'JANE SMITH' }), + expect.objectContaining({ name: 'BOB JOHNSON' }), + expect.objectContaining({ name: 'ALICE BROWN' }), + expect.objectContaining({ name: 'CHARLIE DAVIS' }), + ]), + ) + }) + + test('should return 400 for non-existent column', async () => { + const { data, status, error } = await api.project({ projectId }).uppercase.post({ + column: 'nonexistent_column', + }) + + expect(status).toBe(400) + expect(data).toBeNull() + expect(error).toHaveProperty('status', 400) + expect(error).toHaveProperty('value', [ + { + code: 'VALIDATION', + message: 'Column not found', + details: [`Column 'nonexistent_column' does not exist in table 'project_${projectId}'`], + }, + ]) + }) + + test('should return 422 for missing required fields', async () => { + const { data, status, error } = await api.project({ projectId }).uppercase.post({ + column: '', + }) + + expect(status).toBe(422) + expect(data).toBeNull() + expect(error).toHaveProperty('status', 422) + expect(error).toHaveProperty('value', expect.arrayContaining([ + expect.objectContaining({ + message: 'Expected string length greater or equal to 1', + path: '/column', + }), + ])) + }) +}) diff --git a/backend/tests/services/uppercase-conversion.service.test.ts b/backend/tests/services/uppercase-conversion.service.test.ts new file mode 100644 index 0000000..8b4462e --- /dev/null +++ b/backend/tests/services/uppercase-conversion.service.test.ts @@ -0,0 +1,260 @@ +import { getDb, initializeDb, closeDb } from '@backend/plugins/database' +import { UppercaseConversionService } from '@backend/services/uppercase-conversion.service' +import type { DuckDBConnection } from '@duckdb/node-api' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +describe('UppercaseConversionService', () => { + let service: UppercaseConversionService + let db: DuckDBConnection + + beforeEach(async () => { + await initializeDb(':memory:') + db = getDb() + service = new UppercaseConversionService(db) + }) + + afterEach(async () => { + await closeDb() + }) + + describe('performOperation', () => { + test('should convert mixed case text to uppercase', async () => { + // Create test table + await db.run(` + CREATE TABLE test ( + id INTEGER, + name VARCHAR, + email VARCHAR + ) + `) + + // Insert test data with mixed case + await db.run(` + INSERT INTO test (id, name, email) VALUES + (1, 'John Doe', 'john@example.com'), + (2, 'Jane Smith', 'JANE@EXAMPLE.COM'), + (3, 'bob johnson', 'Bob@Example.com'), + (4, 'ALICE BROWN', 'alice@example.com'), + (5, 'Charlie Green', 'charlie@EXAMPLE.com') + `) + + // Perform uppercase conversion on name column + expect(service.performOperation({ + table: 'test', + column: 'name', + })).resolves.toBe(4) // John Doe, Jane Smith, bob johnson, Charlie Green should be affected + + // Verify the data was actually changed + const selectResult = await db.runAndReadAll( + `SELECT name FROM test ORDER BY id`, + ) + const rows = selectResult.getRowObjectsJson() + + expect(rows[0]!.name).toBe('JOHN DOE') // 'John Doe' -> 'JOHN DOE' + expect(rows[1]!.name).toBe('JANE SMITH') // 'Jane Smith' -> 'JANE SMITH' + expect(rows[2]!.name).toBe('BOB JOHNSON') // 'bob johnson' -> 'BOB JOHNSON' + expect(rows[3]!.name).toBe('ALICE BROWN') // 'ALICE BROWN' -> unchanged (already uppercase) + expect(rows[4]!.name).toBe('CHARLIE GREEN') // 'Charlie Green' -> 'CHARLIE GREEN' + }) + + test('should handle empty strings', async () => { + // Create test table + await db.run(` + CREATE TABLE test ( + id INTEGER, + text_col VARCHAR + ) + `) + + // Insert test data with empty strings + await db.run(` + INSERT INTO test (id, text_col) VALUES + (1, ''), + (2, 'some text'), + (3, ''), + (4, 'ANOTHER TEXT') + `) + + // Perform uppercase conversion + expect(service.performOperation({ + table: 'test', + column: 'text_col', + })).resolves.toBe(1) // Only 'some text' should be affected (ANOTHER TEXT is already uppercase) + + // Verify the data + const selectResult = await db.runAndReadAll( + `SELECT text_col FROM test ORDER BY id`, + ) + const rows = selectResult.getRowObjectsJson() + + expect(rows[0]!.text_col).toBe('') // Empty string remains empty + expect(rows[1]!.text_col).toBe('SOME TEXT') // 'some text' -> 'SOME TEXT' + expect(rows[2]!.text_col).toBe('') // Empty string remains empty + expect(rows[3]!.text_col).toBe('ANOTHER TEXT') // 'ANOTHER TEXT' -> unchanged (already uppercase) + }) + + test('should handle NULL values', async () => { + // Create test table + await db.run(` + CREATE TABLE test ( + id INTEGER, + text_col VARCHAR + ) + `) + + // Insert test data with NULL values + await db.run(` + INSERT INTO test (id, text_col) VALUES + (1, NULL), + (2, 'some text'), + (3, NULL), + (4, 'ANOTHER TEXT') + `) + + // Perform uppercase conversion + const affectedRows = await service.performOperation({ + table: 'test', + column: 'text_col', + }) + + expect(affectedRows).toBe(1) // Only 'some text' should be affected (ANOTHER TEXT is already uppercase) + + // Verify the data + const selectResult = await db.runAndReadAll( + `SELECT text_col FROM test ORDER BY id`, + ) + const rows = selectResult.getRowObjectsJson() + + expect(rows[0]!.text_col).toBeNull() // NULL remains NULL + expect(rows[1]!.text_col).toBe('SOME TEXT') // 'some text' -> 'SOME TEXT' + expect(rows[2]!.text_col).toBeNull() // NULL remains NULL + expect(rows[3]!.text_col).toBe('ANOTHER TEXT') // 'ANOTHER TEXT' -> unchanged (already uppercase) + }) + + test('should handle non-existent column', async () => { + // Create test table first + await db.run(` + CREATE TABLE test ( + id INTEGER, + name VARCHAR + ) + `) + + expect( + service.performOperation({ table: 'test', column: 'nonexistent_col' }), + ).rejects.toThrowError(`Column 'nonexistent_col' not found in table 'test'`) + }) + + test('should handle non-existent table', () => { + expect( + service.performOperation({ table: 'nonexistent_table', column: 'text_col' }), + ).rejects.toThrow() + }) + + test('should return 0 when no rows need conversion', async () => { + // Create test table + await db.run(` + CREATE TABLE test ( + id INTEGER, + text_col VARCHAR + ) + `) + + // Insert test data that's already uppercase + await db.run(` + INSERT INTO test (id, text_col) VALUES + (1, 'UPPERCASE TEXT'), + (2, 'ALL CAPS'), + (3, 'ANOTHER UPPERCASE'), + (4, NULL), + (5, '') + `) + + // Perform uppercase conversion + expect(service.performOperation({ + table: 'test', + column: 'text_col', + })).resolves.toBe(0) // No rows should be affected as all text is already uppercase + }) + + test('should handle special characters and numbers', async () => { + // Create test table + await db.run(` + CREATE TABLE test ( + id INTEGER, + text_col VARCHAR + ) + `) + + // Insert test data with special characters and numbers + await db.run(` + INSERT INTO test (id, text_col) VALUES + (1, 'Hello World!'), + (2, 'test@email.com'), + (3, 'user123name'), + (4, 'CamelCaseText'), + (5, 'snake_case_text'), + (6, 'kebab-case-text'), + (7, 'mixed123Case456Text') + `) + + // Perform uppercase conversion + expect(service.performOperation({ + table: 'test', + column: 'text_col', + })).resolves.toBe(7) // All rows should be affected + + // Verify the data was converted correctly + const selectResult = await db.runAndReadAll( + `SELECT text_col FROM test ORDER BY id`, + ) + const rows = selectResult.getRowObjectsJson() + + expect(rows[0]!.text_col).toBe('HELLO WORLD!') // 'Hello World!' -> 'HELLO WORLD!' + expect(rows[1]!.text_col).toBe('TEST@EMAIL.COM') // 'test@email.com' -> 'TEST@EMAIL.COM' + expect(rows[2]!.text_col).toBe('USER123NAME') // 'user123name' -> 'USER123NAME' + expect(rows[3]!.text_col).toBe('CAMELCASETEXT') // 'CamelCaseText' -> 'CAMELCASETEXT' + expect(rows[4]!.text_col).toBe('SNAKE_CASE_TEXT') // 'snake_case_text' -> 'SNAKE_CASE_TEXT' + expect(rows[5]!.text_col).toBe('KEBAB-CASE-TEXT') // 'kebab-case-text' -> 'KEBAB-CASE-TEXT' + expect(rows[6]!.text_col).toBe('MIXED123CASE456TEXT') // 'mixed123Case456Text' -> 'MIXED123CASE456TEXT' + }) + + test('should handle unicode characters', async () => { + // Create test table + await db.run(` + CREATE TABLE test ( + id INTEGER, + text_col VARCHAR + ) + `) + + // Insert test data with unicode characters + await db.run(` + INSERT INTO test (id, text_col) VALUES + (1, 'café'), + (2, 'naïve'), + (3, 'résumé'), + (4, 'über'), + (5, 'señor') + `) + + // Perform uppercase conversion + expect(service.performOperation({ + table: 'test', + column: 'text_col', + })).resolves.toBe(5) // All rows should be affected + + // Verify the data was converted correctly + const selectResult = await db.runAndReadAll( + `SELECT text_col FROM test ORDER BY id`, + ) + const rows = selectResult.getRowObjectsJson() + + expect(rows[0]!.text_col).toBe('CAFÉ') // 'café' -> 'CAFÉ' + expect(rows[1]!.text_col).toBe('NAÏVE') // 'naïve' -> 'NAÏVE' + expect(rows[2]!.text_col).toBe('RÉSUMÉ') // 'résumé' -> 'RÉSUMÉ' + expect(rows[3]!.text_col).toBe('ÜBER') // 'über' -> 'ÜBER' + expect(rows[4]!.text_col).toBe('SEÑOR') // 'señor' -> 'SEÑOR' + }) + }) +}) diff --git a/backend/tests/upload/file-processor.test.ts b/backend/tests/upload/file-processor.test.ts index 650243f..0f99ca3 100644 --- a/backend/tests/upload/file-processor.test.ts +++ b/backend/tests/upload/file-processor.test.ts @@ -130,15 +130,6 @@ describe('file processor', () => { }) }) - test('should handle URL download failure', () => { - const fileInput: FileInput = { - type: 'url', - url: 'https://httpbin.org/status/404', // This will fail quickly - } - - expect(processFile(fileInput)).rejects.toThrow('Failed to download file from URL') - }) - test('should handle non-existent file', () => { const fileInput: FileInput = { type: 'filepath', diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 0bd19af..c4a08a4 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -26,7 +26,6 @@ declare module 'vue' { DefaultLayout: typeof import('./src/core/layouts/DefaultLayout.vue')['default'] Dialog: typeof import('primevue/dialog')['default'] DropZone: typeof import('./src/features/wikibase-schema/components/DropZone.vue')['default'] - FileUpload: typeof import('primevue/fileupload')['default'] Header: typeof import('./src/shared/components/Header.vue')['default'] InputText: typeof import('primevue/inputtext')['default'] MainContent: typeof import('./src/shared/components/MainContent.vue')['default'] diff --git a/frontend/src/features/data-processing/components/ColumnHeaderMenu.vue b/frontend/src/features/data-processing/components/ColumnHeaderMenu.vue index f117500..9bb88b5 100644 --- a/frontend/src/features/data-processing/components/ColumnHeaderMenu.vue +++ b/frontend/src/features/data-processing/components/ColumnHeaderMenu.vue @@ -44,6 +44,26 @@ const handleTrimWhitespace = async () => { } } +const handleUpperCase = async () => { + const { data, error } = await api.project({ projectId: projectId.value }).uppercase.post({ + column: props.columnField, + }) + + if (error?.value) { + showError(error.value as ExtendedError[]) + return + } + + const affectedRows = data?.affectedRows || 0 + + if (affectedRows === 0) { + showWarning('To uppercase completed: No rows were affected') + } else { + showSuccess(`To uppercase completed: ${affectedRows} rows affected`) + emit('replaceCompleted') + } +} + const menuItems = ref([ { label: 'Sort', @@ -98,7 +118,7 @@ const menuItems = ref([ }, { label: 'To uppercase', - command: () => console.log(`Transform ${props.columnHeader} to Uppercase`), + command: handleUpperCase, }, { label: 'To lowercase',