diff --git a/backend/src/services/replace-operation.service.ts b/backend/src/services/replace-operation.service.ts index 6379a84..bb7bda2 100644 --- a/backend/src/services/replace-operation.service.ts +++ b/backend/src/services/replace-operation.service.ts @@ -18,11 +18,21 @@ export class ReplaceOperationService { async performReplace(params: ReplaceOperationParams): Promise { 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 } @@ -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 { + 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() + } + + /** + * 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 { + 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 { + await this.db.run(`ALTER TABLE "${table}" ALTER "${column}" TYPE ${newType}`) + } } diff --git a/backend/tests/services/replace-operation.alter-table.service.test.ts b/backend/tests/services/replace-operation.alter-table.service.test.ts new file mode 100644 index 0000000..cc67065 --- /dev/null +++ b/backend/tests/services/replace-operation.alter-table.service.test.ts @@ -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) + }, + ) +}) diff --git a/frontend/src/features/data-processing/components/ColumnHeaderMenu.vue b/frontend/src/features/data-processing/components/ColumnHeaderMenu.vue index 368033b..5973a4b 100644 --- a/frontend/src/features/data-processing/components/ColumnHeaderMenu.vue +++ b/frontend/src/features/data-processing/components/ColumnHeaderMenu.vue @@ -5,14 +5,21 @@ const props = defineProps<{ isPrimaryKey: boolean }>() -const { showSuccess } = useErrorHandling() +const emit = defineEmits(['replaceCompleted']) + +const { showSuccess, showWarning } = useErrorHandling() const menu = ref() const isOpen = ref(false) const showReplaceDialog = ref(false) const handleReplaceCompleted = (affectedRows: number) => { - showSuccess(`Replace completed: ${affectedRows} rows affected`) + if (affectedRows === 0) { + showWarning('Replace completed: No rows were affected') + } else { + showSuccess(`Replace completed: ${affectedRows} rows affected`) + emit('replaceCompleted') + } } const menuItems = ref([ diff --git a/frontend/src/features/data-processing/components/DataTabPanel.vue b/frontend/src/features/data-processing/components/DataTabPanel.vue index b24103b..d4a43e6 100644 --- a/frontend/src/features/data-processing/components/DataTabPanel.vue +++ b/frontend/src/features/data-processing/components/DataTabPanel.vue @@ -3,7 +3,7 @@ const projectId = useRouteParams('id') as Ref const projectStore = useProjectStore() const { meta, isLoading, data, columns } = storeToRefs(projectStore) -const { fetchProject, clearProject } = projectStore +const { fetchProject, refreshCurrentPage, clearProject } = projectStore const { processHtml } = useHtml() const totalRecords = computed(() => meta.value.total) @@ -63,6 +63,7 @@ onUnmounted(() => clearProject()) :column-field="col.field" :column-header="col.header" :is-primary-key="col.pk" + @replace-completed="() => refreshCurrentPage(projectId)" /> {{ col.header }} diff --git a/frontend/src/features/data-processing/components/ReplaceDialog.vue b/frontend/src/features/data-processing/components/ReplaceDialog.vue index 0b9c413..eba4e90 100644 --- a/frontend/src/features/data-processing/components/ReplaceDialog.vue +++ b/frontend/src/features/data-processing/components/ReplaceDialog.vue @@ -41,16 +41,10 @@ const handleReplace = async () => { return } - if (!data?.affectedRows) { - showError([{ code: 'NOT_FOUND', message: 'Replace operation failed' }]) - return - } - - if (data?.affectedRows !== undefined && data?.affectedRows !== null) { - emit('replace-completed', data.affectedRows) - } + emit('replace-completed', data.affectedRows) } catch (error) { - console.error('Replace operation failed:', error) + const message = error instanceof Error ? error.message : String(error) + showError([{ code: 'INTERNAL_SERVER_ERROR', message }]) } finally { isLoading.value = false closeDialog() @@ -88,7 +82,12 @@ const handleVisibleChange = (visible: boolean) => { >
- + {
- + { binary :disabled="isLoading" /> - +
@@ -125,7 +134,12 @@ const handleVisibleChange = (visible: boolean) => { binary :disabled="isLoading" /> - +
diff --git a/frontend/src/features/project-management/stores/project.store.ts b/frontend/src/features/project-management/stores/project.store.ts index 2b71e57..eda5cbe 100644 --- a/frontend/src/features/project-management/stores/project.store.ts +++ b/frontend/src/features/project-management/stores/project.store.ts @@ -17,10 +17,18 @@ export const useProjectStore = defineStore('project', () => { const isLoading = ref(false) const columns = ref([]) + // Current pagination state + const currentOffset = ref(0) + const currentLimit = ref(25) + // Actions const fetchProject = async (projectId: string, offset = 0, limit = 25) => { isLoading.value = true + // Store current pagination state + currentOffset.value = offset + currentLimit.value = limit + const { data: rows, error } = await api.project({ projectId }).get({ query: { offset, limit } }) if (error) { @@ -36,6 +44,10 @@ export const useProjectStore = defineStore('project', () => { isLoading.value = false } + const refreshCurrentPage = async (projectId: string) => { + await fetchProject(projectId, currentOffset.value, currentLimit.value) + } + const clearProject = () => { data.value = [] meta.value = { @@ -78,9 +90,12 @@ export const useProjectStore = defineStore('project', () => { isLoading, columns, columnsForSchema, + currentOffset, + currentLimit, // Actions fetchProject, + refreshCurrentPage, clearProject, } }) diff --git a/frontend/src/shared/composables/useErrorHandling.ts b/frontend/src/shared/composables/useErrorHandling.ts index bdd89a6..876d036 100644 --- a/frontend/src/shared/composables/useErrorHandling.ts +++ b/frontend/src/shared/composables/useErrorHandling.ts @@ -30,9 +30,19 @@ export const useErrorHandling = () => { }) } + const showWarning = (text: string): void => { + toast.add({ + severity: 'warn', + summary: 'Warning', + detail: text, + life: 3000, + }) + } + return { showError, showSuccess, showInfo, + showWarning, } }