fix(sqlserver): align insert hints with generated columns
This commit is contained in:
14 files changed
+341
-81
No files matched your search
@@ -15,6 +15,7 @@ import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutio
|
|||||||
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
|
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
|
||||||
import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame";
|
import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame";
|
||||||
import { expandToSqlStatementWindow, parseInsertValueHints } from "@/lib/sql/insertValueHints";
|
import { expandToSqlStatementWindow, parseInsertValueHints } from "@/lib/sql/insertValueHints";
|
||||||
|
import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns";
|
||||||
import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||||
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
|
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
|
||||||
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste";
|
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste";
|
||||||
@@ -67,6 +68,7 @@ import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorS
|
|||||||
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
|
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
|
||||||
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
|
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
|
||||||
import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
|
import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
|
||||||
|
import { metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
|
||||||
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
|
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
|
||||||
import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
|
import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
|
||||||
import * as api from "@/lib/backend/api";
|
import * as api from "@/lib/backend/api";
|
||||||
@@ -329,6 +331,7 @@ let cachedTables: SqlCompletionTable[] = [];
|
|||||||
let cachedCompletionObjects: SqlCompletionObject[] = [];
|
let cachedCompletionObjects: SqlCompletionObject[] = [];
|
||||||
// Persistent column cache keyed by "schema.table" or "table"
|
// Persistent column cache keyed by "schema.table" or "table"
|
||||||
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
|
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
|
||||||
|
const cachedInsertValueHintColumnsByTable = new Map<string, string[]>();
|
||||||
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
|
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
|
||||||
const loadedColumnsByTable = new Set<string>();
|
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 {
|
function getInsertValueHintTableColumns(table: string, schema?: string, database?: string): string[] | undefined {
|
||||||
const cacheKey = insertHintCacheKey({ name: table, schema, database });
|
const cacheKey = insertHintCacheKey({ name: table, schema, database });
|
||||||
|
if (props.databaseType === "sqlserver") return cachedInsertValueHintColumnsByTable.get(cacheKey);
|
||||||
const cached = cachedColumnsByTable.get(cacheKey);
|
const cached = cachedColumnsByTable.get(cacheKey);
|
||||||
if (!cached) return undefined;
|
if (!cached) return undefined;
|
||||||
return cached.map((column) => column.name);
|
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.connectionId || props.database == null) return;
|
||||||
if (props.databaseType === "redis" || props.databaseType === "mongodb" || props.databaseType === "elasticsearch") return;
|
if (props.databaseType === "redis" || props.databaseType === "mongodb" || props.databaseType === "elasticsearch") return;
|
||||||
const cacheKey = insertHintCacheKey({ name: table, schema, database });
|
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 });
|
const target = insertHintMetadataTarget({ name: table, schema, database });
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
pendingInsertValueHintColumnLoads.add(cacheKey);
|
pendingInsertValueHintColumnLoads.add(cacheKey);
|
||||||
void connectionStore
|
const connectionId = props.connectionId;
|
||||||
.listCompletionColumns(props.connectionId, target.database, table, target.schema)
|
const databaseType = props.databaseType;
|
||||||
.then((columns) => {
|
const loadColumns = async () => {
|
||||||
cachedColumnsByTable.set(cacheKey, columns);
|
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());
|
loadedColumnsByTable.add(cacheKey.toLowerCase());
|
||||||
if (view.value) requestInsertValueHintsRefresh(view.value);
|
if (view.value) requestInsertValueHintsRefresh(view.value);
|
||||||
})
|
})
|
||||||
@@ -2923,6 +2938,7 @@ function refreshCompletionCache() {
|
|||||||
cachedTables = [];
|
cachedTables = [];
|
||||||
cachedCompletionObjects = [];
|
cachedCompletionObjects = [];
|
||||||
cachedColumnsByTable.clear();
|
cachedColumnsByTable.clear();
|
||||||
|
cachedInsertValueHintColumnsByTable.clear();
|
||||||
loadedColumnsByTable.clear();
|
loadedColumnsByTable.clear();
|
||||||
cachedForeignKeysByTable.clear();
|
cachedForeignKeysByTable.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export const listCompletionObjects = forward("listCompletionObjects");
|
|||||||
export const completionAssistantSearch = forward("completionAssistantSearch");
|
export const completionAssistantSearch = forward("completionAssistantSearch");
|
||||||
export const getObjectSource = forward("getObjectSource");
|
export const getObjectSource = forward("getObjectSource");
|
||||||
export const getColumns = forward("getColumns");
|
export const getColumns = forward("getColumns");
|
||||||
|
export const getSqlServerColumnMetadata = forward("getSqlServerColumnMetadata");
|
||||||
export const listDataTypes = forward("listDataTypes");
|
export const listDataTypes = forward("listDataTypes");
|
||||||
export const listIndexes = forward("listIndexes");
|
export const listIndexes = forward("listIndexes");
|
||||||
export const listForeignKeys = forward("listForeignKeys");
|
export const listForeignKeys = forward("listForeignKeys");
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
ObjectSource,
|
ObjectSource,
|
||||||
ObjectSourceKind,
|
ObjectSourceKind,
|
||||||
ColumnInfo,
|
ColumnInfo,
|
||||||
|
SqlServerColumnMetadata,
|
||||||
IndexInfo,
|
IndexInfo,
|
||||||
ForeignKeyInfo,
|
ForeignKeyInfo,
|
||||||
TriggerInfo,
|
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 })}`);
|
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[]> {
|
export async function listDataTypes(connectionId: string, database: string): Promise<string[]> {
|
||||||
return get(`/api/schema/data-types?${qs({ connection_id: connectionId, database })}`);
|
return get(`/api/schema/data-types?${qs({ connection_id: connectionId, database })}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
ObjectSource,
|
ObjectSource,
|
||||||
ObjectSourceKind,
|
ObjectSourceKind,
|
||||||
ColumnInfo,
|
ColumnInfo,
|
||||||
|
SqlServerColumnMetadata,
|
||||||
IndexInfo,
|
IndexInfo,
|
||||||
ForeignKeyInfo,
|
ForeignKeyInfo,
|
||||||
TriggerInfo,
|
TriggerInfo,
|
||||||
@@ -789,6 +790,10 @@ export async function getColumns(connectionId: string, database: string, schema:
|
|||||||
return invoke("get_columns", { connectionId, database, schema, table, catalog });
|
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[]> {
|
export async function listDataTypes(connectionId: string, database: string): Promise<string[]> {
|
||||||
return invoke("list_data_types", { connectionId, database });
|
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;
|
collation?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SqlServerColumnMetadata extends ColumnInfo {
|
||||||
|
is_identity: boolean;
|
||||||
|
is_computed: boolean;
|
||||||
|
is_hidden: boolean;
|
||||||
|
generated_always_type: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IndexInfo {
|
export interface IndexInfo {
|
||||||
name: string;
|
name: string;
|
||||||
columns: string[];
|
columns: string[];
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::future::Future;
|
|||||||
use std::panic::AssertUnwindSafe;
|
use std::panic::AssertUnwindSafe;
|
||||||
use std::sync::{Arc as StdArc, Mutex as StdMutex};
|
use std::sync::{Arc as StdArc, Mutex as StdMutex};
|
||||||
use std::time::{Duration, Instant};
|
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::net::TcpStream;
|
||||||
use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
|
use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
|
||||||
use tokio_util::sync::CancellationToken;
|
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),
|
("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)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
struct SqlServerEndpoint<'a> {
|
struct SqlServerEndpoint<'a> {
|
||||||
host: &'a str,
|
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> {
|
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 sql = sqlserver_columns_sql(schema, table);
|
||||||
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
|
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())?;
|
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
|
||||||
Ok(rows
|
Ok(rows.iter().map(sqlserver_column_metadata_from_row).collect())
|
||||||
.iter()
|
}
|
||||||
.map(|row| {
|
|
||||||
let base = row.get::<&str, _>(1).unwrap_or("").to_string();
|
fn sqlserver_column_metadata_from_row(row: &Row) -> SqlServerColumnMetadata {
|
||||||
let max_len = row
|
let base = row.get::<&str, _>(1).unwrap_or("").to_string();
|
||||||
.try_get::<i32, _>(7)
|
let max_len = row
|
||||||
.ok()
|
.try_get::<i32, _>(7)
|
||||||
.flatten()
|
.ok()
|
||||||
.or_else(|| row.try_get::<i16, _>(7).ok().flatten().map(|v| v as i32))
|
.flatten()
|
||||||
.or_else(|| row.try_get::<u8, _>(7).ok().flatten().map(|v| v as i32));
|
.or_else(|| row.try_get::<i16, _>(7).ok().flatten().map(|v| v as i32))
|
||||||
let dt_prec = row
|
.or_else(|| row.try_get::<u8, _>(7).ok().flatten().map(|v| v as i32));
|
||||||
.try_get::<i32, _>(8)
|
let dt_prec = row
|
||||||
.ok()
|
.try_get::<i32, _>(8)
|
||||||
.flatten()
|
.ok()
|
||||||
.or_else(|| row.try_get::<i16, _>(8).ok().flatten().map(|v| v as i32))
|
.flatten()
|
||||||
.or_else(|| row.try_get::<u8, _>(8).ok().flatten().map(|v| v as i32));
|
.or_else(|| row.try_get::<i16, _>(8).ok().flatten().map(|v| v as i32))
|
||||||
let num_prec = row
|
.or_else(|| row.try_get::<u8, _>(8).ok().flatten().map(|v| v as i32));
|
||||||
.try_get::<i32, _>(5)
|
let num_prec = row
|
||||||
.ok()
|
.try_get::<i32, _>(5)
|
||||||
.flatten()
|
.ok()
|
||||||
.or_else(|| row.try_get::<i16, _>(5).ok().flatten().map(|v| v as i32))
|
.flatten()
|
||||||
.or_else(|| row.try_get::<u8, _>(5).ok().flatten().map(|v| v as i32));
|
.or_else(|| row.try_get::<i16, _>(5).ok().flatten().map(|v| v as i32))
|
||||||
let num_scale = row
|
.or_else(|| row.try_get::<u8, _>(5).ok().flatten().map(|v| v as i32));
|
||||||
.try_get::<i32, _>(6)
|
let num_scale = row
|
||||||
.ok()
|
.try_get::<i32, _>(6)
|
||||||
.flatten()
|
.ok()
|
||||||
.or_else(|| row.try_get::<i16, _>(6).ok().flatten().map(|v| v as i32))
|
.flatten()
|
||||||
.or_else(|| row.try_get::<u8, _>(6).ok().flatten().map(|v| v as i32));
|
.or_else(|| row.try_get::<i16, _>(6).ok().flatten().map(|v| v as i32))
|
||||||
let data_type = match base.to_lowercase().as_str() {
|
.or_else(|| row.try_get::<u8, _>(6).ok().flatten().map(|v| v as i32));
|
||||||
"varchar" => match max_len {
|
let data_type = match base.to_lowercase().as_str() {
|
||||||
Some(-1) => "varchar(max)".to_string(),
|
"varchar" => match max_len {
|
||||||
Some(n) => format!("varchar({n})"),
|
Some(-1) => "varchar(max)".to_string(),
|
||||||
None => "varchar".to_string(),
|
Some(n) => format!("varchar({n})"),
|
||||||
},
|
None => "varchar".to_string(),
|
||||||
"nvarchar" => match max_len {
|
},
|
||||||
Some(-1) => "nvarchar(max)".to_string(),
|
"nvarchar" => match max_len {
|
||||||
Some(n) => format!("nvarchar({n})"),
|
Some(-1) => "nvarchar(max)".to_string(),
|
||||||
None => "nvarchar".to_string(),
|
Some(n) => format!("nvarchar({n})"),
|
||||||
},
|
None => "nvarchar".to_string(),
|
||||||
"varbinary" => match max_len {
|
},
|
||||||
Some(-1) => "varbinary(max)".to_string(),
|
"varbinary" => match max_len {
|
||||||
Some(n) if n > 0 => format!("varbinary({n})"),
|
Some(-1) => "varbinary(max)".to_string(),
|
||||||
_ => "varbinary".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})"),
|
"char" | "nchar" | "binary" => match max_len {
|
||||||
_ => base,
|
Some(n) if n > 0 => format!("{base}({n})"),
|
||||||
},
|
_ => base,
|
||||||
"decimal" | "numeric" => match (num_prec, num_scale) {
|
},
|
||||||
(Some(p), Some(s)) => format!("{base}({p},{s})"),
|
"decimal" | "numeric" => match (num_prec, num_scale) {
|
||||||
_ => base,
|
(Some(p), Some(s)) => format!("{base}({p},{s})"),
|
||||||
},
|
_ => base,
|
||||||
"datetime2" | "datetimeoffset" | "time" => match dt_prec {
|
},
|
||||||
Some(p) => format!("{base}({p})"),
|
"datetime2" | "datetimeoffset" | "time" => match dt_prec {
|
||||||
_ => base,
|
Some(p) => format!("{base}({p})"),
|
||||||
},
|
_ => base,
|
||||||
_ => base,
|
},
|
||||||
};
|
_ => base,
|
||||||
ColumnInfo {
|
};
|
||||||
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
|
let column = ColumnInfo {
|
||||||
data_type,
|
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
|
||||||
is_nullable: row.get::<&str, _>(2).unwrap_or("NO") == "YES",
|
data_type,
|
||||||
column_default: row.get::<&str, _>(3).map(|s| s.to_string()),
|
is_nullable: row.get::<&str, _>(2).unwrap_or("NO") == "YES",
|
||||||
is_primary_key: row.get::<i32, _>(4).unwrap_or(0) == 1,
|
column_default: row.get::<&str, _>(3).map(|s| s.to_string()),
|
||||||
extra: row.get::<&str, _>(9).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()),
|
is_primary_key: row.get::<i32, _>(4).unwrap_or(0) == 1,
|
||||||
comment: row.get::<&str, _>(10).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()),
|
extra: row.get::<&str, _>(9).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()),
|
||||||
numeric_precision: num_prec,
|
comment: row.get::<&str, _>(10).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()),
|
||||||
numeric_scale: num_scale,
|
numeric_precision: num_prec,
|
||||||
character_maximum_length: max_len,
|
numeric_scale: num_scale,
|
||||||
enum_values: None,
|
character_maximum_length: max_len,
|
||||||
..Default::default()
|
enum_values: None,
|
||||||
}
|
..Default::default()
|
||||||
})
|
};
|
||||||
.collect())
|
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 {
|
fn sqlserver_columns_sql(schema: &str, table: &str) -> String {
|
||||||
let s = schema.replace('\'', "''");
|
let s = schema.replace('\'', "''");
|
||||||
let t = table.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!(
|
format!(
|
||||||
"SELECT c.name AS COLUMN_NAME, \
|
"SELECT c.name AS COLUMN_NAME, \
|
||||||
ty.name AS DATA_TYPE, \
|
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) + ')' \
|
WHEN ic.column_id IS NOT NULL THEN 'identity(' + CONVERT(VARCHAR(38), ic.seed_value) + ',' + CONVERT(VARCHAR(38), ic.increment_value) + ')' \
|
||||||
ELSE NULL \
|
ELSE NULL \
|
||||||
END AS COLUMN_EXTRA, \
|
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 \
|
FROM sys.objects o \
|
||||||
JOIN sys.schemas s ON s.schema_id = o.schema_id \
|
JOIN sys.schemas s ON s.schema_id = o.schema_id \
|
||||||
JOIN sys.columns c ON c.object_id = o.object_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'"));
|
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]
|
#[test]
|
||||||
fn sqlserver_table_comment_sql_queries_extended_properties() {
|
fn sqlserver_table_comment_sql_queries_extended_properties() {
|
||||||
let sql = sqlserver_table_comment_sql("dbo", "users");
|
let sql = sqlserver_table_comment_sql("dbo", "users");
|
||||||
|
|||||||
@@ -4467,6 +4467,22 @@ pub async fn get_columns_core(
|
|||||||
.await
|
.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> {
|
fn deduplicate_column_infos(columns: Vec<db::ColumnInfo>) -> Vec<db::ColumnInfo> {
|
||||||
let mut result: Vec<db::ColumnInfo> = Vec::with_capacity(columns.len());
|
let mut result: Vec<db::ColumnInfo> = Vec::with_capacity(columns.len());
|
||||||
for column in columns {
|
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]
|
#[tokio::test]
|
||||||
#[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD pointing at a writable SQL Server database"]
|
#[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD pointing at a writable SQL Server database"]
|
||||||
async fn live_sqlserver_execute_query_creates_schema() {
|
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-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-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/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/schemas", get(routes::schema::list_schemas))
|
||||||
.route("/schema/tables", get(routes::schema::list_tables))
|
.route("/schema/tables", get(routes::schema::list_tables))
|
||||||
.route("/schema/objects", get(routes::schema::list_objects))
|
.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()))?))
|
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(
|
pub async fn list_schemas(
|
||||||
State(state): State<Arc<WebState>>,
|
State(state): State<Arc<WebState>>,
|
||||||
Query(q): Query<SchemaQuery>,
|
Query(q): Query<SchemaQuery>,
|
||||||
|
|||||||
+51
@@ -1,6 +1,7 @@
|
|||||||
import { strict as assert } from "node:assert";
|
import { strict as assert } from "node:assert";
|
||||||
import { test } from "vitest";
|
import { test } from "vitest";
|
||||||
import { buildInsertValueHints, expandToSqlStatementWindow, parseInsertValueHints, parseInsertValueHintsInRanges, parseInsertValuesClauses } from "../../apps/desktop/src/lib/sql/insertValueHints.ts";
|
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", () => {
|
test("maps explicit column list to single-row VALUES", () => {
|
||||||
const sql = "INSERT INTO auth_user (id, password, last_login) VALUES (5, 'hash', NULL)";
|
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", () => {
|
test("returns no hints for INSERT ... SELECT", () => {
|
||||||
const sql = "INSERT INTO users (id, name) SELECT id, name FROM staging";
|
const sql = "INSERT INTO users (id, name) SELECT id, name FROM staging";
|
||||||
assert.deepEqual(parseInsertValueHints(sql), []);
|
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
|
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]
|
#[tauri::command]
|
||||||
pub async fn list_indexes(
|
pub async fn list_indexes(
|
||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
|
|||||||
@@ -1117,6 +1117,7 @@ pub fn run() {
|
|||||||
commands::schema::list_schema_infos,
|
commands::schema::list_schema_infos,
|
||||||
commands::schema::list_data_types,
|
commands::schema::list_data_types,
|
||||||
commands::schema::get_columns,
|
commands::schema::get_columns,
|
||||||
|
commands::schema::get_sqlserver_column_metadata,
|
||||||
commands::schema::list_indexes,
|
commands::schema::list_indexes,
|
||||||
commands::schema::list_foreign_keys,
|
commands::schema::list_foreign_keys,
|
||||||
commands::schema::list_triggers,
|
commands::schema::list_triggers,
|
||||||
|
|||||||
Reference in new issue
Block a user