diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 5739d619e..1ad9f4f23 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -15,6 +15,7 @@ import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutio import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache"; import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame"; import { expandToSqlStatementWindow, parseInsertValueHints } from "@/lib/sql/insertValueHints"; +import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns"; import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits"; import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste"; @@ -67,6 +68,7 @@ import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorS import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection"; import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation"; import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; +import { metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy"; import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget"; import * as api from "@/lib/backend/api"; @@ -329,6 +331,7 @@ let cachedTables: SqlCompletionTable[] = []; let cachedCompletionObjects: SqlCompletionObject[] = []; // Persistent column cache keyed by "schema.table" or "table" const cachedColumnsByTable = new Map(); +const cachedInsertValueHintColumnsByTable = new Map(); const cachedForeignKeysByTable = new Map(); const loadedColumnsByTable = new Set(); @@ -1332,6 +1335,7 @@ function insertHintMetadataTarget(table: { name: string; schema?: string | null; function getInsertValueHintTableColumns(table: string, schema?: string, database?: string): string[] | undefined { const cacheKey = insertHintCacheKey({ name: table, schema, database }); + if (props.databaseType === "sqlserver") return cachedInsertValueHintColumnsByTable.get(cacheKey); const cached = cachedColumnsByTable.get(cacheKey); if (!cached) return undefined; return cached.map((column) => column.name); @@ -1341,14 +1345,25 @@ function requestInsertValueHintTableColumns(table: string, schema?: string, data if (!props.connectionId || props.database == null) return; if (props.databaseType === "redis" || props.databaseType === "mongodb" || props.databaseType === "elasticsearch") return; const cacheKey = insertHintCacheKey({ name: table, schema, database }); - if (cachedColumnsByTable.has(cacheKey) || pendingInsertValueHintColumnLoads.has(cacheKey)) return; + const hasCachedColumns = props.databaseType === "sqlserver" ? cachedInsertValueHintColumnsByTable.has(cacheKey) : cachedColumnsByTable.has(cacheKey); + if (hasCachedColumns || pendingInsertValueHintColumnLoads.has(cacheKey)) return; const target = insertHintMetadataTarget({ name: table, schema, database }); if (!target) return; pendingInsertValueHintColumnLoads.add(cacheKey); - void connectionStore - .listCompletionColumns(props.connectionId, target.database, table, target.schema) - .then((columns) => { - cachedColumnsByTable.set(cacheKey, columns); + const connectionId = props.connectionId; + const databaseType = props.databaseType; + const loadColumns = async () => { + if (databaseType === "sqlserver") { + const querySchema = metadataSchemaForConnection(connectionStore.getConfig(connectionId), target.database, target.schema); + const columns = await api.getSqlServerColumnMetadata(connectionId, target.database, querySchema, table); + cachedInsertValueHintColumnsByTable.set(cacheKey, insertValueHintColumnNames(databaseType, columns)); + return; + } + const columns = await connectionStore.listCompletionColumns(connectionId, target.database, table, target.schema); + cachedColumnsByTable.set(cacheKey, columns); + }; + void loadColumns() + .then(() => { loadedColumnsByTable.add(cacheKey.toLowerCase()); if (view.value) requestInsertValueHintsRefresh(view.value); }) @@ -2923,6 +2938,7 @@ function refreshCompletionCache() { cachedTables = []; cachedCompletionObjects = []; cachedColumnsByTable.clear(); + cachedInsertValueHintColumnsByTable.clear(); loadedColumnsByTable.clear(); cachedForeignKeysByTable.clear(); } diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 87f699bee..14737842e 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -151,6 +151,7 @@ export const listCompletionObjects = forward("listCompletionObjects"); export const completionAssistantSearch = forward("completionAssistantSearch"); export const getObjectSource = forward("getObjectSource"); export const getColumns = forward("getColumns"); +export const getSqlServerColumnMetadata = forward("getSqlServerColumnMetadata"); export const listDataTypes = forward("listDataTypes"); export const listIndexes = forward("listIndexes"); export const listForeignKeys = forward("listForeignKeys"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 46fa4af69..fef1df75c 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -14,6 +14,7 @@ import type { ObjectSource, ObjectSourceKind, ColumnInfo, + SqlServerColumnMetadata, IndexInfo, ForeignKeyInfo, TriggerInfo, @@ -640,6 +641,10 @@ export async function getColumns(connectionId: string, database: string, schema: return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); } +export async function getSqlServerColumnMetadata(connectionId: string, database: string, schema: string, table: string): Promise { + return get(`/api/schema/sqlserver/column-metadata?${qs({ connection_id: connectionId, database, schema, table })}`); +} + export async function listDataTypes(connectionId: string, database: string): Promise { return get(`/api/schema/data-types?${qs({ connection_id: connectionId, database })}`); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 18d70c7b6..036a3f278 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -17,6 +17,7 @@ import type { ObjectSource, ObjectSourceKind, ColumnInfo, + SqlServerColumnMetadata, IndexInfo, ForeignKeyInfo, TriggerInfo, @@ -789,6 +790,10 @@ export async function getColumns(connectionId: string, database: string, schema: return invoke("get_columns", { connectionId, database, schema, table, catalog }); } +export async function getSqlServerColumnMetadata(connectionId: string, database: string, schema: string, table: string): Promise { + return invoke("get_sqlserver_column_metadata", { connectionId, database, schema, table }); +} + export async function listDataTypes(connectionId: string, database: string): Promise { return invoke("list_data_types", { connectionId, database }); } diff --git a/apps/desktop/src/lib/sql/insertValueHintColumns.ts b/apps/desktop/src/lib/sql/insertValueHintColumns.ts new file mode 100644 index 000000000..db023fc39 --- /dev/null +++ b/apps/desktop/src/lib/sql/insertValueHintColumns.ts @@ -0,0 +1,14 @@ +import type { DatabaseType, SqlServerColumnMetadata } from "@/types/database"; + +type InsertValueHintColumn = Pick & Partial>; + +export function insertValueHintColumnNames(databaseType: DatabaseType | undefined, columns: readonly InsertValueHintColumn[]): string[] { + return columns + .filter((column) => { + if (databaseType !== "sqlserver") return true; + // SQL Server reports computed and temporal period columns independently; + // check every structured flag so positional VALUES only maps writable columns. + return !column.is_identity && !column.is_computed && !column.is_hidden && (column.generated_always_type ?? 0) === 0; + }) + .map((column) => column.name); +} diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 5ed7d752a..7e1ddd2af 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -425,6 +425,13 @@ export interface ColumnInfo { collation?: string | null; } +export interface SqlServerColumnMetadata extends ColumnInfo { + is_identity: boolean; + is_computed: boolean; + is_hidden: boolean; + generated_always_type: number; +} + export interface IndexInfo { name: string; columns: string[]; diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 72fd98f15..9d57735a9 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -9,7 +9,7 @@ use std::future::Future; use std::panic::AssertUnwindSafe; use std::sync::{Arc as StdArc, Mutex as StdMutex}; use std::time::{Duration, Instant}; -use tiberius::{AuthMethod, Client, ColumnData, ColumnType, Config, FromSql, QueryItem, QueryStream, SqlBrowser}; +use tiberius::{AuthMethod, Client, ColumnData, ColumnType, Config, FromSql, QueryItem, QueryStream, Row, SqlBrowser}; use tokio::net::TcpStream; use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; use tokio_util::sync::CancellationToken; @@ -34,6 +34,16 @@ const SQLSERVER_LEGACY_ENCRYPTION_FALLBACKS: [(&str, tiberius::EncryptionLevel); ("no-encryption compatibility fallback", SQLSERVER_UNSUPPORTED_ENCRYPTION_LEVEL), ]; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SqlServerColumnMetadata { + #[serde(flatten)] + pub column: ColumnInfo, + pub is_identity: bool, + pub is_computed: bool, + pub is_hidden: bool, + pub generated_always_type: i32, +} + #[derive(Debug, PartialEq, Eq)] struct SqlServerEndpoint<'a> { host: &'a str, @@ -1520,88 +1530,104 @@ pub async fn list_object_statistics( } pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str) -> Result, String> { + Ok(get_column_metadata(client, schema, table).await?.into_iter().map(|metadata| metadata.column).collect()) +} + +pub async fn get_column_metadata( + client: &mut SqlServerClient, + schema: &str, + table: &str, +) -> Result, String> { let sql = sqlserver_columns_sql(schema, table); let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?; let rows = stream.into_first_result().await.map_err(|e| e.to_string())?; - Ok(rows - .iter() - .map(|row| { - let base = row.get::<&str, _>(1).unwrap_or("").to_string(); - let max_len = row - .try_get::(7) - .ok() - .flatten() - .or_else(|| row.try_get::(7).ok().flatten().map(|v| v as i32)) - .or_else(|| row.try_get::(7).ok().flatten().map(|v| v as i32)); - let dt_prec = row - .try_get::(8) - .ok() - .flatten() - .or_else(|| row.try_get::(8).ok().flatten().map(|v| v as i32)) - .or_else(|| row.try_get::(8).ok().flatten().map(|v| v as i32)); - let num_prec = row - .try_get::(5) - .ok() - .flatten() - .or_else(|| row.try_get::(5).ok().flatten().map(|v| v as i32)) - .or_else(|| row.try_get::(5).ok().flatten().map(|v| v as i32)); - let num_scale = row - .try_get::(6) - .ok() - .flatten() - .or_else(|| row.try_get::(6).ok().flatten().map(|v| v as i32)) - .or_else(|| row.try_get::(6).ok().flatten().map(|v| v as i32)); - let data_type = match base.to_lowercase().as_str() { - "varchar" => match max_len { - Some(-1) => "varchar(max)".to_string(), - Some(n) => format!("varchar({n})"), - None => "varchar".to_string(), - }, - "nvarchar" => match max_len { - Some(-1) => "nvarchar(max)".to_string(), - Some(n) => format!("nvarchar({n})"), - None => "nvarchar".to_string(), - }, - "varbinary" => match max_len { - Some(-1) => "varbinary(max)".to_string(), - Some(n) if n > 0 => format!("varbinary({n})"), - _ => "varbinary".to_string(), - }, - "char" | "nchar" | "binary" => match max_len { - Some(n) if n > 0 => format!("{base}({n})"), - _ => base, - }, - "decimal" | "numeric" => match (num_prec, num_scale) { - (Some(p), Some(s)) => format!("{base}({p},{s})"), - _ => base, - }, - "datetime2" | "datetimeoffset" | "time" => match dt_prec { - Some(p) => format!("{base}({p})"), - _ => base, - }, - _ => base, - }; - ColumnInfo { - name: row.get::<&str, _>(0).unwrap_or("").to_string(), - data_type, - is_nullable: row.get::<&str, _>(2).unwrap_or("NO") == "YES", - column_default: row.get::<&str, _>(3).map(|s| s.to_string()), - is_primary_key: row.get::(4).unwrap_or(0) == 1, - extra: row.get::<&str, _>(9).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()), - comment: row.get::<&str, _>(10).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()), - numeric_precision: num_prec, - numeric_scale: num_scale, - character_maximum_length: max_len, - enum_values: None, - ..Default::default() - } - }) - .collect()) + Ok(rows.iter().map(sqlserver_column_metadata_from_row).collect()) +} + +fn sqlserver_column_metadata_from_row(row: &Row) -> SqlServerColumnMetadata { + let base = row.get::<&str, _>(1).unwrap_or("").to_string(); + let max_len = row + .try_get::(7) + .ok() + .flatten() + .or_else(|| row.try_get::(7).ok().flatten().map(|v| v as i32)) + .or_else(|| row.try_get::(7).ok().flatten().map(|v| v as i32)); + let dt_prec = row + .try_get::(8) + .ok() + .flatten() + .or_else(|| row.try_get::(8).ok().flatten().map(|v| v as i32)) + .or_else(|| row.try_get::(8).ok().flatten().map(|v| v as i32)); + let num_prec = row + .try_get::(5) + .ok() + .flatten() + .or_else(|| row.try_get::(5).ok().flatten().map(|v| v as i32)) + .or_else(|| row.try_get::(5).ok().flatten().map(|v| v as i32)); + let num_scale = row + .try_get::(6) + .ok() + .flatten() + .or_else(|| row.try_get::(6).ok().flatten().map(|v| v as i32)) + .or_else(|| row.try_get::(6).ok().flatten().map(|v| v as i32)); + let data_type = match base.to_lowercase().as_str() { + "varchar" => match max_len { + Some(-1) => "varchar(max)".to_string(), + Some(n) => format!("varchar({n})"), + None => "varchar".to_string(), + }, + "nvarchar" => match max_len { + Some(-1) => "nvarchar(max)".to_string(), + Some(n) => format!("nvarchar({n})"), + None => "nvarchar".to_string(), + }, + "varbinary" => match max_len { + Some(-1) => "varbinary(max)".to_string(), + Some(n) if n > 0 => format!("varbinary({n})"), + _ => "varbinary".to_string(), + }, + "char" | "nchar" | "binary" => match max_len { + Some(n) if n > 0 => format!("{base}({n})"), + _ => base, + }, + "decimal" | "numeric" => match (num_prec, num_scale) { + (Some(p), Some(s)) => format!("{base}({p},{s})"), + _ => base, + }, + "datetime2" | "datetimeoffset" | "time" => match dt_prec { + Some(p) => format!("{base}({p})"), + _ => base, + }, + _ => base, + }; + let column = ColumnInfo { + name: row.get::<&str, _>(0).unwrap_or("").to_string(), + data_type, + is_nullable: row.get::<&str, _>(2).unwrap_or("NO") == "YES", + column_default: row.get::<&str, _>(3).map(|s| s.to_string()), + is_primary_key: row.get::(4).unwrap_or(0) == 1, + extra: row.get::<&str, _>(9).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()), + comment: row.get::<&str, _>(10).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()), + numeric_precision: num_prec, + numeric_scale: num_scale, + character_maximum_length: max_len, + enum_values: None, + ..Default::default() + }; + SqlServerColumnMetadata { + column, + is_identity: row.get::(11).unwrap_or(0) == 1, + is_computed: row.get::(12).unwrap_or(0) == 1, + is_hidden: row.get::(13).unwrap_or(0) == 1, + generated_always_type: row.get::(14).unwrap_or(0), + } } fn sqlserver_columns_sql(schema: &str, table: &str) -> String { let s = schema.replace('\'', "''"); let t = table.replace('\'', "''"); + // COLUMNPROPERTY keeps hidden/generated flags separate and returns NULL on + // SQL Server versions that do not expose a newer property. format!( "SELECT c.name AS COLUMN_NAME, \ ty.name AS DATA_TYPE, \ @@ -1621,7 +1647,11 @@ fn sqlserver_columns_sql(schema: &str, table: &str) -> String { WHEN ic.column_id IS NOT NULL THEN 'identity(' + CONVERT(VARCHAR(38), ic.seed_value) + ',' + CONVERT(VARCHAR(38), ic.increment_value) + ')' \ ELSE NULL \ END AS COLUMN_EXTRA, \ - ep.value AS COLUMN_COMMENT \ + ep.value AS COLUMN_COMMENT, \ + CONVERT(INT, COLUMNPROPERTY(c.object_id, c.name, 'IsIdentity')) AS IS_IDENTITY, \ + CONVERT(INT, COLUMNPROPERTY(c.object_id, c.name, 'IsComputed')) AS IS_COMPUTED, \ + CONVERT(INT, COLUMNPROPERTY(c.object_id, c.name, 'IsHidden')) AS IS_HIDDEN, \ + CONVERT(INT, COLUMNPROPERTY(c.object_id, c.name, 'GeneratedAlwaysType')) AS GENERATED_ALWAYS_TYPE \ FROM sys.objects o \ JOIN sys.schemas s ON s.schema_id = o.schema_id \ JOIN sys.columns c ON c.object_id = o.object_id \ @@ -2442,6 +2472,16 @@ mod tests { assert!(sql.contains("c.is_computed = 1 THEN 'computed'")); } + #[test] + fn sqlserver_columns_sql_exposes_structured_generation_flags() { + let sql = sqlserver_columns_sql("dbo", "orders"); + + assert!(sql.contains("COLUMNPROPERTY(c.object_id, c.name, 'IsIdentity')")); + assert!(sql.contains("COLUMNPROPERTY(c.object_id, c.name, 'IsComputed')")); + assert!(sql.contains("COLUMNPROPERTY(c.object_id, c.name, 'IsHidden')")); + assert!(sql.contains("COLUMNPROPERTY(c.object_id, c.name, 'GeneratedAlwaysType')")); + } + #[test] fn sqlserver_table_comment_sql_queries_extended_properties() { let sql = sqlserver_table_comment_sql("dbo", "users"); diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 7a79e3bf8..e5f78bddb 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -4467,6 +4467,22 @@ pub async fn get_columns_core( .await } +pub async fn get_sqlserver_column_metadata_core( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, + table: &str, +) -> Result, String> { + retry_metadata_connection(state, connection_id, Some(database), || async { + let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; + let connections = state.connections.read().await; + try_sqlserver!(connections, &pool_key, get_column_metadata, schema, table); + Err("SQL Server column metadata requires a native SQL Server connection".to_string()) + }) + .await +} + fn deduplicate_column_infos(columns: Vec) -> Vec { let mut result: Vec = Vec::with_capacity(columns.len()); for column in columns { diff --git a/crates/dbx-core/tests/live_sqlserver_completion.rs b/crates/dbx-core/tests/live_sqlserver_completion.rs index 458509d93..026c82ac8 100644 --- a/crates/dbx-core/tests/live_sqlserver_completion.rs +++ b/crates/dbx-core/tests/live_sqlserver_completion.rs @@ -65,6 +65,84 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti } } +#[tokio::test] +#[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD pointing at a writable SQL Server database"] +async fn live_sqlserver_column_metadata_marks_non_positional_insert_columns() { + let database = std::env::var("DBX_LIVE_SQLSERVER_DATABASE").unwrap_or_else(|_| "tempdb".to_string()); + let host = std::env::var("DBX_LIVE_SQLSERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port = std::env::var("DBX_LIVE_SQLSERVER_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(1433); + let user = std::env::var("DBX_LIVE_SQLSERVER_USER").unwrap_or_else(|_| "sa".to_string()); + let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD"); + let mut client = + dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(&database), None, Duration::from_secs(10)) + .await + .expect("connect SQL Server"); + + let suffix = uuid::Uuid::new_v4().simple().to_string(); + let computed_table = format!("dbx_insert_computed_{suffix}"); + let temporal_table = format!("dbx_insert_temporal_{suffix}"); + let history_table = format!("dbx_insert_history_{suffix}"); + let create_computed = format!( + "CREATE TABLE dbo.[{computed_table}] (id int IDENTITY(1,1) NOT NULL, quantity int NOT NULL, doubled AS quantity * 2, note nvarchar(40) NOT NULL)" + ); + let create_temporal = format!( + "CREATE TABLE dbo.[{temporal_table}] (\ + id int IDENTITY(1,1) NOT NULL PRIMARY KEY, \ + note nvarchar(40) NOT NULL, \ + valid_from datetime2 GENERATED ALWAYS AS ROW START HIDDEN NOT NULL DEFAULT SYSUTCDATETIME(), \ + valid_to datetime2 GENERATED ALWAYS AS ROW END HIDDEN NOT NULL DEFAULT CONVERT(datetime2, '9999-12-31 23:59:59.9999999'), \ + PERIOD FOR SYSTEM_TIME (valid_from, valid_to)\ + ) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.[{history_table}]))" + ); + + dbx_core::db::sqlserver::execute_query(&mut client, &create_computed).await.expect("create computed table"); + dbx_core::db::sqlserver::execute_query(&mut client, &create_temporal).await.expect("create temporal table"); + dbx_core::db::sqlserver::execute_query( + &mut client, + &format!("INSERT INTO dbo.[{computed_table}] VALUES (3, N'normal')"), + ) + .await + .expect("insert while omitting identity and computed columns"); + dbx_core::db::sqlserver::execute_query( + &mut client, + &format!("INSERT INTO dbo.[{temporal_table}] VALUES (N'temporal')"), + ) + .await + .expect("insert while omitting identity and hidden temporal columns"); + + let computed = dbx_core::db::sqlserver::get_column_metadata(&mut client, "dbo", &computed_table) + .await + .expect("read computed metadata"); + let temporal = dbx_core::db::sqlserver::get_column_metadata(&mut client, "dbo", &temporal_table) + .await + .expect("read temporal metadata"); + + dbx_core::db::sqlserver::execute_query( + &mut client, + &format!("ALTER TABLE dbo.[{temporal_table}] SET (SYSTEM_VERSIONING = OFF)"), + ) + .await + .expect("disable system versioning"); + dbx_core::db::sqlserver::execute_query( + &mut client, + &format!("DROP TABLE dbo.[{temporal_table}], dbo.[{history_table}], dbo.[{computed_table}]"), + ) + .await + .expect("drop metadata probe tables"); + + let identity = computed.iter().find(|metadata| metadata.column.name == "id").expect("identity column"); + let quantity = computed.iter().find(|metadata| metadata.column.name == "quantity").expect("normal column"); + let doubled = computed.iter().find(|metadata| metadata.column.name == "doubled").expect("computed column"); + let valid_from = temporal.iter().find(|metadata| metadata.column.name == "valid_from").expect("row start column"); + let valid_to = temporal.iter().find(|metadata| metadata.column.name == "valid_to").expect("row end column"); + + assert!(identity.is_identity); + assert!(!quantity.is_identity && !quantity.is_computed && !quantity.is_hidden); + assert!(doubled.is_computed); + assert!(valid_from.is_hidden && valid_from.generated_always_type == 1); + assert!(valid_to.is_hidden && valid_to.generated_always_type == 2); +} + #[tokio::test] #[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD pointing at a writable SQL Server database"] async fn live_sqlserver_execute_query_creates_schema() { diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index a75df6b08..ccd18fe6f 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -308,6 +308,7 @@ async fn main() { .route("/schema/sqlserver/linked-server-catalogs", get(routes::schema::list_sqlserver_linked_server_catalogs)) .route("/schema/sqlserver/linked-server-schemas", get(routes::schema::list_sqlserver_linked_server_schemas)) .route("/schema/sqlserver/linked-server-tables", get(routes::schema::list_sqlserver_linked_server_tables)) + .route("/schema/sqlserver/column-metadata", get(routes::schema::get_sqlserver_column_metadata)) .route("/schema/schemas", get(routes::schema::list_schemas)) .route("/schema/tables", get(routes::schema::list_tables)) .route("/schema/objects", get(routes::schema::list_objects)) diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 865f44806..1597bf7ce 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -111,6 +111,20 @@ pub async fn list_sqlserver_linked_server_tables( Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } +pub async fn get_sqlserver_column_metadata( + State(state): State>, + Query(q): Query, +) -> Result, AppError> { + let database = q.database.as_deref().unwrap_or(""); + let schema = q.schema.as_deref().unwrap_or(""); + let table = q.table.as_deref().unwrap_or(""); + let result = + dbx_core::schema::get_sqlserver_column_metadata_core(&state.app, &q.connection_id, database, schema, table) + .await + .map_err(AppError)?; + Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) +} + pub async fn list_schemas( State(state): State>, Query(q): Query, diff --git a/packages/app-tests/insertValueHints.test.ts b/packages/app-tests/insertValueHints.test.ts index 9f61cbfff..982bd348d 100644 --- a/packages/app-tests/insertValueHints.test.ts +++ b/packages/app-tests/insertValueHints.test.ts @@ -1,6 +1,7 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; import { buildInsertValueHints, expandToSqlStatementWindow, parseInsertValueHints, parseInsertValueHintsInRanges, parseInsertValuesClauses } from "../../apps/desktop/src/lib/sql/insertValueHints.ts"; +import { insertValueHintColumnNames } from "../../apps/desktop/src/lib/sql/insertValueHintColumns.ts"; test("maps explicit column list to single-row VALUES", () => { const sql = "INSERT INTO auth_user (id, password, last_login) VALUES (5, 'hash', NULL)"; @@ -68,6 +69,56 @@ test("resolves columns from table metadata when column list is omitted", () => { ); }); +test("skips SQL Server identity columns when mapping multi-row VALUES without a column list", () => { + const sql = "INSERT INTO dbo.users VALUES (N'A', 1), (N'B', 2)"; + const columns = insertValueHintColumnNames("sqlserver", [ + { name: "id", is_identity: true }, + { name: "name" }, + { name: "status" }, + ]); + const hints = parseInsertValueHints(sql, { + resolveTableColumns: () => columns, + }); + assert.deepEqual( + hints.map((hint) => hint.column), + ["name", "status", "name", "status"], + ); +}); + +test("skips SQL Server computed and temporal generated columns in positional hints", () => { + const columns = insertValueHintColumnNames("sqlserver", [ + { name: "id", is_identity: true }, + { name: "quantity" }, + { name: "doubled", is_computed: true }, + { name: "note" }, + { name: "valid_from", is_hidden: true, generated_always_type: 1 }, + { name: "valid_to", is_hidden: true, generated_always_type: 2 }, + ]); + + assert.deepEqual(columns, ["quantity", "note"]); +}); + +test("skips visible SQL Server generated columns in positional hints", () => { + assert.deepEqual( + insertValueHintColumnNames("sqlserver", [ + { name: "name" }, + { name: "valid_from", generated_always_type: 1 }, + { name: "valid_to", generated_always_type: 2 }, + ]), + ["name"], + ); +}); + +test("keeps identity columns in positional hints for databases other than SQL Server", () => { + assert.deepEqual( + insertValueHintColumnNames("postgres", [ + { name: "id", is_identity: true }, + { name: "name" }, + ]), + ["id", "name"], + ); +}); + test("returns no hints for INSERT ... SELECT", () => { const sql = "INSERT INTO users (id, name) SELECT id, name FROM staging"; assert.deepEqual(parseInsertValueHints(sql), []); diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 5f667be71..98eb7d905 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -300,6 +300,17 @@ pub async fn get_columns( dbx_core::schema::get_columns_core(&state, &connection_id, &database, &schema, &table).await } +#[tauri::command] +pub async fn get_sqlserver_column_metadata( + state: State<'_, Arc>, + connection_id: String, + database: String, + schema: String, + table: String, +) -> Result, String> { + dbx_core::schema::get_sqlserver_column_metadata_core(&state, &connection_id, &database, &schema, &table).await +} + #[tauri::command] pub async fn list_indexes( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 79d4e55f8..d8295f047 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1117,6 +1117,7 @@ pub fn run() { commands::schema::list_schema_infos, commands::schema::list_data_types, commands::schema::get_columns, + commands::schema::get_sqlserver_column_metadata, commands::schema::list_indexes, commands::schema::list_foreign_keys, commands::schema::list_triggers,