fix(sqlserver): align insert hints with generated columns

This commit is contained in:
zipg 2026-07-20 02:29:28 +08:00 committed by GitHub
parent f5cdbb8662
commit 49a1172469
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 341 additions and 81 deletions

View File

@ -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<string, SqlCompletionColumn[]>();
const cachedInsertValueHintColumnsByTable = new Map<string, string[]>();
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
const loadedColumnsByTable = new Set<string>();
@ -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();
}

View File

@ -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");

View File

@ -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<SqlServerColumnMetadata[]> {
return get(`/api/schema/sqlserver/column-metadata?${qs({ connection_id: connectionId, database, schema, table })}`);
}
export async function listDataTypes(connectionId: string, database: string): Promise<string[]> {
return get(`/api/schema/data-types?${qs({ connection_id: connectionId, database })}`);
}

View File

@ -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<SqlServerColumnMetadata[]> {
return invoke("get_sqlserver_column_metadata", { connectionId, database, schema, table });
}
export async function listDataTypes(connectionId: string, database: string): Promise<string[]> {
return invoke("list_data_types", { connectionId, database });
}

View File

@ -0,0 +1,14 @@
import type { DatabaseType, SqlServerColumnMetadata } from "@/types/database";
type InsertValueHintColumn = Pick<SqlServerColumnMetadata, "name"> & Partial<Pick<SqlServerColumnMetadata, "is_identity" | "is_computed" | "is_hidden" | "generated_always_type">>;
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);
}

View File

@ -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[];

View File

@ -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<Vec<ColumnInfo>, 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<Vec<SqlServerColumnMetadata>, 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::<i32, _>(7)
.ok()
.flatten()
.or_else(|| row.try_get::<i16, _>(7).ok().flatten().map(|v| v as i32))
.or_else(|| row.try_get::<u8, _>(7).ok().flatten().map(|v| v as i32));
let dt_prec = row
.try_get::<i32, _>(8)
.ok()
.flatten()
.or_else(|| row.try_get::<i16, _>(8).ok().flatten().map(|v| v as i32))
.or_else(|| row.try_get::<u8, _>(8).ok().flatten().map(|v| v as i32));
let num_prec = row
.try_get::<i32, _>(5)
.ok()
.flatten()
.or_else(|| row.try_get::<i16, _>(5).ok().flatten().map(|v| v as i32))
.or_else(|| row.try_get::<u8, _>(5).ok().flatten().map(|v| v as i32));
let num_scale = row
.try_get::<i32, _>(6)
.ok()
.flatten()
.or_else(|| row.try_get::<i16, _>(6).ok().flatten().map(|v| v as i32))
.or_else(|| row.try_get::<u8, _>(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::<i32, _>(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::<i32, _>(7)
.ok()
.flatten()
.or_else(|| row.try_get::<i16, _>(7).ok().flatten().map(|v| v as i32))
.or_else(|| row.try_get::<u8, _>(7).ok().flatten().map(|v| v as i32));
let dt_prec = row
.try_get::<i32, _>(8)
.ok()
.flatten()
.or_else(|| row.try_get::<i16, _>(8).ok().flatten().map(|v| v as i32))
.or_else(|| row.try_get::<u8, _>(8).ok().flatten().map(|v| v as i32));
let num_prec = row
.try_get::<i32, _>(5)
.ok()
.flatten()
.or_else(|| row.try_get::<i16, _>(5).ok().flatten().map(|v| v as i32))
.or_else(|| row.try_get::<u8, _>(5).ok().flatten().map(|v| v as i32));
let num_scale = row
.try_get::<i32, _>(6)
.ok()
.flatten()
.or_else(|| row.try_get::<i16, _>(6).ok().flatten().map(|v| v as i32))
.or_else(|| row.try_get::<u8, _>(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::<i32, _>(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::<i32, _>(11).unwrap_or(0) == 1,
is_computed: row.get::<i32, _>(12).unwrap_or(0) == 1,
is_hidden: row.get::<i32, _>(13).unwrap_or(0) == 1,
generated_always_type: row.get::<i32, _>(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");

View File

@ -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<Vec<db::sqlserver::SqlServerColumnMetadata>, 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<db::ColumnInfo>) -> Vec<db::ColumnInfo> {
let mut result: Vec<db::ColumnInfo> = Vec::with_capacity(columns.len());
for column in columns {

View File

@ -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() {

View File

@ -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))

View File

@ -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<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
) -> Result<Json<serde_json::Value>, 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<Arc<WebState>>,
Query(q): Query<SchemaQuery>,

View File

@ -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), []);

View File

@ -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<AppState>>,
connection_id: String,
database: String,
schema: String,
table: String,
) -> Result<Vec<db::sqlserver::SqlServerColumnMetadata>, 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<AppState>>,

View File

@ -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,