feat(sqlserver): temporal table DDL and history links

This commit is contained in:
zipg authored and GitHub committed 2026-10-02 09:50:35 +08:00
1 parent 9089131184
commit 14c152f1eb
7 files changed
+486 -12

No files matched your search

+167 -12
View File
@@ -25,6 +25,8 @@ mod mongodb_columns;
pub mod plugin_metadata;
#[cfg(test)]
mod plugin_metadata_tests;
#[cfg(test)]
mod sqlserver_temporal_ddl_tests;
macro_rules! extract_pool {
($pool:expr, $variant:ident) => {
@@ -9124,15 +9126,34 @@ struct TableDdlOptions {
include_postgres_access: bool,
include_partitions: bool,
portable_oracle: bool,
include_sqlserver_temporal: bool,
}
impl TableDdlOptions {
const SINGLE_RELATION: Self =
Self { include_postgres_access: false, include_partitions: false, portable_oracle: false };
const RELATION_EXPORT: Self =
Self { include_postgres_access: false, include_partitions: false, portable_oracle: true };
const EXPORT: Self = Self { include_postgres_access: false, include_partitions: true, portable_oracle: true };
const DISPLAY: Self = Self { include_postgres_access: true, include_partitions: true, portable_oracle: false };
const SINGLE_RELATION: Self = Self {
include_postgres_access: false,
include_partitions: false,
portable_oracle: false,
include_sqlserver_temporal: false,
};
const RELATION_EXPORT: Self = Self {
include_postgres_access: false,
include_partitions: false,
portable_oracle: true,
include_sqlserver_temporal: false,
};
const EXPORT: Self = Self {
include_postgres_access: false,
include_partitions: true,
portable_oracle: true,
include_sqlserver_temporal: false,
};
const DISPLAY: Self = Self {
include_postgres_access: true,
include_partitions: true,
portable_oracle: false,
include_sqlserver_temporal: true,
};
}
pub async fn get_table_ddl_core(
@@ -9400,7 +9421,8 @@ async fn get_table_ddl_once(
}
if let Some(client) = extract_pool!(pool_handle.as_ref(), SqlServer) {
let mut client = lock_sqlserver_metadata_client(&client).await?;
return build_sqlserver_ddl(&mut client, schema, table).await;
return build_sqlserver_ddl_with_temporal(&mut client, schema, table, options.include_sqlserver_temporal)
.await;
}
if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) {
if let Some(config) = db_config.as_ref().filter(|config| is_agent_postgres_metadata_fallback_config(config))
@@ -14844,6 +14866,19 @@ fn render_postgres_table_ddl_with_constraints_and_partition_info(
ddl
}
fn sqlserver_temporal_period_mismatches(
temporal: &db::sqlserver::SqlServerTemporalTableMetadata,
generated_clauses: &HashMap<String, String>,
) -> bool {
[(&temporal.start_column, "START"), (&temporal.end_column, "END")].into_iter().any(|(column, kind)| {
column.as_deref().is_some_and(|name| {
generated_clauses
.get(name)
.is_some_and(|clause| !clause.starts_with(&format!("GENERATED ALWAYS AS ROW {kind}")))
})
})
}
fn sqlserver_identity_clause(extra: Option<&str>) -> Option<String> {
let extra = extra?.trim();
let lower = extra.to_ascii_lowercase();
@@ -14877,6 +14912,15 @@ pub async fn build_sqlserver_ddl(
client: &mut db::sqlserver::SqlServerClient,
schema: &str,
table: &str,
) -> Result<String, String> {
build_sqlserver_ddl_with_temporal(client, schema, table, false).await
}
async fn build_sqlserver_ddl_with_temporal(
client: &mut db::sqlserver::SqlServerClient,
schema: &str,
table: &str,
include_temporal: bool,
) -> Result<String, String> {
// The computed-column definitions come from the same metadata query as the
// columns, so the DDL path reads the richer driver record instead of the
@@ -14885,6 +14929,8 @@ pub async fn build_sqlserver_ddl(
let indexes = db::sqlserver::list_indexes(client, schema, table).await?;
let fkeys = db::sqlserver::list_foreign_keys(client, schema, table).await?;
let table_comment = db::sqlserver::get_table_comment(client, schema, table).await?;
let temporal =
if include_temporal { db::sqlserver::get_temporal_table_metadata(client, schema, table).await? } else { None };
let columns = metadata.iter().map(|metadata| metadata.column.clone()).collect::<Vec<_>>();
let computed_clauses = metadata
@@ -14899,17 +14945,56 @@ pub async fn build_sqlserver_ddl(
})
.collect::<HashMap<_, _>>();
Ok(render_sqlserver_table_ddl_with_computed(
let generated_clauses = if include_temporal {
metadata
.iter()
.filter_map(|column| {
let kind = match column.generated_always_type {
1 => "START",
2 => "END",
_ => return None,
};
Some((
column.column.name.clone(),
format!("GENERATED ALWAYS AS ROW {kind}{}", if column.is_hidden { " HIDDEN" } else { "" }),
))
})
.collect::<HashMap<_, _>>()
} else {
HashMap::new()
};
if include_temporal && !generated_clauses.is_empty() && temporal.is_none() {
return Err("SQL Server temporal period metadata is unavailable".to_string());
}
if let Some(temporal) = temporal.as_ref() {
if sqlserver_temporal_period_mismatches(temporal, &generated_clauses) {
return Err(
"SQL Server temporal period and column metadata do not match; refresh the table definition".to_string()
);
}
}
Ok(render_sqlserver_table_ddl_details(
schema,
table,
&columns,
&computed_clauses,
&indexes,
&fkeys,
table_comment.as_deref(),
SqlServerDdlDetails {
computed_clauses: Some(&computed_clauses),
generated_clauses: Some(&generated_clauses),
temporal: temporal.as_ref(),
},
))
}
#[derive(Default)]
struct SqlServerDdlDetails<'a> {
computed_clauses: Option<&'a HashMap<String, String>>,
generated_clauses: Option<&'a HashMap<String, String>>,
temporal: Option<&'a db::sqlserver::SqlServerTemporalTableMetadata>,
}
fn sqlserver_fk_action_clause(kind: &str, value: Option<&str>) -> String {
let Some(value) = value else {
return String::new();
@@ -14945,17 +15030,57 @@ pub fn render_sqlserver_table_ddl_with_computed(
indexes: &[db::IndexInfo],
fkeys: &[db::ForeignKeyInfo],
table_comment: Option<&str>,
) -> String {
render_sqlserver_table_ddl_details(
schema,
table,
columns,
indexes,
fkeys,
table_comment,
SqlServerDdlDetails { computed_clauses: Some(computed_clauses), ..Default::default() },
)
}
fn render_sqlserver_table_ddl_details(
schema: &str,
table: &str,
columns: &[db::ColumnInfo],
indexes: &[db::IndexInfo],
fkeys: &[db::ForeignKeyInfo],
table_comment: Option<&str>,
details: SqlServerDdlDetails<'_>,
) -> String {
let table_name = format!("{}.{}", sqlserver_ident(schema), sqlserver_ident(table));
let mut ddl = format!("CREATE TABLE {table_name} (\n");
if let Some(temporal) = details.temporal {
if let (Some(schema), Some(table)) = (&temporal.parent_schema, &temporal.parent_table) {
let parent = format!("{}.{}", sqlserver_ident(schema), sqlserver_ident(table)).chars().fold(
String::new(),
|mut text, character| {
if character.is_control() || matches!(character, '\u{2028}' | '\u{2029}') {
text.extend(character.escape_default());
} else {
text.push(character);
}
text
},
);
ddl.insert_str(0, &format!("-- History table for {parent}\n"));
}
}
let col_lines: Vec<String> = columns
.iter()
.map(|c| {
// A computed column can never have a default, and its `data_type`
// is only the derived result type: rendering either would emit a
// table that differs from the one being scripted.
if let Some(clause) =
computed_clauses.get(&c.name).map(String::as_str).map(str::trim).filter(|clause| !clause.is_empty())
if let Some(clause) = details
.computed_clauses
.and_then(|clauses| clauses.get(&c.name))
.map(String::as_str)
.map(str::trim)
.filter(|clause| !clause.is_empty())
{
let mut line = format!(" {} {clause}", sqlserver_ident(&c.name));
if !c.is_nullable {
@@ -14967,6 +15092,9 @@ pub fn render_sqlserver_table_ddl_with_computed(
if let Some(identity) = sqlserver_identity_clause(c.extra.as_deref()) {
line.push_str(&format!(" {identity}"));
}
if let Some(clause) = details.generated_clauses.and_then(|clauses| clauses.get(&c.name)) {
line.push_str(&format!(" {clause}"));
}
if !c.is_nullable {
line.push_str(" NOT NULL");
}
@@ -15007,7 +15135,34 @@ pub fn render_sqlserver_table_ddl_with_computed(
ref_columns
));
}
ddl.push_str("\n);\n");
if let Some(temporal) = details.temporal {
if let (Some(start), Some(end)) = (&temporal.start_column, &temporal.end_column) {
ddl.push_str(&format!(
",\n PERIOD FOR SYSTEM_TIME ({}, {})",
sqlserver_ident(start),
sqlserver_ident(end)
));
}
}
ddl.push_str("\n)");
if let Some(temporal) = details.temporal.filter(|metadata| metadata.temporal_type == 2) {
if let (Some(schema), Some(table)) = (&temporal.history_schema, &temporal.history_table) {
ddl.push_str(&format!(
" WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = {}.{}",
sqlserver_ident(schema),
sqlserver_ident(table)
));
if let (Some(period), Some(unit)) =
(temporal.retention_period.filter(|period| *period >= 0), temporal.retention_unit.as_deref())
{
if matches!(unit, "DAY" | "WEEK" | "MONTH" | "YEAR") {
ddl.push_str(&format!(", HISTORY_RETENTION_PERIOD = {period} {unit}"));
}
}
ddl.push_str("))");
}
}
ddl.push_str(";\n");
if let Some(comment) = table_comment.filter(|comment| !comment.trim().is_empty()) {
ddl.push_str(&format!(
@@ -0,0 +1,127 @@
use super::{render_sqlserver_table_ddl_details, sqlserver_temporal_period_mismatches, SqlServerDdlDetails};
use crate::db::{sqlserver::SqlServerTemporalTableMetadata, ColumnInfo};
use std::collections::HashMap;
fn metadata() -> SqlServerTemporalTableMetadata {
SqlServerTemporalTableMetadata {
temporal_type: 2,
start_column: Some("ValidFrom".into()),
end_column: Some("ValidTo".into()),
history_schema: Some("History".into()),
history_table: Some("Versions".into()),
parent_schema: None,
parent_table: None,
retention_period: None,
retention_unit: None,
}
}
fn render(temporal: &SqlServerTemporalTableMetadata) -> String {
let columns = [
ColumnInfo { name: "id".into(), data_type: "int".into(), is_primary_key: true, ..Default::default() },
ColumnInfo { name: "ValidFrom".into(), data_type: "datetime2(7)".into(), ..Default::default() },
ColumnInfo { name: "ValidTo".into(), data_type: "datetime2(7)".into(), ..Default::default() },
];
let generated = HashMap::from([
("ValidFrom".into(), "GENERATED ALWAYS AS ROW START HIDDEN".into()),
("ValidTo".into(), "GENERATED ALWAYS AS ROW END".into()),
]);
render_sqlserver_table_ddl_details(
"dbo",
"Current",
&columns,
&[],
&[],
None,
SqlServerDdlDetails { generated_clauses: Some(&generated), temporal: Some(temporal), ..Default::default() },
)
}
#[test]
fn period_columns_without_generated_clauses_are_not_a_mismatch() {
let temporal = metadata();
let unrelated = HashMap::from([("unrelated".to_string(), "GENERATED ALWAYS AS ROW END".to_string())]);
assert!(!sqlserver_temporal_period_mismatches(&temporal, &unrelated));
assert!(!sqlserver_temporal_period_mismatches(&temporal, &HashMap::new()));
}
#[test]
fn generated_clause_with_wrong_period_kind_is_a_mismatch() {
let temporal = metadata();
let swapped = HashMap::from([
("ValidFrom".to_string(), "GENERATED ALWAYS AS ROW END".to_string()),
("ValidTo".to_string(), "GENERATED ALWAYS AS ROW START".to_string()),
]);
assert!(sqlserver_temporal_period_mismatches(&temporal, &swapped));
let correct = HashMap::from([
("ValidFrom".to_string(), "GENERATED ALWAYS AS ROW START HIDDEN".to_string()),
("ValidTo".to_string(), "GENERATED ALWAYS AS ROW END".to_string()),
]);
assert!(!sqlserver_temporal_period_mismatches(&temporal, &correct));
}
#[test]
fn current_temporal_ddl_keeps_period_generation_visibility_and_history_identity() {
let ddl = render(&metadata());
assert!(ddl.contains("[ValidFrom] datetime2(7) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL"));
assert!(ddl.contains("[ValidTo] datetime2(7) GENERATED ALWAYS AS ROW END NOT NULL"));
assert!(ddl.contains("PERIOD FOR SYSTEM_TIME ([ValidFrom], [ValidTo])"));
assert!(ddl.contains("WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [History].[Versions]))"));
}
#[test]
fn disabled_versioning_keeps_the_period_without_reenabling_history() {
let mut temporal = metadata();
temporal.temporal_type = 0;
temporal.history_schema = None;
temporal.history_table = None;
let ddl = render(&temporal);
assert!(ddl.contains("PERIOD FOR SYSTEM_TIME ([ValidFrom], [ValidTo])"));
assert!(!ddl.contains("SYSTEM_VERSIONING"));
}
#[test]
fn history_retention_is_explicit_only_for_finite_periods() {
let mut temporal = metadata();
temporal.retention_period = Some(6);
temporal.retention_unit = Some("MONTH".into());
assert!(render(&temporal).contains("HISTORY_RETENTION_PERIOD = 6 MONTH"));
temporal.retention_period = Some(-1);
temporal.retention_unit = Some("INFINITE".into());
assert!(!render(&temporal).contains("HISTORY_RETENTION_PERIOD"));
}
#[test]
fn qualified_history_identifiers_escape_brackets() {
let mut temporal = metadata();
temporal.history_schema = Some("His]t".into());
temporal.history_table = Some("V]ersions".into());
assert!(render(&temporal).contains("HISTORY_TABLE = [His]]t].[V]]ersions]"));
}
#[test]
fn history_relationship_comment_cannot_turn_identifier_newlines_into_sql() {
let temporal = SqlServerTemporalTableMetadata {
temporal_type: 1,
start_column: None,
end_column: None,
history_schema: None,
history_table: None,
parent_schema: Some("dbo".into()),
parent_table: Some("Current\nDROP TABLE victim;--".into()),
retention_period: None,
retention_unit: None,
};
let ddl = render_sqlserver_table_ddl_details(
"History",
"Versions",
&[ColumnInfo { name: "id".into(), data_type: "int".into(), ..Default::default() }],
&[],
&[],
None,
SqlServerDdlDetails { temporal: Some(&temporal), ..Default::default() },
);
assert!(ddl.starts_with("-- History table for [dbo].[Current\\nDROP TABLE victim;--]\nCREATE TABLE"));
let statements = sqlparser::parser::Parser::parse_sql(&sqlparser::dialect::MsSqlDialect {}, &ddl).unwrap();
assert_eq!(statements.len(), 1);
}
@@ -0,0 +1,110 @@
use dbx_core::connection::AppState;
use dbx_core::models::connection::ConnectionConfig;
use dbx_core::query::execute_sql_statement;
use dbx_core::storage::Storage;
use std::sync::Arc;
fn quote(value: &str) -> String {
format!("[{}]", value.replace(']', "]]"))
}
#[tokio::test]
#[ignore = "requires writable DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD"]
async fn live_sqlserver_temporal_display_ddl_replays_period_history_and_disabled_versioning() {
let dir = tempfile::tempdir().unwrap();
let state = Arc::new(AppState::new(Storage::open(&dir.path().join("dbx.db")).await.unwrap()));
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id":"temporal-ddl", "name":"Temporal DDL regression", "db_type":"sqlserver",
"host":std::env::var("DBX_LIVE_SQLSERVER_HOST").unwrap_or_else(|_|"127.0.0.1".into()),
"port":std::env::var("DBX_LIVE_SQLSERVER_PORT").unwrap_or_else(|_|"1433".into()).parse::<u16>().unwrap(),
"username":std::env::var("DBX_LIVE_SQLSERVER_USER").unwrap_or_else(|_|"sa".into()),
"password":std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("live SQL Server password"),
"database":"master", "connect_timeout_secs":15, "query_timeout_secs":60
}))
.unwrap();
state.configs.write().await.insert(config.id.clone(), config.clone());
let suffix = uuid::Uuid::new_v4().simple().to_string();
let source = format!("dbx_temporal_{suffix}");
let restored = format!("dbx_temporal_restore_{suffix}");
let history_schema = "His]t_2209";
let table = "Current]Table";
let history = "Archive]Table";
let table_ref = format!("[dbo].{}", quote(table));
let history_ref = format!("{}.{}", quote(history_schema), quote(history));
for database in [&source, &restored] {
execute_sql_statement(
&state,
&config.id,
"master",
&format!("CREATE DATABASE {}", quote(database)),
None,
None,
)
.await
.unwrap();
execute_sql_statement(
&state,
&config.id,
database,
&format!("CREATE SCHEMA {}", quote(history_schema)),
None,
None,
)
.await
.unwrap();
}
let result = async {
let create = format!("CREATE TABLE {table_ref} ([id] int NOT NULL PRIMARY KEY,[payload] nvarchar(50),[Start]]At] datetime2(7) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL DEFAULT SYSUTCDATETIME(),[End.At] datetime2(7) GENERATED ALWAYS AS ROW END NOT NULL DEFAULT CONVERT(datetime2(7),'9999-12-31 23:59:59.9999999'),PERIOD FOR SYSTEM_TIME ([Start]]At],[End.At])) WITH (SYSTEM_VERSIONING=ON (HISTORY_TABLE={history_ref},HISTORY_RETENTION_PERIOD=6 MONTHS))");
execute_sql_statement(&state, &config.id, &source, &create, None, None).await?;
let ddl = dbx_core::schema::get_table_display_ddl_core(&state, &config.id, &source, "dbo", table, None).await?;
assert!(ddl.contains("GENERATED ALWAYS AS ROW START HIDDEN"), "{ddl}");
assert!(ddl.contains("GENERATED ALWAYS AS ROW END NOT NULL"), "{ddl}");
assert!(ddl.contains("PERIOD FOR SYSTEM_TIME ([Start]]At], [End.At])"), "{ddl}");
assert!(ddl.contains(&format!("HISTORY_TABLE = {history_ref}")), "{ddl}");
assert!(ddl.contains("HISTORY_RETENTION_PERIOD = 6 MONTH"), "{ddl}");
// A history definition advertises the reverse relationship. Replaying it
// first lets the current-table script bind to the same qualified history.
let history_ddl = dbx_core::schema::get_table_display_ddl_core(&state, &config.id, &source, history_schema, history, None).await?;
assert!(history_ddl.starts_with(&format!("-- History table for {table_ref}\n")), "{history_ddl}");
execute_sql_statement(&state, &config.id, &restored, &history_ddl, None, None).await?;
execute_sql_statement(&state, &config.id, &restored, &ddl, None, None).await?;
let catalog = execute_sql_statement(&state, &config.id, &restored,
"SELECT CAST(t.temporal_type AS int),s.name,h.name,cs.name,ce.name,CAST(t.history_retention_period AS int),t.history_retention_period_unit_desc FROM sys.tables t JOIN sys.periods p ON p.object_id=t.object_id JOIN sys.tables h ON h.object_id=t.history_table_id JOIN sys.schemas s ON s.schema_id=h.schema_id JOIN sys.columns cs ON cs.object_id=p.object_id AND cs.column_id=p.start_column_id JOIN sys.columns ce ON ce.object_id=p.object_id AND ce.column_id=p.end_column_id WHERE t.temporal_type=2", None, None).await?;
assert_eq!(catalog.rows[0], vec![serde_json::json!(2),serde_json::json!(history_schema),serde_json::json!(history),serde_json::json!("Start]At"),serde_json::json!("End.At"),serde_json::json!(6),serde_json::json!("MONTH")]);
execute_sql_statement(&state, &config.id, &restored, &format!("INSERT INTO {table_ref}([id],[payload]) VALUES(1,N'first')"), None, None).await?;
execute_sql_statement(&state, &config.id, &restored, &format!("UPDATE {table_ref} SET [payload]=N'second' WHERE [id]=1"), None, None).await?;
let rows = execute_sql_statement(&state, &config.id, &restored, &format!("SELECT [payload] FROM {history_ref}"), None, None).await?;
assert_eq!(rows.rows[0][0], serde_json::json!("first"));
// Basic/portable definitions must not attach a clone to a source history table.
let portable = dbx_core::schema::get_table_export_ddl_core(&state, &config.id, &source, "dbo", table, None).await?;
assert!(!portable.contains("PERIOD FOR SYSTEM_TIME"));
assert!(!portable.contains("SYSTEM_VERSIONING"));
execute_sql_statement(&state, &config.id, &source, &format!("ALTER TABLE {table_ref} SET (SYSTEM_VERSIONING=OFF)"), None, None).await?;
let disabled = dbx_core::schema::get_table_display_ddl_core(&state, &config.id, &source, "dbo", table, None).await?;
assert!(disabled.contains("PERIOD FOR SYSTEM_TIME"), "{disabled}");
assert!(!disabled.contains("SYSTEM_VERSIONING"), "{disabled}");
execute_sql_statement(&state, &config.id, &restored, &format!("ALTER TABLE {table_ref} SET (SYSTEM_VERSIONING=OFF); DROP TABLE {table_ref}"), None, None).await?;
execute_sql_statement(&state, &config.id, &restored, &disabled, None, None).await?;
let state_after = execute_sql_statement(&state, &config.id, &restored, "SELECT CAST(t.temporal_type AS int) FROM sys.tables t JOIN sys.periods p ON p.object_id=t.object_id", None, None).await?;
assert_eq!(state_after.rows[0][0], serde_json::json!(0));
Ok::<(),String>(())
}.await;
for database in [&source, &restored] {
execute_sql_statement(
&state,
&config.id,
"master",
&format!(
"ALTER DATABASE {} SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE {}",
quote(database),
quote(database)
),
None,
None,
)
.await
.unwrap();
}
state.shutdown(std::time::Duration::from_secs(1)).await;
result.expect("temporal DDL live regression");
}
@@ -205,6 +205,19 @@ pub struct SqlServerColumnMetadata {
pub computed_clause: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SqlServerTemporalTableMetadata {
pub temporal_type: i32,
pub start_column: Option<String>,
pub end_column: Option<String>,
pub history_schema: Option<String>,
pub history_table: Option<String>,
pub parent_schema: Option<String>,
pub parent_table: Option<String>,
pub retention_period: Option<i32>,
pub retention_unit: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct SqlServerCompletionContext {
pub default_schema: String,
@@ -2928,6 +2941,75 @@ pub async fn get_column_metadata(
Ok(rows.iter().map(sqlserver_column_metadata_from_row).collect())
}
pub async fn get_temporal_table_metadata(
client: &mut SqlServerClient,
schema: &str,
table: &str,
) -> Result<Option<SqlServerTemporalTableMetadata>, String> {
// Dynamic SQL keeps pre-2016 servers from binding the newer catalog columns.
// Retention metadata was added later, so it has its own feature check.
let sql = r#"IF OBJECT_ID(N'sys.periods') IS NOT NULL
BEGIN
DECLARE @retention nvarchar(max) = N'CAST(NULL AS int), CAST(NULL AS nvarchar(30))';
IF COL_LENGTH(N'sys.tables', N'history_retention_period') IS NOT NULL
SET @retention = N'CAST(t.history_retention_period AS int), t.history_retention_period_unit_desc';
DECLARE @sql nvarchar(max) = N'
SELECT CAST(t.temporal_type AS int), cs.name, ce.name,
hs.name, h.name, ps.name, parent.name, ' + @retention + N'
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
LEFT JOIN sys.periods p ON p.object_id = t.object_id
LEFT JOIN sys.columns cs ON cs.object_id = p.object_id AND cs.column_id = p.start_column_id
LEFT JOIN sys.columns ce ON ce.object_id = p.object_id AND ce.column_id = p.end_column_id
LEFT JOIN sys.tables h ON h.object_id = t.history_table_id
LEFT JOIN sys.schemas hs ON hs.schema_id = h.schema_id
LEFT JOIN sys.tables parent ON parent.history_table_id = t.object_id
LEFT JOIN sys.schemas ps ON ps.schema_id = parent.schema_id
WHERE s.name = COALESCE(NULLIF(@schema, N''''), SCHEMA_NAME()) AND t.name = @table';
EXEC sys.sp_executesql @sql, N'@schema nvarchar(128), @table nvarchar(128)', @schema=@P1, @table=@P2;
END"#;
let rows = client
.query(sql, &[&schema, &table])
.await
.map_err(|error| error.to_string())?
.into_first_result()
.await
.map_err(|error| error.to_string())?;
let Some(row) = rows.first() else { return Ok(None) };
let metadata = SqlServerTemporalTableMetadata {
temporal_type: row.get::<i32, _>(0).unwrap_or(0),
start_column: row.get::<&str, _>(1).map(str::to_string),
end_column: row.get::<&str, _>(2).map(str::to_string),
history_schema: row.get::<&str, _>(3).map(str::to_string),
history_table: row.get::<&str, _>(4).map(str::to_string),
parent_schema: row.get::<&str, _>(5).map(str::to_string),
parent_table: row.get::<&str, _>(6).map(str::to_string),
retention_period: row.get::<i32, _>(7),
retention_unit: row.get::<&str, _>(8).map(str::to_string),
};
if metadata.temporal_type == 0 && metadata.start_column.is_none() && metadata.end_column.is_none() {
return Ok(None);
}
if metadata.temporal_type == 2
&& (metadata.start_column.is_none()
|| metadata.end_column.is_none()
|| metadata.history_schema.is_none()
|| metadata.history_table.is_none())
{
return Err("SQL Server temporal table metadata is incomplete".to_string());
}
if metadata.temporal_type == 1 && (metadata.parent_schema.is_none() || metadata.parent_table.is_none()) {
return Err("SQL Server history table relationship metadata is incomplete".to_string());
}
if metadata.temporal_type == 2
&& metadata.retention_period.is_some_and(|period| period >= 0)
&& !metadata.retention_unit.as_deref().is_some_and(|unit| matches!(unit, "DAY" | "WEEK" | "MONTH" | "YEAR"))
{
return Err("SQL Server temporal history retention metadata is unavailable or unsupported".to_string());
}
Ok(Some(metadata))
}
fn sqlserver_column_metadata_from_row(row: &Row) -> SqlServerColumnMetadata {
let base = row.get::<&str, _>(1).unwrap_or("").to_string();
let max_len = row
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB