From 50c706cc958f2978a9e442983eac03e710404492 Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 18 Aug 2026 10:51:38 +0800 Subject: [PATCH] fix(vastbase): preserve schema for result inserts --- .../drivers/vastbase-go/integration_test.go | 15 ++++ .../drivers/vastbase-go/vastbase_metadata.go | 59 ++++++++---- .../vastbase-go/vastbase_metadata_test.go | 38 ++++++++ .../src/lib/metadata/tableMetadataCache.ts | 11 ++- apps/desktop/src/stores/queryStore.ts | 9 +- apps/desktop/src/types/database.ts | 1 + crates/dbx-core/src/ai.rs | 14 +-- crates/dbx-core/src/data_grid_sql.rs | 32 +++++++ crates/dbx-core/src/db/meilisearch_driver.rs | 1 + crates/dbx-core/src/db/mysql.rs | 2 + crates/dbx-core/src/db/mysql_compatible.rs | 1 + crates/dbx-core/src/schema_diff.rs | 4 + crates/dbx-core/src/script_generator.rs | 3 + crates/dbx-core/src/types.rs | 2 + .../tests/api_contract_verification.rs | 2 + .../dbx-core/tests/bidirectional_diff_e2e.rs | 1 + .../tests/cross_dialect_integration.rs | 1 + .../dbx-core/tests/performance_benchmarks.rs | 1 + .../tests/regression_compatibility.rs | 1 + crates/dbx-mcp/src/backend.rs | 1 + packages/app-tests/queryStore.test.ts | 89 +++++++++++++++++++ packages/app-tests/tableMetadataCache.test.ts | 33 +++++++ 22 files changed, 291 insertions(+), 30 deletions(-) diff --git a/agents/drivers/vastbase-go/integration_test.go b/agents/drivers/vastbase-go/integration_test.go index b8fbcba1b6..4589f5e149 100644 --- a/agents/drivers/vastbase-go/integration_test.go +++ b/agents/drivers/vastbase-go/integration_test.go @@ -31,6 +31,9 @@ func TestVastbaseIntegration(t *testing.T) { child := "dbx_go_child_" + suffix view := "dbx_go_view_" + suffix function := "dbx_go_fn_" + suffix + searchFirst := "dbx_go_first_" + suffix + searchSecond := "dbx_go_second_" + suffix + searchTable := "DBX_GO_VISIBLE_" + suffix server := newServer() cp := connectParams{ @@ -42,6 +45,8 @@ func TestVastbaseIntegration(t *testing.T) { } t.Cleanup(func() { _ = server.disconnect() }) cleanup := []string{ + "DROP SCHEMA IF EXISTS " + quoteIdentifier(searchFirst) + " CASCADE", + "DROP SCHEMA IF EXISTS " + quoteIdentifier(searchSecond) + " CASCADE", "DROP VIEW IF EXISTS public." + quoteIdentifier(view), "DROP FUNCTION IF EXISTS public." + quoteIdentifier(function) + "()", "DROP TABLE IF EXISTS public." + quoteIdentifier(child), @@ -61,6 +66,16 @@ func TestVastbaseIntegration(t *testing.T) { mustExecute(t, server, "CREATE INDEX "+quoteIdentifier(child+"_parent_idx")+" ON public."+quoteIdentifier(child)+"(parent_id)") mustExecute(t, server, "CREATE VIEW public."+quoteIdentifier(view)+" AS SELECT id, name FROM public."+quoteIdentifier(parent)) mustExecute(t, server, "CREATE FUNCTION public."+quoteIdentifier(function)+"() RETURNS text AS $$ SELECT 'dbx'; $$ LANGUAGE SQL") + mustExecute(t, server, "CREATE SCHEMA "+quoteIdentifier(searchFirst)) + mustExecute(t, server, "CREATE SCHEMA "+quoteIdentifier(searchSecond)) + mustExecute(t, server, "CREATE TABLE "+quoteIdentifier(searchSecond)+"."+quoteIdentifier(searchTable)+" (id integer PRIMARY KEY, name varchar(64))") + mustExecute(t, server, "SET search_path TO "+quoteIdentifier(searchFirst)+", "+quoteIdentifier(searchSecond)) + visibleColumns, err := server.getColumns("", searchTable) + if err != nil || len(visibleColumns) != 2 || visibleColumns[0].ResolvedSchema == nil || *visibleColumns[0].ResolvedSchema != searchSecond { + t.Fatalf("unqualified metadata did not resolve the non-first search_path schema: columns=%#v err=%v", visibleColumns, err) + } + mustExecute(t, server, "INSERT INTO "+quoteIdentifier(searchTable)+" VALUES (1, 'visible')") + mustExecute(t, server, "SET search_path TO public") tables, err := server.listTables("public", metadataListConstraints{Filter: suffix}) if err != nil || len(tables) < 3 { diff --git a/agents/drivers/vastbase-go/vastbase_metadata.go b/agents/drivers/vastbase-go/vastbase_metadata.go index d42653e301..763f5f9010 100644 --- a/agents/drivers/vastbase-go/vastbase_metadata.go +++ b/agents/drivers/vastbase-go/vastbase_metadata.go @@ -64,6 +64,7 @@ type metadataListConstraints struct { type columnInfo struct { Name string `json:"name"` DataType string `json:"data_type"` + ResolvedSchema *string `json:"resolved_schema,omitempty"` FullDataType string `json:"-"` IsNullable bool `json:"is_nullable"` ColumnDefault *string `json:"column_default"` @@ -1198,43 +1199,66 @@ func completionNameMatches(name string, request completionAssistantRequest) bool } func (s *server) getColumns(schema, table string) ([]columnInfo, error) { - effective, err := s.effectiveSchema(schema) - if err != nil { - return nil, err - } - primary, _ := s.primaryKeys(effective, table) if s.mode.mysqlCompat { + effective, err := s.effectiveSchema(schema) + if err != nil { + return nil, err + } + primary, _ := s.primaryKeys(effective, table) return s.informationSchemaColumns(effective, table, primary) } catalog, prefix := "sys_catalog", "sys" if s.mode.postgresCatalog { catalog, prefix = "pg_catalog", "pg" - return s.queryCatalogColumns(effective, table, primary, catalog, prefix, "pg_get_expr") + result, err := s.queryCatalogColumns(schema, table, catalog, prefix, "pg_get_expr") + return s.finishCatalogColumns(schema, table, result, err) } expression := "sys_get_expr" if s.usePgDefaultExpression { expression = "pg_get_expr" } - result, err := s.queryCatalogColumns(effective, table, primary, catalog, prefix, expression) + result, err := s.queryCatalogColumns(schema, table, catalog, prefix, expression) if err != nil && expression == "sys_get_expr" && isUndefinedFunction(err, expression) { // Some V8R6 PostgreSQL-mode databases keep sys_catalog while adbin is // pg_node_tree. Cache the compatible function after the exact failure. s.usePgDefaultExpression = true - return s.queryCatalogColumns(effective, table, primary, catalog, prefix, "pg_get_expr") + result, err = s.queryCatalogColumns(schema, table, catalog, prefix, "pg_get_expr") } - return result, err + return s.finishCatalogColumns(schema, table, result, err) +} + +func (s *server) finishCatalogColumns(schema, table string, result []columnInfo, err error) ([]columnInfo, error) { + if err != nil || len(result) == 0 { + return result, err + } + resolvedSchema := strings.TrimSpace(schema) + if result[0].ResolvedSchema != nil { + resolvedSchema = *result[0].ResolvedSchema + } + primary, _ := s.primaryKeys(resolvedSchema, table) + for index := range result { + result[index].IsPrimaryKey = primary[strings.ToLower(result[index].Name)] + } + if s.mode.sqlServerIdentity { + s.applyIdentityMetadata(resolvedSchema, table, result) + } + return result, nil } func (s *server) queryCatalogColumns( schema, table string, - primary map[string]bool, catalog, prefix, expression string, ) ([]columnInfo, error) { identityExpression := "a.attidentity" if s.catalogIdentityUnsupported { identityExpression = "CAST(NULL AS varchar(1)) AS attidentity" } - query := fmt.Sprintf(`SELECT a.attname, format_type(a.atttypid, a.atttypmod), NOT a.attnotnull, + relationPredicate := fmt.Sprintf("n.nspname = %s AND c.relname = %s", quoteLiteral(schema), quoteLiteral(table)) + if strings.TrimSpace(schema) == "" { + visibilityFunction := vastbaseCatalogFunction(catalog, "sys_table_is_visible", "pg_table_is_visible") + relationPredicate = fmt.Sprintf("c.relname = %s AND %s(c.oid)", quoteLiteral(table), visibilityFunction) + } + query := fmt.Sprintf(`SELECT n.nspname, a.attname, format_type(a.atttypid, a.atttypmod), NOT a.attnotnull, %s(ad.adbin, ad.adrelid), col_description(a.attrelid, a.attnum), CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 THEN ((a.atttypmod - 4) >> 16) & 65535 END, CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 THEN (a.atttypmod - 4) & 65535 END, @@ -1243,11 +1267,11 @@ func (s *server) queryCatalogColumns( FROM %s.%s_attribute a JOIN %s.%s_type t ON t.oid = a.atttypid JOIN %s.%s_class c ON c.oid = a.attrelid JOIN %s.%s_namespace n ON n.oid = c.relnamespace LEFT JOIN %s.%s_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum -WHERE n.nspname = %s AND c.relname = %s AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, expression, identityExpression, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(table)) +WHERE %s AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, expression, identityExpression, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, catalog, prefix, relationPredicate) rows, err := s.metadataQuery(query) if err != nil && !s.catalogIdentityUnsupported && isUndefinedColumn(err, "attidentity") { s.catalogIdentityUnsupported = true - return s.queryCatalogColumns(schema, table, primary, catalog, prefix, expression) + return s.queryCatalogColumns(schema, table, catalog, prefix, expression) } if err != nil { return nil, err @@ -1255,21 +1279,18 @@ WHERE n.nspname = %s AND c.relname = %s AND a.attnum > 0 AND NOT a.attisdropped defer rows.Close() result := []columnInfo{} for rows.Next() { - var name, dataType string + var resolvedSchema, name, dataType string var nullable bool var defaultValue, comment, identity sql.NullString var precision, scale, length sql.NullInt64 - if err := rows.Scan(&name, &dataType, &nullable, &defaultValue, &comment, &precision, &scale, &length, &identity); err != nil { + if err := rows.Scan(&resolvedSchema, &name, &dataType, &nullable, &defaultValue, &comment, &precision, &scale, &length, &identity); err != nil { return nil, err } - result = append(result, columnInfo{Name: name, DataType: dataType, IsNullable: nullable, ColumnDefault: nullStringPtr(defaultValue), IsPrimaryKey: primary[strings.ToLower(name)], Extra: vastbaseIdentityClause(identity.String), Comment: nullStringPtr(comment), NumericPrecision: nullIntPtr(precision), NumericScale: nullIntPtr(scale), CharacterMaximumLength: nullIntPtr(length)}) + result = append(result, columnInfo{Name: name, DataType: dataType, ResolvedSchema: stringPtr(resolvedSchema), IsNullable: nullable, ColumnDefault: nullStringPtr(defaultValue), Extra: vastbaseIdentityClause(identity.String), Comment: nullStringPtr(comment), NumericPrecision: nullIntPtr(precision), NumericScale: nullIntPtr(scale), CharacterMaximumLength: nullIntPtr(length)}) } if err := rows.Err(); err != nil { return nil, err } - if s.mode.sqlServerIdentity { - s.applyIdentityMetadata(schema, table, result) - } return result, nil } diff --git a/agents/drivers/vastbase-go/vastbase_metadata_test.go b/agents/drivers/vastbase-go/vastbase_metadata_test.go index 7c7032f3ae..c670893727 100644 --- a/agents/drivers/vastbase-go/vastbase_metadata_test.go +++ b/agents/drivers/vastbase-go/vastbase_metadata_test.go @@ -197,6 +197,44 @@ func openVastbaseCustomTypesDB(t *testing.T, state *vastbaseCustomTypesTestState return db } +func TestVastbaseGetColumnsResolvesVisibleRelationSchemaInCatalogQuery(t *testing.T) { + queries := []string{} + state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { + queries = append(queries, query) + switch { + case strings.Contains(query, "FROM pg_catalog.pg_attribute a"): + if !strings.Contains(query, "pg_catalog.pg_table_is_visible(c.oid)") || strings.Contains(query, "current_schema()") { + return nil, fmt.Errorf("unqualified columns query did not resolve the visible relation: %s", query) + } + return &valueRows{ + columns: []string{"nspname", "attname", "format_type", "nullable", "default", "comment", "precision", "scale", "length", "identity"}, + rows: [][]driver.Value{{"tenant_b", "ID", "bigint", false, nil, nil, nil, nil, nil, nil}}, + }, nil + case strings.Contains(query, "FROM information_schema.table_constraints"): + if !strings.Contains(query, "tc.table_schema='tenant_b'") { + return nil, fmt.Errorf("primary-key lookup did not use resolved schema: %s", query) + } + return &valueRows{columns: []string{"column_name"}, rows: [][]driver.Value{{"ID"}}}, nil + default: + return nil, fmt.Errorf("unexpected query: %s", query) + } + }} + server := newServer() + server.db = openVastbaseCustomTypesDB(t, state) + server.mode.postgresCatalog = true + + columns, err := server.getColumns("", "TBLCUSPOSTMATERIALLOG") + if err != nil { + t.Fatal(err) + } + if len(queries) != 2 { + t.Fatalf("getColumns executed %d metadata queries, want 2: %v", len(queries), queries) + } + if len(columns) != 1 || columns[0].ResolvedSchema == nil || *columns[0].ResolvedSchema != "tenant_b" || !columns[0].IsPrimaryKey { + t.Fatalf("resolved relation metadata was lost: %#v", columns) + } +} + func TestVastbaseListCustomTypesUsesPostgresCatalog(t *testing.T) { state := &vastbaseCustomTypesTestState{query: func(query string) (driver.Rows, error) { if !strings.Contains(query, "FROM pg_catalog.pg_type t") || !strings.Contains(query, "t.typtype IN ('b','c','d','e','r','m')") || !strings.Contains(query, "t.typisdefined") || !strings.Contains(query, "t.typelem = 0") || !strings.Contains(query, "(t.typrelid = 0 OR c.relkind = 'c')") || !strings.Contains(query, "d.classoid = 'pg_catalog.pg_type'::regclass") || !strings.Contains(query, "n.nspname <> 'pg_catalog'") || !strings.Contains(query, "n.nspname <> 'information_schema'") || !strings.Contains(query, "n.nspname NOT LIKE 'pg_toast%'") || !strings.Contains(query, "n.nspname NOT LIKE 'pg_temp%'") { diff --git a/apps/desktop/src/lib/metadata/tableMetadataCache.ts b/apps/desktop/src/lib/metadata/tableMetadataCache.ts index c052d4518d..336e36526f 100644 --- a/apps/desktop/src/lib/metadata/tableMetadataCache.ts +++ b/apps/desktop/src/lib/metadata/tableMetadataCache.ts @@ -160,14 +160,17 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise< scope, async () => { // Column discovery can be especially slow on Oracle. Start row-identity - // discovery independently so query preflight can reuse it without waiting. + // discovery independently unless an unqualified Vastbase relation must + // first report its visible schema for the index lookup. + const resolveVastbaseSchema = request.databaseType === "vastbase" && !request.schema; const columnsPromise = api.getColumns(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog); - const indexesPromise = loadTableIndexes(request).catch((): IndexInfo[] => []); + const indexesPromise = resolveVastbaseSchema ? undefined : loadTableIndexes(request).catch((): IndexInfo[] => []); const columns = await columnsPromise; - const indexes = columns.length > 0 ? await indexesPromise : []; + const resolvedSchema = resolveVastbaseSchema ? columns.find((column) => column.resolved_schema)?.resolved_schema : request.schema; + const indexes = columns.length > 0 ? await (indexesPromise ?? loadTableIndexes({ ...request, schema: resolvedSchema }).catch((): IndexInfo[] => [])) : []; const primaryKeys = editableRowIdentifierColumns(request.databaseType as DatabaseType, columns, indexes, request.tableType); return { - schema: request.schema || undefined, + schema: resolvedSchema || undefined, tableName: request.tableName, tableType: request.tableType, catalog: request.catalog, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 8c34dbd76d..2760199cb3 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -3487,7 +3487,11 @@ export const useQueryStore = defineStore("query", () => { // Oracle-family connection databases are service names, not schemas. When // the query does not qualify a schema, let the driver resolve the current // login user's schema instead of looking up metadata under the service name. - const resolvedSchema = (dbType === "sqlserver" && !source.schema) || (ORACLE_LIKE_METADATA_TYPES.has(dbType) && !schema) ? "" : metadataSchemaForConnection(conn, metadataDatabase, schema || undefined); + // An unqualified Vastbase query runs in the connection's current + // search_path. Do not reinterpret the selected database as a schema; the + // agent resolves the visible relation's actual namespace with the columns. + const useCurrentVastbaseSchema = dbType === "vastbase" && !source.schema && !tab.schema; + const resolvedSchema = (dbType === "sqlserver" && !source.schema) || (ORACLE_LIKE_METADATA_TYPES.has(dbType) && !schema) || useCurrentVastbaseSchema ? "" : metadataSchemaForConnection(conn, metadataDatabase, schema || undefined); const metadataSchema = normalizeUppercaseFoldedMetadataIdentifier(dbType, resolvedSchema || undefined, source.schema ? source.schemaQuoted : false) || ""; const metadataTableName = normalizeUppercaseFoldedMetadataIdentifier(dbType, source.tableName, source.tableNameQuoted)!; const metadataCatalog = normalizeUppercaseFoldedMetadataIdentifier(dbType, source.catalog, source.catalogQuoted); @@ -3519,13 +3523,14 @@ export const useQueryStore = defineStore("query", () => { } function loadedEditableSourceFromMetadata(target: EditableSourceMetadataTarget, metadata: Awaited>["metadata"]): LoadedEditableSource { + const writeSchema = target.request.databaseType === "vastbase" && !target.writeSchema ? metadata.schema : target.writeSchema; return { source: target.source, analysis: target.analysis, tableMeta: { catalog: target.request.catalog, database: target.request.database, - schema: target.writeSchema, + schema: writeSchema, tableName: target.request.tableName, tableType: metadata.tableType, columns: metadata.columns, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 490e11b5fd..f567b81b60 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -537,6 +537,7 @@ export interface CustomTypeDetails { export interface ColumnInfo { name: string; data_type: string; + resolved_schema?: string; is_nullable: boolean; column_default: string | null; is_primary_key: boolean; diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index 225bcc2a2a..b9ce35fc2d 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -928,8 +928,12 @@ impl MiniMaxTextAccumulator { Some(value.to_string()) } - fn complete(&self) -> &str { - &self.complete + fn replay_text(&self) -> &str { + if matches!(self.semantics, MiniMaxStreamSemantics::Incremental) { + &self.latest + } else { + &self.complete + } } } @@ -964,7 +968,7 @@ impl MiniMaxReasoningDetailState { fn replay_value(&self) -> serde_json::Value { let mut detail = self.latest.clone(); if detail.get("text").is_some() { - detail["text"] = serde_json::Value::String(self.text.complete().to_string()); + detail["text"] = serde_json::Value::String(self.text.replay_text().to_string()); } detail } @@ -6751,7 +6755,7 @@ mod tests { assert_eq!(normalizer.push("Hell"), None); assert_eq!(normalizer.push("Hello world"), Some(" world".to_string())); assert_eq!(normalizer.push(""), None); - assert_eq!(normalizer.complete(), "Hello world"); + assert_eq!(normalizer.replay_text(), "Hello world"); } #[test] @@ -6761,7 +6765,7 @@ mod tests { assert_eq!(normalizer.push("Hello"), Some("Hello".to_string())); assert_eq!(normalizer.push(" world"), Some(" world".to_string())); assert_eq!(normalizer.push("!"), Some("!".to_string())); - assert_eq!(normalizer.complete(), "Hello world!"); + assert_eq!(normalizer.replay_text(), "Hello world!"); } #[test] diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 72aab6940e..2819aea6d8 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -5032,6 +5032,38 @@ mod tests { .all(|statement| statement.contains("`gc`.`docfileinfo`") && !statement.contains('"'))); } + #[test] + fn vastbase_query_result_writes_use_resolved_search_path_schema() { + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Vastbase), + identifier_quote: None, + table_meta: DataGridTableMeta { + catalog: None, + database: Some("smes_dev".to_string()), + schema: Some("tenant_b".to_string()), + table_name: "TBLCUSPOSTMATERIALLOG".to_string(), + primary_keys: vec![], + columns: Some(vec![column("MONO", "varchar", true, None), column("ID", "bigint", false, None)]), + }, + columns: vec!["MONO".to_string(), "ID".to_string()], + source_columns: None, + rows: vec![vec![json!("mono"), json!(461936049002042_i64)]], + dirty_rows: vec![(0, vec![(0, json!("LY-SC01-260800002"))])], + deleted_rows: vec![], + new_rows: vec![vec![json!("dbx-insert-check"), json!(461936049002043_i64)]], + }); + + assert_eq!(result.validation_error, None); + assert_eq!(result.execution_schema.as_deref(), Some("tenant_b")); + assert_eq!( + result.statements, + vec![ + "UPDATE \"tenant_b\".\"TBLCUSPOSTMATERIALLOG\" SET \"MONO\" = 'LY-SC01-260800002' WHERE \"MONO\" = 'mono' AND \"ID\" = 461936049002042;", + "INSERT INTO \"tenant_b\".\"TBLCUSPOSTMATERIALLOG\" (\"MONO\", \"ID\") VALUES ('dbx-insert-check', 461936049002043);", + ] + ); + } + #[test] fn gbase8s_save_omits_owner_for_insert_update_delete_and_rollback() { let result = prepare_data_grid_save_for_driver_profile( diff --git a/crates/dbx-core/src/db/meilisearch_driver.rs b/crates/dbx-core/src/db/meilisearch_driver.rs index a7ac2b76b4..c8ceaf3598 100644 --- a/crates/dbx-core/src/db/meilisearch_driver.rs +++ b/crates/dbx-core/src/db/meilisearch_driver.rs @@ -293,6 +293,7 @@ pub async fn get_columns(client: &MeilisearchClient, index: &str) -> Result Resul is_unique: column_key.eq_ignore_ascii_case("UNI"), name, data_type: column_type, + resolved_schema: None, is_nullable: get_str_by_name(row, "IS_NULLABLE") == "YES", column_default: get_opt_str(row, "COLUMN_DEFAULT"), extra: get_opt_str(row, "EXTRA"), @@ -3702,6 +3703,7 @@ pub async fn get_columns_show(pool: &MySqlPool, database: &str, table: &str) -> Some(ColumnInfo { name, data_type: get_str_by_name(row, "Type"), + resolved_schema: None, is_nullable: get_str_by_name(row, "Null").eq_ignore_ascii_case("YES"), column_default: get_opt_str(row, "Default"), is_primary_key: key.eq_ignore_ascii_case("PRI"), diff --git a/crates/dbx-core/src/db/mysql_compatible.rs b/crates/dbx-core/src/db/mysql_compatible.rs index 229f877a62..a669a76986 100644 --- a/crates/dbx-core/src/db/mysql_compatible.rs +++ b/crates/dbx-core/src/db/mysql_compatible.rs @@ -197,6 +197,7 @@ pub async fn get_columns_show_from( Some(ColumnInfo { name, data_type: get_str_by_name(row, "Type"), + resolved_schema: None, is_nullable: get_str_by_name(row, "Null").eq_ignore_ascii_case("YES"), column_default: get_opt_str(row, "Default"), is_primary_key: key.eq_ignore_ascii_case("PRI"), diff --git a/crates/dbx-core/src/schema_diff.rs b/crates/dbx-core/src/schema_diff.rs index ff42738d76..e92fd381e7 100644 --- a/crates/dbx-core/src/schema_diff.rs +++ b/crates/dbx-core/src/schema_diff.rs @@ -4405,6 +4405,7 @@ mod tests { ColumnInfo { name: name.to_string(), data_type: data_type.to_string(), + resolved_schema: None, is_nullable: false, column_default: None, is_primary_key: false, @@ -5963,6 +5964,7 @@ mod tests { source: Some(ColumnInfo { name: "status".to_string(), data_type: "text".to_string(), + resolved_schema: None, is_nullable: true, column_default: None, is_primary_key: false, @@ -8408,6 +8410,7 @@ mod tests { let s = vec![ColumnInfo { name: "c".into(), data_type: "varchar(100)".into(), + resolved_schema: None, is_nullable: true, column_default: Some("'default'".into()), comment: Some("new".into()), @@ -8424,6 +8427,7 @@ mod tests { let t = vec![ColumnInfo { name: "c".into(), data_type: "varchar(50)".into(), + resolved_schema: None, is_nullable: false, column_default: None, comment: Some("old".into()), diff --git a/crates/dbx-core/src/script_generator.rs b/crates/dbx-core/src/script_generator.rs index 4d11f92b7e..850073a56d 100644 --- a/crates/dbx-core/src/script_generator.rs +++ b/crates/dbx-core/src/script_generator.rs @@ -1360,6 +1360,7 @@ mod tests { ColumnInfo { name: name.to_string(), data_type: data_type.to_string(), + resolved_schema: None, is_nullable: true, column_default: None, is_primary_key: false, @@ -2234,6 +2235,7 @@ mod tests { source: Some(ColumnInfo { name: "large_val".to_string(), data_type: "BIGINT".to_string(), + resolved_schema: None, is_nullable: true, column_default: None, is_primary_key: false, @@ -2250,6 +2252,7 @@ mod tests { target: Some(ColumnInfo { name: "large_val".to_string(), data_type: "INT".to_string(), + resolved_schema: None, is_nullable: true, column_default: None, is_primary_key: false, diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index 7209cff87d..6ec52a568b 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -144,6 +144,8 @@ pub struct ObjectSource { pub struct ColumnInfo { pub name: String, pub data_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_schema: Option, pub is_nullable: bool, pub column_default: Option, pub is_primary_key: bool, diff --git a/crates/dbx-core/tests/api_contract_verification.rs b/crates/dbx-core/tests/api_contract_verification.rs index 75fafde52f..4a26d68173 100644 --- a/crates/dbx-core/tests/api_contract_verification.rs +++ b/crates/dbx-core/tests/api_contract_verification.rs @@ -228,6 +228,7 @@ fn column_info_serialization_roundtrip() { let col = ColumnInfo { name: "id".to_string(), data_type: "int".to_string(), + resolved_schema: None, is_nullable: false, column_default: None, is_primary_key: true, @@ -274,6 +275,7 @@ fn table_columns_result_serialization_contract() { columns: vec![ColumnInfo { name: "id".to_string(), data_type: "int".to_string(), + resolved_schema: None, is_nullable: false, column_default: None, is_primary_key: true, diff --git a/crates/dbx-core/tests/bidirectional_diff_e2e.rs b/crates/dbx-core/tests/bidirectional_diff_e2e.rs index 4c15ec2724..cc96be0c79 100644 --- a/crates/dbx-core/tests/bidirectional_diff_e2e.rs +++ b/crates/dbx-core/tests/bidirectional_diff_e2e.rs @@ -16,6 +16,7 @@ fn col(name: &str, data_type: &str) -> ColumnInfo { ColumnInfo { name: name.to_string(), data_type: data_type.to_string(), + resolved_schema: None, is_nullable: false, column_default: None, is_primary_key: false, diff --git a/crates/dbx-core/tests/cross_dialect_integration.rs b/crates/dbx-core/tests/cross_dialect_integration.rs index a45f6f6251..7781ad0557 100644 --- a/crates/dbx-core/tests/cross_dialect_integration.rs +++ b/crates/dbx-core/tests/cross_dialect_integration.rs @@ -19,6 +19,7 @@ fn col(name: &str, data_type: &str) -> ColumnInfo { ColumnInfo { name: name.to_string(), data_type: data_type.to_string(), + resolved_schema: None, is_nullable: false, column_default: None, is_primary_key: false, diff --git a/crates/dbx-core/tests/performance_benchmarks.rs b/crates/dbx-core/tests/performance_benchmarks.rs index c806b001a2..127699a139 100644 --- a/crates/dbx-core/tests/performance_benchmarks.rs +++ b/crates/dbx-core/tests/performance_benchmarks.rs @@ -34,6 +34,7 @@ fn generate_details(tables: &[TableInfo], columns_per_table: usize) -> Vec ColumnInfo { ColumnInfo { name: name.to_string(), data_type: data_type.to_string(), + resolved_schema: None, is_nullable: false, column_default: None, is_primary_key: false, diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index aedca827b1..837036a3b8 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -1736,6 +1736,7 @@ fn infer_document_columns(documents: &[Value]) -> Vec { .map(|(name, data_type)| ColumnInfo { name, data_type, + resolved_schema: None, is_nullable: true, column_default: None, is_primary_key: false, diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index cf45fb1077..f2606465a3 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -68,6 +68,13 @@ function sapHanaConn(id: string): ConnectionConfig { }; } +function vastbaseConn(id: string): ConnectionConfig { + return { + ...conn(id), + db_type: "vastbase", + }; +} + function clearableQuerySchemaConn(id: string, dbType: "oracle" | "dameng" | "gaussdb" | "oceanbase-oracle"): ConnectionConfig { return { ...conn(id), @@ -2119,6 +2126,88 @@ test("normalizes only unquoted SAP HANA query identifiers before loading editabl } }); +test("uses the visible Vastbase relation schema for unqualified query writes", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + const columnRequests: Array<{ database: string | null; schema: string | null; table: string | null }> = []; + + connectionStore.addEphemeralConnection(vastbaseConn("vastbase-1")); + + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/query/execute-multi") { + return new Response( + JSON.stringify([ + { + columns: ["MONO", "ID"], + rows: [["mono", 461936049002042]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "/api/query/analyze-editability") { + return new Response( + JSON.stringify({ + editable: true, + analysis: { + schema: undefined, + schemaQuoted: false, + tableName: "TBLCUSPOSTMATERIALLOG", + tableNameQuoted: true, + tableAlias: undefined, + selectStar: true, + columns: [], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url.startsWith("/api/schema/columns?")) { + const params = new URL(url, "http://localhost").searchParams; + columnRequests.push({ database: params.get("database"), schema: params.get("schema"), table: params.get("table") }); + return new Response( + JSON.stringify([ + { name: "MONO", data_type: "varchar", resolved_schema: "tenant_b", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null }, + { name: "ID", data_type: "bigint", resolved_schema: "tenant_b", is_nullable: false, column_default: null, is_primary_key: false, extra: null, comment: null }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "/api/query/prepare-pagination-plan") { + const body = JSON.parse(String(init?.body ?? "{}")); + return new Response(JSON.stringify({ sqlToExecute: body.options.sql, useAgentResultSession: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const tabId = store.createTab("vastbase-1", "smes_dev", "Unqualified", "query"); + await store.executeTabSql(tabId, 'select * from "TBLCUSPOSTMATERIALLOG"'); + + const tab = store.tabs.find((item) => item.id === tabId); + await waitFor(() => columnRequests.length > 0 && tab?.tableMeta?.tableName === "TBLCUSPOSTMATERIALLOG"); + assert.deepEqual(columnRequests, [{ database: "smes_dev", schema: "", table: "TBLCUSPOSTMATERIALLOG" }]); + assert.equal(tab?.tableMeta?.schema, "tenant_b"); + assert.deepEqual( + tab?.tableMeta?.columns.map((column) => column.name), + ["MONO", "ID"], + ); + assert.equal(tab?.queryEditabilityReason, undefined); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("keeps PostgreSQL quoted primary keys distinct from case-only result columns", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia()); diff --git a/packages/app-tests/tableMetadataCache.test.ts b/packages/app-tests/tableMetadataCache.test.ts index 5135239a02..46deab9ae1 100644 --- a/packages/app-tests/tableMetadataCache.test.ts +++ b/packages/app-tests/tableMetadataCache.test.ts @@ -61,6 +61,39 @@ test("table metadata loader deduplicates equivalent in-flight requests and cache assert.equal(getColumns.mock.calls.length, 1); }); +test("uses the visible Vastbase relation schema for the index lookup", async () => { + const getColumns = vi.fn(async (): Promise => [ + { + name: "id", + data_type: "bigint", + resolved_schema: "tenant_b", + is_nullable: false, + column_default: null, + is_primary_key: true, + extra: null, + }, + ]); + const listIndexes = vi.fn(async (): Promise => []); + vi.doMock("@/lib/backend/api", () => ({ getColumns, listIndexes })); + + const { clearTableMetadataCache, loadTableMetadata } = await import("../../apps/desktop/src/lib/metadata/tableMetadataCache.ts"); + clearTableMetadataCache(); + + const result = await loadTableMetadata({ + connectionId: "conn", + database: "app", + schema: "", + tableName: "users", + tableType: "TABLE", + databaseType: "vastbase", + driverProfile: "vastbase", + }); + + assert.equal(result.metadata.schema, "tenant_b"); + assert.equal(listIndexes.mock.calls.length, 1); + assert.equal(listIndexes.mock.calls[0]?.[2], "tenant_b"); +}); + test("table metadata invalidation keeps unrelated schemas cached", async () => { const getColumns = vi.fn( async (_connectionId: string, _database: string, _schema: string, table: string): Promise => [