fix(sqlserver): align insert hints with generated columns
This commit is contained in:
parent
f5cdbb8662
commit
49a1172469
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 })}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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), []);
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue