diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 7ac344cb3d..0d16aabede 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -109,6 +109,8 @@ import { structureColumnNamesForCopy, tableStructureIdentifierComparisonKey, toColumnNames, + copySourceColumnDetails, + matchesCopySourceColumnSearch, } from "@/lib/table/tableStructureEditorState"; import { CREATE_DATABASE_CHARSET_OPTIONS, createDatabaseCollationOptionsForCharset, fallbackCreateDatabaseCharsetMetadata, normalizeCreateDatabaseCharsetKey, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions"; import type { CreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions"; @@ -2501,14 +2503,14 @@ const copyableSourceColumns = computed(() => { const existingNames = new Set(columns.value.filter((column) => !column.markedForDrop).map((column) => tableStructureIdentifierComparisonKey(column.name, databaseType.value, databaseInfo))); return copySourceColumns.value.map((column) => ({ column, + details: copySourceColumnDetails(column, databaseType.value), alreadyExists: existingNames.has(tableStructureIdentifierComparisonKey(column.name, databaseType.value, databaseInfo)), })); }); const filteredCopyableSourceColumns = computed(() => { - const search = normalizedColumnSearch(copySourceColumnSearch.value); - if (!search) return copyableSourceColumns.value; - return copyableSourceColumns.value.filter(({ column }) => [column.name, column.data_type, column.comment ?? ""].some((value) => normalizedColumnSearch(value).includes(search))); + if (!normalizedColumnSearch(copySourceColumnSearch.value)) return copyableSourceColumns.value; + return copyableSourceColumns.value.filter(({ column }) => matchesCopySourceColumnSearch(column, copySourceColumnSearch.value, databaseType.value)); }); const copyableSourceColumnNames = computed(() => copyableSourceColumns.value.filter(({ alreadyExists }) => !alreadyExists).map(({ column }) => column.name)); @@ -5441,10 +5443,18 @@ watch( {{ t("structureEditor.copyColumnsNoMatchingFields") }}
-
diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts index 6beaa3ef1e..d13eb728b4 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts @@ -4,6 +4,7 @@ import { cloneColumnDraftAsNew, combineDataTypeForDatabase, combineDataTypeForDatabaseWithLengthUnit, + copySourceColumnDetails, createCopiedColumnDrafts, createColumnDrafts, createTriggerDrafts, @@ -20,6 +21,7 @@ import { isMysqlCharacterDataType, isMysqlEnumDataType, isSqlServerIdentityCompatibleDataType, + matchesCopySourceColumnSearch, mysqlEnumDataType, parseExtraToColumnExtra, rehydrateColumnDraftsFromMetadata, @@ -706,6 +708,54 @@ describe("tableStructureEditorState", () => { }); }); +describe("copySourceColumnDetails", () => { + it("keeps the comment and default shown in the copy-fields dialog", () => { + expect(copySourceColumnDetails({ data_type: "varchar(20)", column_default: "'unknown'", comment: "名称" })).toEqual({ defaultValue: "'unknown'", comment: "名称" }); + }); + + it("keeps falsy-looking defaults such as 0 and empty strings", () => { + expect(copySourceColumnDetails({ data_type: "int", column_default: "0", comment: null })).toEqual({ defaultValue: "0", comment: null }); + expect(copySourceColumnDetails({ data_type: "varchar(20)", column_default: "''", comment: null })).toEqual({ defaultValue: "''", comment: null }); + }); + + it("drops blank metadata instead of rendering an empty label", () => { + expect(copySourceColumnDetails({ data_type: "int", column_default: null, comment: null })).toEqual({ defaultValue: null, comment: null }); + expect(copySourceColumnDetails({ data_type: "int", column_default: undefined, comment: undefined })).toEqual({ defaultValue: null, comment: null }); + expect(copySourceColumnDetails({ data_type: "int", column_default: " ", comment: " " })).toEqual({ defaultValue: null, comment: null }); + }); + + it("trims padded metadata for display", () => { + expect(copySourceColumnDetails({ data_type: "int", column_default: " 1 ", comment: " 备注 " })).toEqual({ defaultValue: "1", comment: "备注" }); + }); + + it("normalizes defaults per database like the editor grid", () => { + expect(copySourceColumnDetails({ data_type: "character varying", column_default: "'unknown'::character varying", comment: null }, "postgres")).toEqual({ defaultValue: "'unknown'", comment: null }); + expect(copySourceColumnDetails({ data_type: "int", column_default: "((0))", comment: null }, "sqlserver")).toEqual({ defaultValue: "0", comment: null }); + expect(copySourceColumnDetails({ data_type: "varchar(50)", column_default: "", comment: null }, "mysql")).toEqual({ defaultValue: "''", comment: null }); + }); +}); + +describe("matchesCopySourceColumnSearch", () => { + const column = { name: "status", data_type: "tinyint", column_default: "1", comment: "状态:1启用 0停用" }; + + it("matches everything for a blank query", () => { + expect(matchesCopySourceColumnSearch(column, "")).toBe(true); + expect(matchesCopySourceColumnSearch(column, " ")).toBe(true); + }); + + it("matches name and type case-insensitively", () => { + expect(matchesCopySourceColumnSearch(column, "STAT")).toBe(true); + expect(matchesCopySourceColumnSearch(column, "TINY")).toBe(true); + expect(matchesCopySourceColumnSearch(column, "missing")).toBe(false); + }); + + it("matches comments and default values too", () => { + expect(matchesCopySourceColumnSearch(column, "启用")).toBe(true); + expect(matchesCopySourceColumnSearch(column, "1")).toBe(true); + expect(matchesCopySourceColumnSearch({ ...column, column_default: null, comment: null }, "1")).toBe(false); + }); +}); + describe("structureColumnNamesForCopy", () => { const column = (name: string, markedForDrop = false) => ({ name, markedForDrop }); diff --git a/apps/desktop/src/lib/table/tableStructureEditorState.ts b/apps/desktop/src/lib/table/tableStructureEditorState.ts index 2039c732a3..5a75e2ede7 100644 --- a/apps/desktop/src/lib/table/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/table/tableStructureEditorState.ts @@ -1,6 +1,37 @@ import type { ColumnInfo, DatabaseConnectionInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database.ts"; import type { ColumnExtra, EditableStructureColumn, EditableStructureForeignKey, EditableStructureIndex, EditableStructureTrigger } from "@/lib/table/tableStructureEditorSql.ts"; +export interface CopySourceColumnDetails { + /** Column default as shown in the copy-fields dialog, or null when there is none. */ + defaultValue: string | null; + /** Column comment as shown in the copy-fields dialog, or null when there is none. */ + comment: string | null; +} + +/** + * Read-only summary rendered under a column name in the "copy fields from another + * table" dialog. The source table is not open while copying, so the comment and + * default value are the only hint about what an unfamiliar field means. + */ +export function copySourceColumnDetails(column: Pick, databaseType?: DatabaseType): CopySourceColumnDetails { + // Match the main grid and the editor drafts so the dialog, the grid, and the + // copied result render the same normalized default for every database. + const defaultValue = column.column_default == null ? "" : columnDefaultForEditor(column, databaseType); + const rawComment = column.comment ?? ""; + return { + defaultValue: defaultValue.trim() ? defaultValue.trim() : null, + comment: rawComment.trim() ? rawComment.trim() : null, + }; +} + +/** Copy-dialog search matches comments and default values on top of name and type. */ +export function matchesCopySourceColumnSearch(column: Pick, search: string, databaseType?: DatabaseType): boolean { + const query = search.trim().toLowerCase(); + if (!query) return true; + const details = copySourceColumnDetails(column, databaseType); + return [column.name, column.data_type, details.defaultValue ?? "", details.comment ?? ""].some((value) => value.toLowerCase().includes(query)); +} + /** * Column names offered by the structure editor's "copy all column names" action. * Fields marked for drop disappear on save, so they are not offered. @@ -847,7 +878,7 @@ function stripSqlServerDefaultOuterParens(defaultValue: string): string { return value; } -function columnDefaultForEditor(column: ColumnInfo, databaseType?: DatabaseType): string { +function columnDefaultForEditor(column: Pick, databaseType?: DatabaseType): string { if (column.column_default === null) return ""; const defaultValue = column.column_default; if (databaseType === "mysql" && defaultValue === "" && isMysqlCharacterDataType(column.data_type)) { diff --git a/docs/screenshots/issue-9652-after.png b/docs/screenshots/issue-9652-after.png new file mode 100644 index 0000000000..4619abe959 Binary files /dev/null and b/docs/screenshots/issue-9652-after.png differ diff --git a/docs/screenshots/issue-9652-before.png b/docs/screenshots/issue-9652-before.png new file mode 100644 index 0000000000..139fe1712a Binary files /dev/null and b/docs/screenshots/issue-9652-before.png differ diff --git a/docs/screenshots/issue-9652-search-by-comment.png b/docs/screenshots/issue-9652-search-by-comment.png new file mode 100644 index 0000000000..af34804ec8 Binary files /dev/null and b/docs/screenshots/issue-9652-search-by-comment.png differ