feat(sqlserver): temporal table DDL and history links
This commit is contained in:
7 files changed
+486
-12
No files matched your search
@@ -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 |
Reference in new issue
Block a user