From 0099e1a262cf208f3d58506dc254fc5ac96ff54f Mon Sep 17 00:00:00 2001 From: skyler <1156263951@qq.com> Date: Fri, 15 May 2026 16:46:54 +0800 Subject: [PATCH] feat(ai): add table mentions Add table mentions to AI context, Ask/Agent mode selection, and related UI/i18n fixes. --- src/components/editor/AiAssistant.vue | 347 +++++++++++++++++++++++++- src/components/grid/DataGrid.vue | 278 +++++++++++++++++++-- src/components/layout/ContentArea.vue | 4 +- src/composables/useDataGridEditor.ts | 32 ++- src/i18n/locales/en.ts | 27 +- src/i18n/locales/es.ts | 4 +- src/i18n/locales/zh-CN.ts | 27 +- src/lib/ai.ts | 70 +++++- src/lib/aiTableMentions.ts | 103 ++++++++ src/lib/dataGridSql.ts | 61 +++++ tests/aiTableMentions.test.ts | 28 +++ tests/dataGridEditor.test.ts | 162 ++++++++++++ tests/dataGridSql.test.ts | 56 +++++ 13 files changed, 1150 insertions(+), 49 deletions(-) create mode 100644 src/lib/aiTableMentions.ts create mode 100644 tests/aiTableMentions.test.ts create mode 100644 tests/dataGridEditor.test.ts diff --git a/src/components/editor/AiAssistant.vue b/src/components/editor/AiAssistant.vue index 2585b80792..7f0ab159f1 100644 --- a/src/components/editor/AiAssistant.vue +++ b/src/components/editor/AiAssistant.vue @@ -1,5 +1,5 @@ @@ -2581,9 +2706,9 @@ defineExpose({ - +
- - {{ tableMeta?.tableName }} DDL - -
-
- +
+
+ +
+ + + + + + + + + + + + + + + +
{{ t("grid.columnName") }}{{ t("grid.columnType") }}{{ t("grid.tableInfoNullable") }}
+ + + {{ column.name }} + +
+ {{ column.comment }} +
+
{{ column.data_type }}{{ column.is_nullable ? "YES" : "NO" }}
+
+ +
+
+ +
+
{{ indexesError }}
+
+ {{ t("grid.tableInfoEmpty") }} +
+
+
+
{{ index.name }}
+
+ PK + UNIQUE + {{ + index.index_type + }} +
+
+ {{ index.columns.join(", ") }} +
+
+
+
+ +
+
+ +
+
{{ foreignKeysError }}
+
+ {{ t("grid.tableInfoEmpty") }} +
+
+
+
{{ fk.name }}
+
+ {{ fk.column }} -> {{ fk.ref_table }}.{{ fk.ref_column }} +
+
+
+
+ +
+
+ +
+
{{ triggersError }}
+
+ {{ t("grid.tableInfoEmpty") }} +
+
+
+
{{ trigger.name }}
+
{{ trigger.timing }} {{ trigger.event }}
+
+
+
+

+              
+ +
import { computed, ref, defineAsyncComponent } from "vue"; import { useI18n } from "vue-i18n"; -import { Loader2, Square, Bot, Table2, GitBranch, BarChart3, Code2 } from "lucide-vue-next"; +import { Loader2, Square, Bot, Table2, GitBranch, BarChart3, TableProperties } from "lucide-vue-next"; import { Splitpanes, Pane } from "splitpanes"; import "splitpanes/dist/splitpanes.css"; import { Button } from "@/components/ui/button"; @@ -382,7 +382,7 @@ defineExpose({ focusSearch }); :class="{ 'bg-accent': dataGridRef?.showDdl }" @click="dataGridRef?.toggleDdl()" > - DDL + {{ t("grid.tableInfo") }}
[column.name.toLowerCase(), column]), + ); + return item.data.map((val, i) => { + const columnName = result.value.columns[i]; + const columnInfo = columnInfoByName.get(columnName.toLowerCase()); + return shouldClearClonedColumn(columnName, columnInfo) ? null : val; + }); + } + + function shouldClearClonedColumn(columnName: string, columnInfo: ColumnInfo | undefined): boolean { + if (databaseType.value === "oracle" && columnName.toUpperCase() === DBX_ROWID_COLUMN) return true; + if (databaseType.value === "neo4j" && columnName === DBX_NEO4J_ELEMENT_ID_COLUMN) return true; + const extra = columnInfo?.extra ?? ""; + const columnDefault = columnInfo?.column_default ?? ""; + return ( + /\b(auto_increment|autoincrement|identity|generated)\b/i.test(extra) || /\bnextval\s*\(/i.test(columnDefault) + ); + } + function cloneRow(rowId: number) { const item = getRowItem(rowId); if (!item) return; - const primaryKeys = tableMeta.value?.primaryKeys ?? []; - const columns = result.value.columns; - const clonedData = item.data.map((val, i) => (primaryKeys.includes(columns[i]) ? null : val)); + const clonedData = clonedRowData(item); rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value); newRows.value.push(clonedData); if (useTransaction.value && !transactionActive.value) { @@ -391,13 +411,11 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } function cloneRows(rowIds: number[]) { - const primaryKeys = tableMeta.value?.primaryKeys ?? []; - const columns = result.value.columns; rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value); for (const rowId of rowIds) { const item = getRowItem(rowId); if (!item) continue; - const clonedData = item.data.map((val, i) => (primaryKeys.includes(columns[i]) ? null : val)); + const clonedData = clonedRowData(item); newRows.value.push(clonedData); } if (useTransaction.value && !transactionActive.value) { @@ -559,7 +577,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { const validationError = stmtOptions ? validateDataGridSave({ databaseType: databaseType.value, + tableMeta: tableMeta.value, columns: stmtOptions.columns, + rows: stmtOptions.rows, columnInfo: tableMeta.value?.columns, dirtyRows: stmtOptions.dirtyRows, newRows: stmtOptions.newRows, diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b7aa9fb23f..94531b8f51 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -294,10 +294,10 @@ export default { discard: "Discard", dismiss: "Dismiss", addRow: "Add Row", - cloneRow: "Clone Row", + cloneRow: "Clone as New Row", deleteRow: "Delete Row", deleteRows: "Delete {count} Rows", - cloneRows: "Clone {count} Rows", + cloneRows: "Clone {count} as New Rows", restoreRows: "Restore {count} Rows", copyRows: "Copy {count} Rows (JSON)", copyRowsInsert: "Copy {count} Rows as INSERT", @@ -327,6 +327,13 @@ export default { transpose: "Transpose Row", rowsPerPageShort: " rows", columnDetails: "Column Details", + tableInfo: "Table Info", + tableInfoColumns: "Columns", + tableInfoIndexes: "Indexes", + tableInfoForeignKeys: "Foreign Keys", + tableInfoTriggers: "Triggers", + tableInfoNullable: "Nullable", + tableInfoEmpty: "No metadata", queryError: "Query Error", refresh: "Refresh", commit: "Commit", @@ -498,6 +505,22 @@ export default { thinking: "Thinking...", reasoningProcess: "Thinking process", stopGenerating: "Stop generating", + tableMentionEmpty: "No matching tables", + tableMentionPlaceholderHint: "Type {'@'} to mention tables for context", + tableMentionTypes: { + table: "Table", + view: "View", + systemTable: "System table", + temporaryTable: "Temporary table", + }, + modes: { + ask: "Ask", + agent: "Agent", + }, + modeHints: { + ask: "Generate SQL and explanations without auto-running them", + agent: "Auto-run safe queries when possible", + }, fixWithAi: "Fix with AI", truncated: "Context truncated", contextSummary: "{database} · {tables} tables", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index fee8a09705..1befb41d13 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -295,10 +295,10 @@ export default { discard: "Descartar", dismiss: "Cerrar", addRow: "Agregar fila", - cloneRow: "Clonar fila", + cloneRow: "Clonar como fila nueva", deleteRow: "Eliminar fila", deleteRows: "Eliminar {count} filas", - cloneRows: "Clonar {count} filas", + cloneRows: "Clonar {count} como filas nuevas", restoreRows: "Restaurar {count} filas", copyRows: "Copiar {count} filas (JSON)", copyRowsInsert: "Copiar {count} filas como INSERT", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 44a220666e..e7788d613b 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -291,10 +291,10 @@ export default { discard: "放弃", dismiss: "关闭", addRow: "新增行", - cloneRow: "克隆行", + cloneRow: "克隆为新行", deleteRow: "删除行", deleteRows: "删除 {count} 行", - cloneRows: "克隆 {count} 行", + cloneRows: "克隆 {count} 行为新行", restoreRows: "恢复 {count} 行", copyRows: "复制 {count} 行 (JSON)", copyRowsInsert: "复制 {count} 行为 INSERT", @@ -324,6 +324,13 @@ export default { transpose: "转置查看", rowsPerPageShort: " 行/页", columnDetails: "列详情", + tableInfo: "表属性", + tableInfoColumns: "字段", + tableInfoIndexes: "索引", + tableInfoForeignKeys: "外键", + tableInfoTriggers: "触发器", + tableInfoNullable: "可空", + tableInfoEmpty: "暂无元数据", queryError: "查询出错", refresh: "刷新", commit: "提交", @@ -487,6 +494,22 @@ export default { thinking: "思考中...", reasoningProcess: "思考过程", stopGenerating: "停止生成", + tableMentionEmpty: "没有匹配的表", + tableMentionPlaceholderHint: "输入 {'@'} 可选择表并带入上下文", + tableMentionTypes: { + table: "表", + view: "视图", + systemTable: "系统表", + temporaryTable: "临时表", + }, + modes: { + ask: "Ask", + agent: "Agent", + }, + modeHints: { + ask: "只生成 SQL 和说明,不自动执行", + agent: "可自动执行安全查询", + }, fixWithAi: "用 AI 修复", truncated: "上下文已截断", contextSummary: "{database} · {tables} 张表", diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 34a6755a44..6ffc649d2a 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -11,6 +11,8 @@ import type { } from "@/types/database"; import * as api from "@/lib/api"; import { currentLocale } from "@/i18n"; +import { aiTableMentionKey, type AiTableMention } from "@/lib/aiTableMentions"; +import { isSchemaAware } from "@/lib/databaseCapabilities"; export type AiAction = "generate" | "explain" | "optimize" | "fix" | "convert" | "sampleData"; @@ -169,6 +171,9 @@ export function buildSystemPrompt(action: AiAction, context: AiContext): string ? "当用户要求分析或查看某个表时,生成 SELECT 查询获取数据,而不是查询元数据。" : "When the user asks to 'analyze' or 'look at' a table, generate a SELECT query to retrieve data, not a metadata query.", isZh ? "不要编造 Schema 中不存在的表或列。" : "Never invent tables or columns that are not in the schema context.", + isZh + ? "用户输入中的 @schema.table 或 @table 表示用户明确提到的表;这些表已优先放入 Schema 上下文。" + : "User input may contain @schema.table or @table mentions. Treat them as explicit table references; mentioned tables are prioritized in the schema context.", isZh ? "对于 DROP、DELETE、TRUNCATE、ALTER 或没有 WHERE 的 UPDATE,简要警告并优先提供安全的 SELECT 预览。" : "For destructive statements (DROP, DELETE, TRUNCATE, ALTER, UPDATE without WHERE), warn briefly and prefer a safer SELECT preview.", @@ -255,11 +260,12 @@ function formatSchema(context: AiContext): string { export async function buildAiContext( tab: QueryTab, connection: ConnectionConfig, - options: { maxTables?: number; maxColumnsPerTable?: number } = {}, + options: { maxTables?: number; maxColumnsPerTable?: number; mentionedTables?: AiTableMention[] } = {}, ): Promise { const maxTables = options.maxTables ?? 50; const maxColumnsPerTable = options.maxColumnsPerTable ?? 40; const tables: AiSchemaTable[] = []; + const tableKeys = new Set(); let truncated = false; if (tab.tableMeta) { @@ -277,8 +283,20 @@ export async function buildAiContext( indexes, foreignKeys, }); + tableKeys.add(aiTableMentionKey(tab.tableMeta.schema, tName)); truncated = tab.tableMeta.columns.length > maxColumnsPerTable; - } else if (!["redis", "mongodb"].includes(connection.db_type)) { + } + + for (const mention of options.mentionedTables ?? []) { + const key = aiTableMentionKey(mention.schema, mention.table); + if (tableKeys.has(key)) continue; + const entry = await loadMentionedTableContext(tab, connection, mention, maxColumnsPerTable).catch(() => undefined); + if (!entry) continue; + tableKeys.add(aiTableMentionKey(entry.schema, entry.name)); + tables.push(entry); + } + + if (!tab.tableMeta && !["redis", "mongodb"].includes(connection.db_type)) { try { const schemas = await loadCandidateSchemas(tab, connection); for (const schema of schemas) { @@ -295,7 +313,7 @@ export async function buildAiContext( .listForeignKeys(tab.connectionId, tab.database, schema, table.name) .catch(() => [] as ForeignKeyInfo[]), ]).then(([columns, indexes, foreignKeys]) => ({ - schema: schema === tab.database && connection.db_type !== "postgres" ? undefined : schema, + schema: schema === tab.database && !isSchemaAware(connection.db_type) ? undefined : schema, name: table.name, tableType: table.table_type, columns: columns.slice(0, maxColumnsPerTable), @@ -309,6 +327,9 @@ export async function buildAiContext( for (const meta of metaResults) { if (meta._truncatedCols) truncated = true; const { _truncatedCols, ...entry } = meta; + const key = aiTableMentionKey(entry.schema, entry.name); + if (tableKeys.has(key)) continue; + tableKeys.add(key); tables.push(entry); } if (tables.length >= maxTables) break; @@ -330,8 +351,49 @@ export async function buildAiContext( }; } +async function loadMentionedTableContext( + tab: QueryTab, + connection: ConnectionConfig, + mention: AiTableMention, + maxColumnsPerTable: number, +): Promise { + const schema = await resolveMentionedTableSchema(tab, connection, mention); + const [columns, indexes, foreignKeys] = await Promise.all([ + api.getColumns(tab.connectionId, tab.database, schema, mention.table), + api.listIndexes(tab.connectionId, tab.database, schema, mention.table).catch(() => [] as IndexInfo[]), + api.listForeignKeys(tab.connectionId, tab.database, schema, mention.table).catch(() => [] as ForeignKeyInfo[]), + ]); + return { + schema: schema === tab.database && !isSchemaAware(connection.db_type) ? undefined : schema, + name: mention.table, + tableType: "TABLE", + columns: columns.slice(0, maxColumnsPerTable), + indexes, + foreignKeys, + }; +} + +async function resolveMentionedTableSchema( + tab: QueryTab, + connection: ConnectionConfig, + mention: AiTableMention, +): Promise { + if (mention.schema) return mention.schema; + if (tab.tableMeta?.tableName.toLowerCase() === mention.table.toLowerCase() && tab.tableMeta.schema) { + return tab.tableMeta.schema; + } + if (isSchemaAware(connection.db_type)) { + const schemas = await loadCandidateSchemas(tab, connection); + for (const schema of schemas) { + const tables = await api.listTables(tab.connectionId, tab.database, schema, mention.table, 10).catch(() => []); + if (tables.some((table) => table.name.toLowerCase() === mention.table.toLowerCase())) return schema; + } + } + return tab.database || connection.database || "main"; +} + async function loadCandidateSchemas(tab: QueryTab, connection: ConnectionConfig): Promise { - if (connection.db_type === "postgres" || connection.db_type === "sqlserver") { + if (isSchemaAware(connection.db_type)) { const schemas = await api.listSchemas(tab.connectionId, tab.database); return prioritizeSchemas(schemas); } diff --git a/src/lib/aiTableMentions.ts b/src/lib/aiTableMentions.ts new file mode 100644 index 0000000000..072c6597ba --- /dev/null +++ b/src/lib/aiTableMentions.ts @@ -0,0 +1,103 @@ +export interface AiTableMention { + raw: string; + schema?: string; + table: string; +} + +const SIMPLE_IDENTIFIER_RE = /^[\p{L}_][\p{L}\p{N}_$]*$/u; + +function isMentionBoundary(char: string | undefined): boolean { + return !char || /\s|[([{,;:]/.test(char); +} + +function isUnquotedMentionChar(char: string): boolean { + return /[\p{L}\p{N}_$.-]/u.test(char); +} + +function readQuotedSegment(source: string, start: number): { value: string; end: number } | null { + const quote = source[start]; + if (quote !== '"' && quote !== "`") return null; + let value = ""; + for (let i = start + 1; i < source.length; i++) { + const char = source[i]; + if (char === quote) { + if (source[i + 1] === quote) { + value += quote; + i++; + continue; + } + return { value, end: i + 1 }; + } + value += char; + } + return null; +} + +function readMentionToken(source: string, start: number): { raw: string; parts: string[]; end: number } | null { + let i = start; + const parts: string[] = []; + let raw = ""; + + while (i < source.length) { + const quoted = readQuotedSegment(source, i); + if (quoted) { + parts.push(quoted.value); + raw += source.slice(i, quoted.end); + i = quoted.end; + } else { + let value = ""; + const valueStart = i; + while (i < source.length && isUnquotedMentionChar(source[i]) && source[i] !== ".") { + value += source[i]; + i++; + } + if (!value) break; + parts.push(value); + raw += source.slice(valueStart, i); + } + + if (source[i] !== ".") break; + raw += "."; + i++; + } + + const usableParts = parts.filter(Boolean); + if (!usableParts.length) return null; + return { raw, parts: usableParts, end: i }; +} + +export function parseAiTableMentions(text: string): AiTableMention[] { + const mentions: AiTableMention[] = []; + const seen = new Set(); + + for (let i = 0; i < text.length; i++) { + if (text[i] !== "@" || !isMentionBoundary(text[i - 1])) continue; + const token = readMentionToken(text, i + 1); + if (!token) continue; + + const table = token.parts[token.parts.length - 1]; + const schema = token.parts.length > 1 ? token.parts[token.parts.length - 2] : undefined; + const key = `${schema || ""}.${table}`.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + mentions.push({ raw: `@${token.raw}`, schema, table }); + } + i = token.end - 1; + } + + return mentions; +} + +export function formatAiTableMention(schema: string | undefined, table: string): string { + const parts = [schema, table].filter((part): part is string => !!part); + return `@${parts.map(formatMentionPart).join(".")}`; +} + +function formatMentionPart(part: string): string { + if (SIMPLE_IDENTIFIER_RE.test(part)) return part; + return `"${part.replace(/"/g, '""')}"`; +} + +export function aiTableMentionKey(schema: string | undefined, table: string): string { + return `${schema || ""}.${table}`.toLowerCase(); +} diff --git a/src/lib/dataGridSql.ts b/src/lib/dataGridSql.ts index 69a1a23d92..e24c776649 100644 --- a/src/lib/dataGridSql.ts +++ b/src/lib/dataGridSql.ts @@ -29,13 +29,18 @@ export interface DataGridSaveStatementOptions { export interface DataGridSaveValidationOptions { databaseType?: DatabaseType; + tableMeta?: DataGridTableMeta; columns: string[]; + rows?: GridCellValue[][]; columnInfo?: DataGridColumnInfo[]; dirtyRows: Array<[number, Array<[number, GridCellValue]>]>; newRows: GridCellValue[][]; } export function validateDataGridSave(options: DataGridSaveValidationOptions): string | undefined { + const duplicateKeyError = validateInsertedPrimaryKeys(options); + if (duplicateKeyError) return duplicateKeyError; + const notNullColumns = new Set( (options.columnInfo ?? []) .filter( @@ -69,6 +74,32 @@ export function validateDataGridSave(options: DataGridSaveValidationOptions): st return undefined; } +function validateInsertedPrimaryKeys(options: DataGridSaveValidationOptions): string | undefined { + const primaryKeys = options.tableMeta?.primaryKeys ?? []; + if (primaryKeys.length === 0 || options.newRows.length === 0) return undefined; + + const primaryKeyIndexes = primaryKeys.map((primaryKey) => findColumnIndex(options.columns, primaryKey)); + if (primaryKeyIndexes.some((index) => index === -1)) return undefined; + + const existingKeys = new Set(); + for (const row of options.rows ?? []) { + const key = primaryKeyValueKey(primaryKeyIndexes, row); + if (key) existingKeys.add(key); + } + + const newKeys = new Set(); + for (const row of options.newRows) { + const key = primaryKeyValueKey(primaryKeyIndexes, row); + if (!key) continue; + if (existingKeys.has(key) || newKeys.has(key)) { + return duplicatePrimaryKeyError(primaryKeys, primaryKeyIndexes, row, existingKeys.has(key)); + } + newKeys.add(key); + } + + return undefined; +} + export function buildDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] { if (options.databaseType === "neo4j") return buildNeo4jDataGridSaveStatements(options); @@ -252,6 +283,36 @@ function isNullWriteToNotNullColumn( return value === null && notNullColumns.has(normalizeColumnName(column)); } +function findColumnIndex(columns: string[], target: string): number { + const normalizedTarget = normalizeColumnName(target); + return columns.findIndex((column) => normalizeColumnName(column) === normalizedTarget); +} + +function primaryKeyValueKey(primaryKeyIndexes: number[], row: GridCellValue[]): string | undefined { + const values = primaryKeyIndexes.map((index) => row[index]); + if (values.some((value) => value === null || value === undefined)) return undefined; + return JSON.stringify(values); +} + +function duplicatePrimaryKeyError( + primaryKeys: string[], + primaryKeyIndexes: number[], + row: GridCellValue[], + matchesExistingRow: boolean, +): string { + const keySummary = primaryKeys + .map((primaryKey, index) => `${primaryKey} = ${formatKeyValueForMessage(row[primaryKeyIndexes[index]])}`) + .join(", "); + const source = matchesExistingRow ? "the existing primary key" : "another new row's primary key"; + return `New row duplicates ${source} (${keySummary}). Change the key before saving.`; +} + +function formatKeyValueForMessage(value: GridCellValue): string { + if (value === null || value === undefined) return "NULL"; + if (typeof value === "string") return `"${value.replace(/"/g, '\\"')}"`; + return String(value); +} + function normalizeColumnName(name: string): string { return name.toUpperCase(); } diff --git a/tests/aiTableMentions.test.ts b/tests/aiTableMentions.test.ts new file mode 100644 index 0000000000..f7985615d6 --- /dev/null +++ b/tests/aiTableMentions.test.ts @@ -0,0 +1,28 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { formatAiTableMention, parseAiTableMentions } from "../src/lib/aiTableMentions.ts"; + +test("parses simple and schema-qualified AI table mentions", () => { + assert.deepEqual(parseAiTableMentions("show @users and join @public.orders"), [ + { raw: "@users", schema: undefined, table: "users" }, + { raw: "@public.orders", schema: "public", table: "orders" }, + ]); +}); + +test("parses quoted AI table mentions with spaces and dots", () => { + assert.deepEqual(parseAiTableMentions('compare @"sales schema"."order.items" with @"audit log"'), [ + { raw: '@"sales schema"."order.items"', schema: "sales schema", table: "order.items" }, + { raw: '@"audit log"', schema: undefined, table: "audit log" }, + ]); +}); + +test("deduplicates AI table mentions case-insensitively", () => { + assert.deepEqual(parseAiTableMentions("check @public.Users then @PUBLIC.users"), [ + { raw: "@public.Users", schema: "public", table: "Users" }, + ]); +}); + +test("formats AI table mentions with quotes only when needed", () => { + assert.equal(formatAiTableMention("public", "users"), "@public.users"); + assert.equal(formatAiTableMention("sales schema", "order items"), '@"sales schema"."order items"'); +}); diff --git a/tests/dataGridEditor.test.ts b/tests/dataGridEditor.test.ts new file mode 100644 index 0000000000..3fa7dae319 --- /dev/null +++ b/tests/dataGridEditor.test.ts @@ -0,0 +1,162 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { computed, nextTick, ref } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { useDataGridEditor } from "../src/composables/useDataGridEditor.ts"; +import type { ColumnInfo } from "../src/types/database.ts"; + +type CellValue = string | number | boolean | null; + +function installBrowserTestGlobals() { + globalThis.document = { querySelector: () => null } as unknown as Document; + globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + key: () => null, + length: 0, + }; +} + +function column(name: string, isPrimaryKey = false, extra: string | null = null): ColumnInfo { + return { + name, + data_type: "VARCHAR", + is_nullable: true, + column_default: null, + is_primary_key: isPrimaryKey, + extra, + }; +} + +test("cloning a row copies non-generated primary key values without executing save", async () => { + setActivePinia(createPinia()); + installBrowserTestGlobals(); + + const result = computed(() => ({ + columns: ["code", "year", "score"], + rows: [["AFW", 1995, 35271.907090628745] as CellValue[]], + })); + const rowStatusFilter = ref<"all" | "changed" | "edited" | "new" | "deleted">("all"); + let saveCalls = 0; + let editor: ReturnType; + + editor = useDataGridEditor({ + result, + editable: computed(() => true), + databaseType: computed(() => "postgres"), + connectionId: computed(() => undefined), + database: computed(() => undefined), + tableMeta: computed(() => ({ + tableName: "metrics", + columns: [column("code", true), column("year", true), column("score")], + primaryKeys: ["code", "year"], + })), + onExecuteSql: computed(() => undefined), + customSave: computed(() => async () => { + saveCalls += 1; + }), + sql: computed(() => undefined), + searchText: ref(""), + whereFilterInput: ref(""), + orderByInput: ref(""), + rowStatusFilter, + pageSize: ref(100), + currentPage: ref(1), + getRowItem: (rowId) => { + if (rowId === 0) { + return { + id: 0, + sourceIndex: 0, + data: result.value.rows[0], + isNew: false, + isDeleted: false, + isDirtyCol: [false, false, false], + status: "clean", + }; + } + if (rowId < 0) { + const newIndex = -rowId - 1; + const row = editor.newRows.value[newIndex]; + if (!row) return undefined; + return { + id: rowId, + newIndex, + data: row, + isNew: true, + isDeleted: false, + isDirtyCol: [false, false, false], + status: "new", + }; + } + return undefined; + }, + emit: () => {}, + }); + + editor.cloneRow(0); + await nextTick(); + + assert.equal(saveCalls, 0); + assert.deepEqual(editor.newRows.value, [["AFW", 1995, 35271.907090628745]]); + assert.equal(editor.transactionActive.value, true); + assert.deepEqual(editor.editingCell.value, { rowId: -1, col: 0 }); + + await editor.saveChanges(); + + assert.equal(saveCalls, 1); + assert.deepEqual(editor.newRows.value, []); +}); + +test("cloning a row clears auto-generated key columns", async () => { + setActivePinia(createPinia()); + installBrowserTestGlobals(); + + const result = computed(() => ({ + columns: ["id", "name"], + rows: [[1, "Ada"] as CellValue[]], + })); + const rowStatusFilter = ref<"all" | "changed" | "edited" | "new" | "deleted">("all"); + let editor: ReturnType; + + editor = useDataGridEditor({ + result, + editable: computed(() => true), + databaseType: computed(() => "mysql"), + connectionId: computed(() => undefined), + database: computed(() => undefined), + tableMeta: computed(() => ({ + tableName: "people", + columns: [column("id", true, "auto_increment"), column("name")], + primaryKeys: ["id"], + })), + onExecuteSql: computed(() => undefined), + customSave: computed(() => undefined), + sql: computed(() => undefined), + searchText: ref(""), + whereFilterInput: ref(""), + orderByInput: ref(""), + rowStatusFilter, + pageSize: ref(100), + currentPage: ref(1), + getRowItem: (rowId) => { + if (rowId !== 0) return undefined; + return { + id: 0, + sourceIndex: 0, + data: result.value.rows[0], + isNew: false, + isDeleted: false, + isDirtyCol: [false, false], + status: "clean", + }; + }, + emit: () => {}, + }); + + editor.cloneRow(0); + await nextTick(); + + assert.deepEqual(editor.newRows.value, [[null, "Ada"]]); +}); diff --git a/tests/dataGridSql.test.ts b/tests/dataGridSql.test.ts index f3078febc0..ae96a5e6f3 100644 --- a/tests/dataGridSql.test.ts +++ b/tests/dataGridSql.test.ts @@ -232,7 +232,12 @@ test("rejects NULL writes to non-null table columns", () => { test("allows NULL for MySQL auto increment columns when inserting rows", () => { const error = validateDataGridSave({ databaseType: "mysql", + tableMeta: { + tableName: "people", + primaryKeys: ["id"], + }, columns: ["id", "name"], + rows: [], columnInfo: [ { name: "id", is_nullable: false, column_default: null, is_primary_key: true, extra: "auto_increment" }, { name: "name", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, @@ -243,3 +248,54 @@ test("allows NULL for MySQL auto increment columns when inserting rows", () => { assert.equal(error, undefined); }); + +test("rejects inserted rows that duplicate existing primary key values", () => { + const error = validateDataGridSave({ + databaseType: "postgres", + tableMeta: { + tableName: "education_data", + primaryKeys: ["country_code", "year"], + }, + columns: ["country_code", "year", "value"], + rows: [["ALB", 2021, 0.812]], + columnInfo: [ + { name: "country_code", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "year", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "value", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ], + dirtyRows: [], + newRows: [["ALB", 2021, 0.812]], + }); + + assert.equal( + error, + 'New row duplicates the existing primary key (country_code = "ALB", year = 2021). Change the key before saving.', + ); +}); + +test("rejects inserted rows that duplicate another new row primary key", () => { + const error = validateDataGridSave({ + databaseType: "postgres", + tableMeta: { + tableName: "education_data", + primaryKeys: ["country_code", "year"], + }, + columns: ["country_code", "year", "value"], + rows: [], + columnInfo: [ + { name: "country_code", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "year", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "value", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ], + dirtyRows: [], + newRows: [ + ["ALB", 2021, 0.812], + ["ALB", 2021, 0.913], + ], + }); + + assert.equal( + error, + 'New row duplicates another new row\'s primary key (country_code = "ALB", year = 2021). Change the key before saving.', + ); +});