diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 2c34b05bf1..447122ca74 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -320,7 +320,7 @@ import { mongoCollectionSupportsIndexes, supportsMongoIndexMutations } from "@/l import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata"; import { isProtectedMongoIndex, mongoDropAllIndexesPreview, mongoDropIndexFailureCount, mongoDropIndexPreview } from "@/lib/sidebar/mongoCollectionMutation"; import { runMongoMutation } from "@/lib/sidebar/runMongoSidebarMutation"; -import { dataGridConditionColumnOptions, dataGridConditionIdentifierQuote } from "@/lib/dataGrid/dataGridConditionCompletion"; +import { dataGridConditionColumnOptions, dataGridConditionIdentifierQuote, dataGridFilterColumns } from "@/lib/dataGrid/dataGridConditionCompletion"; import { isMacOS } from "@/lib/backend/platform"; import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { formatShortcut } from "@/lib/editor/shortcutRegistry"; @@ -931,7 +931,18 @@ const whereFilterInput = ref(props.initialWhereInput ?? ""); const conditionInputRevision = ref(0); const appliedConditionInputRevision = ref(0); const queryControlError = ref(""); -const conditionColumns = computed(() => dataGridConditionColumnOptions(props.tableMeta?.columns ?? props.result.columns, resolvedDatabaseType.value)); +const filterBuilderColumns = computed(() => + dataGridFilterColumns({ + databaseType: resolvedDatabaseType.value, + context: props.context, + urlParams: resolvedConnectionConfig.value?.url_params, + connectionString: resolvedConnectionConfig.value?.connection_string, + tableColumns: props.tableMeta?.columns ?? [], + resultColumns: props.result.columns, + resultColumnTypes: props.result.column_types, + }), +); +const conditionColumns = computed(() => dataGridConditionColumnOptions(props.tableMeta ? filterBuilderColumns.value.map((column) => column.columnInfo ?? column.name) : props.result.columns, resolvedDatabaseType.value)); const conditionIdentifierQuote = computed(() => dataGridConditionIdentifierQuote(resolvedDatabaseType.value, connectionStore.connectionIdentifierQuote?.(props.connectionId))); const conditionHistoryScope = computed(() => ({ connectionId: props.connectionId, @@ -1000,16 +1011,15 @@ const allFilterModeOptions: Array<{ value: FilterMode; labelKey: string }> = [ { value: "is-not-null", labelKey: "grid.filterBuilderIsNotNull" }, ]; const filterModeOptions = computed(() => allFilterModeOptions.filter((option) => filterModeIsSupportedForDatabase(option.value, resolvedDatabaseType.value))); -const filterBuilderColumns = computed(() => props.tableMeta?.columns ?? []); const filterBuilderColumnOptions = computed(() => filterBuilderColumns.value.map((column) => column.name)); const structuredFilterCacheKey = computed(() => props.cacheKey || [props.connectionId ?? "", props.database ?? "", props.context ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? ""].join("\u0001")); -const structuredFilterScopeKey = computed(() => [props.connectionId ?? "", props.database ?? "", props.schema ?? "", props.context ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", filterBuilderColumnOptions.value.join("\0")].join("\u0001")); +const structuredFilterScopeKey = computed(() => [props.connectionId ?? "", props.database ?? "", props.schema ?? "", props.context ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", props.tableMeta?.columns.map((column) => column.name).join("\0") ?? ""].join("\u0001")); function isStructuredFilterRuleComplete(rule: StructuredFilterRule): boolean { return filterModeIsSupportedForDatabase(rule.mode, resolvedDatabaseType.value) && filterModeHasCompleteValue(rule.mode, rule.rawValue, rule.rawEndValue); } async function buildStructuredFilterCondition(rule: StructuredFilterRule): Promise { - const columnInfo = filterBuilderColumns.value.find((column) => column.name === rule.columnName); + const columnInfo = filterBuilderColumns.value.find((column) => column.name === rule.columnName)?.columnInfo; const usesList = filterModeUsesList(rule.mode); const usesRange = filterModeUsesRange(rule.mode); return ( @@ -2081,7 +2091,7 @@ async function buildStructuredWhereFromRules(rules: StructuredFilterRule[]): Pro if (!rule.columnName) return { rule, condition: null }; if (!filterModeIsSupportedForDatabase(rule.mode, resolvedDatabaseType.value)) return { rule, condition: null }; if (!filterModeHasCompleteValue(rule.mode, rule.rawValue, rule.rawEndValue)) return { rule, condition: null }; - const columnInfo = filterBuilderColumns.value.find((column) => column.name === rule.columnName); + const columnInfo = filterBuilderColumns.value.find((column) => column.name === rule.columnName)?.columnInfo; const usesList = filterModeUsesList(rule.mode); const usesRange = filterModeUsesRange(rule.mode); const options = { diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridConditionCompletion.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridConditionCompletion.spec.ts index 1d6a9790e4..0ebea81ec8 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridConditionCompletion.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridConditionCompletion.spec.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from "vitest"; -import { dataGridConditionColumnOptions, dataGridConditionIdentifierQuote } from "@/lib/dataGrid/dataGridConditionCompletion"; +import { dataGridConditionColumnOptions, dataGridConditionIdentifierQuote, dataGridFilterColumns } from "@/lib/dataGrid/dataGridConditionCompletion"; + +function column(name: string, dataType: string) { + return { + name, + data_type: dataType, + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + }; +} describe("dataGridConditionColumnOptions", () => { it("reuses PostgreSQL completion quoting while preserving display metadata", () => { @@ -26,3 +37,108 @@ describe("dataGridConditionColumnOptions", () => { expect(dataGridConditionIdentifierQuote("kingbase", "`")).toBe("`"); }); }); + +describe("dataGridFilterColumns", () => { + it.each(["ms", "us", "ns"])("adds metadata-less IoTDB Tree Time for %s precision", (precision) => { + const value = column("value", "DOUBLE"); + + expect( + dataGridFilterColumns({ + databaseType: "iotdb", + context: "table-data", + urlParams: "time_zone=Asia%2FShanghai", + tableColumns: [value], + resultColumns: ["Time", "value"], + resultColumnTypes: [`TIMESTAMP(${precision})`, "DOUBLE"], + }), + ).toEqual([{ name: "Time" }, { name: "value", columnInfo: value }]); + }); + + it("lets URL params override the same connection-string dialect key", () => { + const value = column("value", "DOUBLE"); + + expect( + dataGridFilterColumns({ + databaseType: "iotdb", + context: "table-data", + connectionString: "jdbc:iotdb://localhost:6667/root.db?sql_dialect=table", + urlParams: "sql_dialect=tree", + tableColumns: [value], + resultColumns: ["Time", "value"], + resultColumnTypes: ["TIMESTAMP(ms)", "DOUBLE"], + }), + ).toEqual([{ name: "Time" }, { name: "value", columnInfo: value }]); + }); + + it("keeps explicit Tree Time metadata exactly once", () => { + const time = column("Time", "TIMESTAMP"); + const value = column("value", "DOUBLE"); + + expect( + dataGridFilterColumns({ + databaseType: "iotdb", + context: "table-data", + urlParams: "sql_dialect=tree", + tableColumns: [time, value], + resultColumns: ["Time", "value"], + resultColumnTypes: ["TIMESTAMP(ms)", "DOUBLE"], + }), + ).toEqual([ + { name: "Time", columnInfo: time }, + { name: "value", columnInfo: value }, + ]); + }); + + it.each([{ urlParams: "sql_dialect=table" }, { urlParams: "DIALECT=TABLE" }, { connectionString: "jdbc:iotdb://localhost:6667/root.db?sql_dialect=table" }] as const)("does not synthesize Time for IoTDB Table dialect ($urlParams$connectionString)", (connection) => { + const value = column("value", "DOUBLE"); + + expect( + dataGridFilterColumns({ + databaseType: "iotdb", + context: "table-data", + ...connection, + tableColumns: [value], + resultColumns: ["Time", "value"], + resultColumnTypes: ["TIMESTAMP(ms)", "DOUBLE"], + }), + ).toEqual([{ name: "value", columnInfo: value }]); + }); + + it.each([ + { name: "query results", context: "results", databaseType: "iotdb", resultColumns: ["Time", "value"], resultColumnTypes: ["TIMESTAMP(ms)", "DOUBLE"] }, + { name: "non-IoTDB", context: "table-data", databaseType: "mysql", resultColumns: ["Time", "value"], resultColumnTypes: ["TIMESTAMP(ms)", "DOUBLE"] }, + { name: "non-leading Time", context: "table-data", databaseType: "iotdb", resultColumns: ["value", "Time"], resultColumnTypes: ["DOUBLE", "TIMESTAMP(ms)"] }, + { name: "non-canonical time", context: "table-data", databaseType: "iotdb", resultColumns: ["time", "value"], resultColumnTypes: ["TIMESTAMP(ms)", "DOUBLE"] }, + { name: "unqualified timestamp", context: "table-data", databaseType: "iotdb", resultColumns: ["Time", "value"], resultColumnTypes: ["TIMESTAMP", "DOUBLE"] }, + ] as const)("keeps physical candidates unchanged for $name", ({ context, databaseType, resultColumns, resultColumnTypes }) => { + const value = column("value", "DOUBLE"); + + expect( + dataGridFilterColumns({ + databaseType, + context, + tableColumns: [value], + resultColumns, + resultColumnTypes, + }), + ).toEqual([{ name: "value", columnInfo: value }]); + }); + + it("does not duplicate a case-variant metadata Time column", () => { + const time = column("time", "TIMESTAMP"); + const value = column("value", "DOUBLE"); + + expect( + dataGridFilterColumns({ + databaseType: "iotdb", + context: "table-data", + tableColumns: [time, value], + resultColumns: ["Time", "value"], + resultColumnTypes: ["TIMESTAMP(ns)", "DOUBLE"], + }), + ).toEqual([ + { name: "time", columnInfo: time }, + { name: "value", columnInfo: value }, + ]); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridConditionCompletion.ts b/apps/desktop/src/lib/dataGrid/dataGridConditionCompletion.ts index 0f0eaa0565..6899010cb8 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridConditionCompletion.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridConditionCompletion.ts @@ -2,7 +2,51 @@ import type { DataGridConditionColumnOption } from "@/composables/useDataGridCon import { codeMirrorSqlDialect } from "@/lib/database/jdbcDialect"; import { quoteSqlIdentifier } from "@/lib/sql/sqlCompletion"; import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect"; -import type { DatabaseType } from "@/types/database"; +import type { ColumnInfo, DatabaseType } from "@/types/database"; + +export interface DataGridFilterColumn { + name: string; + columnInfo?: ColumnInfo; +} + +export interface DataGridFilterColumnsOptions { + databaseType?: DatabaseType; + context?: "results" | "table-data"; + urlParams?: string; + connectionString?: string; + tableColumns: readonly ColumnInfo[]; + resultColumns: readonly string[]; + resultColumnTypes?: readonly (string | null | undefined)[]; +} + +export function dataGridFilterColumns(options: DataGridFilterColumnsOptions): DataGridFilterColumn[] { + const physicalColumns = options.tableColumns.map((columnInfo) => ({ name: columnInfo.name, columnInfo })); + if (!usesImplicitIoTDBTreeTime(options)) return physicalColumns; + return [{ name: "Time" }, ...physicalColumns]; +} + +function usesImplicitIoTDBTreeTime(options: DataGridFilterColumnsOptions): boolean { + return ( + options.databaseType === "iotdb" && + options.context === "table-data" && + iotdbSqlDialect(options.urlParams, options.connectionString) !== "table" && + options.resultColumns[0] === "Time" && + /^TIMESTAMP\((ms|us|ns)\)$/i.test(options.resultColumnTypes?.[0]?.trim() ?? "") && + !options.tableColumns.some((column) => column.name.toLowerCase() === "time") + ); +} + +function iotdbSqlDialect(urlParams: string | undefined, connectionString: string | undefined): string | undefined { + const merged = new Map(); + addIoTDBConnectionParams(merged, connectionString?.split("?", 2)[1]?.split("#", 1)[0]); + addIoTDBConnectionParams(merged, urlParams); + return (merged.get("sql_dialect") || merged.get("dialect"))?.trim().toLowerCase(); +} + +function addIoTDBConnectionParams(target: Map, raw: string | undefined) { + const params = new URLSearchParams((raw ?? "").trim().replace(/^\?/, "")); + for (const [key, value] of params) target.set(key.toLowerCase(), value); +} export function dataGridConditionColumnOptions(columns: readonly DataGridConditionColumnOption[], databaseType?: DatabaseType): DataGridConditionColumnOption[] { const dialect = codeMirrorSqlDialect(databaseType);