From 219cbb4fe2949470bb98810a449d12918b635ed4 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Wed, 30 Sep 2026 23:28:30 +0000 Subject: [PATCH 1/7] feat(orm): add owned runtime migration targets and guarded application --- crates/graphql-orm/src/graphql/orm/core.rs | 663 +------ .../graphql-orm/src/graphql/orm/execution.rs | 221 ++- .../graphql-orm/src/graphql/orm/migrations.rs | 495 +++--- crates/graphql-orm/src/graphql/orm/mod.rs | 4 + .../src/graphql/orm/owned_schema.rs | 1545 +++++++++++++++++ .../src/graphql/orm/runtime_migration.rs | 1252 +++++++++++++ .../src/graphql/orm/schema_manager.rs | 92 +- .../graphql-orm/tests/runtime_migrations.rs | 469 +++++ 8 files changed, 3847 insertions(+), 894 deletions(-) create mode 100644 crates/graphql-orm/src/graphql/orm/owned_schema.rs create mode 100644 crates/graphql-orm/src/graphql/orm/runtime_migration.rs create mode 100644 crates/graphql-orm/tests/runtime_migrations.rs diff --git a/crates/graphql-orm/src/graphql/orm/core.rs b/crates/graphql-orm/src/graphql/orm/core.rs index cbfe6dcf..c5e4cbd7 100644 --- a/crates/graphql-orm/src/graphql/orm/core.rs +++ b/crates/graphql-orm/src/graphql/orm/core.rs @@ -3251,21 +3251,7 @@ pub struct IndexDef { impl PartialEq for IndexDef { fn eq(&self, other: &Self) -> bool { - let self_directions_valid = - self.column_directions.is_empty() || self.column_directions.len() == self.columns.len(); - let other_directions_valid = other.column_directions.is_empty() - || other.column_directions.len() == other.columns.len(); - self.name == other.name - && self.columns == other.columns - && self_directions_valid - && other_directions_valid - && self.is_unique == other.is_unique - && self.method == other.method - && self.is_spatial == other.is_spatial - && self.predicate == other.predicate - && self.columns.len() == other.columns.len() - && (0..self.columns.len()) - .all(|index| self.direction_at(index) == other.direction_at(index)) + super::owned_schema::IndexRef::from(self) == super::owned_schema::IndexRef::from(other) } } @@ -3651,7 +3637,7 @@ fn sanitize_generated_index_part(value: &str) -> String { } } -fn generated_index_name(table_name: &str, columns: &[&'static str]) -> String { +pub(super) fn generated_index_name(table_name: &str, columns: &[&str]) -> String { let column_part = columns .iter() .map(|column| sanitize_generated_index_part(column)) @@ -3671,77 +3657,6 @@ fn generated_index_name(table_name: &str, columns: &[&'static str]) -> String { format!("{}_{}", &full[..prefix_len], hash) } -fn static_index_name(name: String) -> &'static str { - Box::leak(name.into_boxed_str()) -} - -fn static_index_columns(columns: &[&'static str]) -> &'static [&'static str] { - Box::leak(columns.to_vec().into_boxed_slice()) -} - -fn index_columns_match(left: &[&str], right: &[&str]) -> bool { - left.len() == right.len() - && left - .iter() - .zip(right.iter()) - .all(|(left, right)| left == right) -} - -fn table_has_index_for_columns(table: &TableModel, columns: &[&'static str]) -> bool { - if columns.is_empty() { - return true; - } - - if table.primary_keys().len() == columns.len() - && table - .primary_keys() - .iter() - .zip(columns.iter()) - .all(|(left, right)| left == right) - { - return true; - } - - if columns.len() == 1 - && table - .columns - .iter() - .any(|column| column.name == columns[0] && (column.is_primary_key || column.is_unique)) - { - return true; - } - - if table.composite_unique_indexes.iter().any(|index| { - index_columns_match( - &index.iter().map(String::as_str).collect::>(), - columns, - ) - }) { - return true; - } - - table - .indexes - .iter() - .any(|index| index_columns_match(index.columns, columns)) -} - -fn add_generated_index(table: &mut TableModel, columns: &[&'static str]) { - if table_has_index_for_columns(table, columns) { - return; - } - - let name = generated_index_name(&table.table_name, columns); - if table.indexes.iter().any(|index| index.name == name) { - return; - } - - table.indexes.push(IndexDef::new( - static_index_name(name), - static_index_columns(columns), - )); -} - #[derive(Clone, Debug, PartialEq)] pub struct SchemaModel { pub extensions: Vec, @@ -3759,144 +3674,7 @@ impl SchemaModel { /// ordered source/target members are missing, type-incompatible, duplicate, /// or not backed by an exact target primary/unique key. pub fn validate_physical_contract(&self) -> crate::Result<()> { - let tables = self - .tables - .iter() - .map(|table| (table.table_name.as_str(), table)) - .collect::>(); - for table in &self.tables { - for index in &table.indexes { - if index.name.trim().is_empty() - || index.columns.is_empty() - || (!index.column_directions.is_empty() - && index.column_directions.len() != index.columns.len()) - { - return Err(sqlx::Error::Protocol(format!( - "invalid index column metadata for {}.{}", - table.table_name, index.name - ))); - } - let mut index_members = std::collections::BTreeSet::new(); - for column_name in index.columns { - if !index_members.insert(*column_name) - || !table - .columns - .iter() - .any(|column| column.name == *column_name) - { - return Err(sqlx::Error::Protocol(format!( - "index {}.{} contains a duplicate or missing column {}", - table.table_name, index.name, column_name - ))); - } - } - } - let mut foreign_key_identities = std::collections::BTreeSet::new(); - for foreign_key in &table.foreign_keys { - if foreign_key - .constraint_name - .as_deref() - .is_some_and(|name| name.trim().is_empty()) - || foreign_key.column_pairs.is_empty() - { - return Err(sqlx::Error::Protocol(format!( - "foreign key on {} has invalid constraint identity or no column pairs", - table.table_name - ))); - } - if !foreign_key_identities.insert(( - foreign_key.column_pairs.clone(), - foreign_key.target_table.clone(), - foreign_key.on_delete.clone(), - )) { - return Err(sqlx::Error::Protocol(format!( - "table {} contains duplicate semantic foreign keys", - table.table_name - ))); - } - let Some(target_table) = tables.get(foreign_key.target_table.as_str()) else { - return Err(sqlx::Error::Protocol(format!( - "foreign key on {} references unmanaged target table {}", - table.table_name, foreign_key.target_table - ))); - }; - let mut source_members = std::collections::BTreeSet::new(); - let mut target_members = std::collections::BTreeSet::new(); - for pair in &foreign_key.column_pairs { - if !source_members.insert(pair.source_column.as_str()) - || !target_members.insert(pair.target_column.as_str()) - { - return Err(sqlx::Error::Protocol(format!( - "foreign key on {} contains duplicate column members", - table.table_name - ))); - } - let source = table - .columns - .iter() - .find(|column| column.name == pair.source_column) - .ok_or_else(|| { - sqlx::Error::Protocol(format!( - "foreign key on {} references missing source column {}", - table.table_name, pair.source_column - )) - })?; - let target = target_table - .columns - .iter() - .find(|column| column.name == pair.target_column) - .ok_or_else(|| { - sqlx::Error::Protocol(format!( - "foreign key on {} references missing target column {}.{}", - table.table_name, target_table.table_name, pair.target_column - )) - })?; - if !source.sql_type.eq_ignore_ascii_case(&target.sql_type) { - return Err(sqlx::Error::Protocol(format!( - "foreign key type mismatch between {}.{} ({}) and {}.{} ({})", - table.table_name, - pair.source_column, - source.sql_type, - target_table.table_name, - pair.target_column, - target.sql_type - ))); - } - if foreign_key.on_delete == DeletePolicy::SetNull && !source.nullable { - return Err(sqlx::Error::Protocol(format!( - "SET NULL foreign key source {}.{} is not nullable", - table.table_name, pair.source_column - ))); - } - } - let target_columns = foreign_key.target_columns().collect::>(); - let target_is_unique = target_table - .primary_keys() - .iter() - .map(String::as_str) - .eq(target_columns.iter().copied()) - || (target_columns.len() == 1 - && target_table.columns.iter().any(|column| { - column.name == target_columns[0] - && (column.is_primary_key || column.is_unique) - })) - || target_table.composite_unique_indexes.iter().any(|columns| { - columns.iter().map(String::as_str).collect::>() == target_columns - }) - || target_table.indexes.iter().any(|index| { - index.is_unique - && index.predicate.is_none() - && index.columns == target_columns.as_slice() - }); - if !target_is_unique { - return Err(sqlx::Error::Protocol(format!( - "foreign key on {} does not reference an exact unique key on {}", - table.table_name, target_table.table_name - ))); - } - } - } - Ok(()) + super::owned_schema::SchemaRef::from(self).validate_physical_contract() } } @@ -4295,212 +4073,13 @@ pub struct PlannedSchemaStage { impl From<&EntityMetadata> for TableModel { fn from(value: &EntityMetadata) -> Self { - let primary_keys = value - .primary_keys - .iter() - .map(|column| (*column).to_string()) - .collect::>(); - let mut table = Self { - entity_name: value.entity_name.to_string(), - table_name: value.table_name.to_string(), - primary_key: value.primary_key.to_string(), - primary_keys, - default_sort: value.default_sort.to_string(), - columns: value - .fields - .iter() - .map(|field| ColumnModel { - name: field.name.to_string(), - sql_type: field.sql_type.to_string(), - spatial: field.spatial, - nullable: field.nullable, - is_primary_key: field.is_primary_key, - is_unique: field.is_unique, - default: field - .default - .map(super::dialect::canonicalize_column_default_expression), - }) - .collect(), - indexes: value.indexes.iter().cloned().collect(), - composite_unique_indexes: value - .composite_unique_indexes - .iter() - .map(|columns| columns.iter().map(|column| (*column).to_string()).collect()) - .collect(), - foreign_keys: value - .relations - .iter() - .filter(|relation| relation.emit_foreign_key) - .map(|relation| { - let column_pairs = relation - .source_columns - .iter() - .zip(relation.target_columns.iter()) - .map(|(source_field, target_column)| { - let source_column = value - .fields - .iter() - .find(|field| field.rust_name == *source_field) - .map_or(*source_field, |field| field.name); - ForeignKeyColumnPairModel { - source_column: source_column.to_string(), - target_column: (*target_column).to_string(), - } - }) - .collect(); - ForeignKeyModel { - constraint_name: None, - column_pairs, - target_table: relation.target_type.to_string(), - is_multiple: relation.is_multiple, - on_delete: relation.on_delete.clone(), - } - }) - .collect(), - search_indexes: value - .search - .filter(|index| index.enabled) - .map(|index| { - vec![SearchIndexModel { - name: index.index_name.to_string(), - table_name: index.table_name.to_string(), - entity_name: index.entity_name.to_string(), - primary_key: index.primary_key.to_string(), - strategy: index.strategy, - language: index.language.to_string(), - tokenizer: index.tokenizer.to_string(), - min_token_len: index.min_token_len, - fallback_enabled: index.fallback_enabled, - fields: index - .fields - .iter() - .map(|field| SearchFieldModel { - field_name: field.field_name.to_string(), - column_name: field.column_name.to_string(), - weight: field.weight, - alias: field.alias.map(str::to_string), - policy: field.policy.map(str::to_string), - }) - .collect(), - json_paths: index - .json_paths - .iter() - .map(|json_path| SearchJsonPathModel { - field_name: json_path.field_name.to_string(), - column_name: json_path.column_name.to_string(), - path: json_path.path.to_string(), - weight: json_path.weight, - policy: json_path.policy.map(str::to_string), - }) - .collect(), - relations: index - .relations - .iter() - .map(|relation| SearchRelationFieldModel { - relation_field: relation.relation_field.to_string(), - target_type: relation.target_type.to_string(), - fields: relation - .fields - .iter() - .map(|field| (*field).to_string()) - .collect(), - weight: relation.weight, - max_items: relation.max_items, - policy: relation.policy.map(str::to_string), - }) - .collect(), - }] - }) - .unwrap_or_default(), - append_only: value.append_only, - retention_purge: value.retention_policy.is_some(), - check_constraints: value - .check_constraints - .iter() - .map(|constraint| CheckConstraintModel { - name: constraint.name.to_string(), - expression: constraint.expression.to_string(), - }) - .collect(), - }; - table.check_constraints.sort(); - - for field in &value.fields { - if field.is_filterable && field.spatial.is_none() { - add_generated_index(&mut table, &[field.name]); - } - } - - table + super::owned_schema::OwnedTableModel::from(value).into_legacy() } } impl SchemaModel { pub fn from_entities(entities: &[&EntityMetadata]) -> Self { - let entity_table_names = entities - .iter() - .map(|entity| (entity.entity_name, entity.table_name)) - .collect::>(); - let mut extensions = Vec::new(); - if entities - .iter() - .flat_map(|entity| entity.fields.iter()) - .any(|field| field.spatial.is_some()) - { - extensions.push("postgis".to_string()); - } - - let mut tables = entities - .iter() - .map(|entity| { - let mut table = TableModel::from(*entity); - for foreign_key in &mut table.foreign_keys { - if let Some(table_name) = - entity_table_names.get(foreign_key.target_table.as_str()) - { - foreign_key.target_table = (*table_name).to_string(); - } - } - table - }) - .collect::>(); - - let table_positions = tables - .iter() - .enumerate() - .map(|(index, table)| (table.table_name.clone(), index)) - .collect::>(); - - for entity in entities { - for relation in &entity.relations { - let (table_name, columns) = if relation.is_multiple { - let Some(target_table) = entity_table_names.get(relation.target_type) else { - continue; - }; - (*target_table, relation.target_columns.to_vec()) - } else { - let source_columns = relation - .source_columns - .iter() - .map(|source_field| { - entity - .fields - .iter() - .find(|field| field.rust_name == *source_field) - .map_or(*source_field, |field| field.name) - }) - .collect::>(); - (entity.table_name, source_columns) - }; - - let Some(table_index) = table_positions.get(table_name) else { - continue; - }; - add_generated_index(&mut tables[*table_index], &columns); - } - } - - Self { extensions, tables } + super::owned_schema::OwnedSchemaModel::from_entities(entities).into_legacy() } } @@ -4695,237 +4274,7 @@ pub fn stable_schema_hash(entities: &[EntityBackupDescriptor]) -> String { } pub fn stable_schema_model_hash(schema: &SchemaModel) -> String { - let mut canonical = String::new(); - let mut extensions = schema.extensions.iter().collect::>(); - extensions.sort(); - for extension in extensions { - canonical.push_str("extension:"); - canonical.push_str(extension); - canonical.push('\n'); - } - - let mut tables = schema.tables.iter().collect::>(); - tables.sort_by(|left, right| left.table_name.cmp(&right.table_name)); - - for table in tables { - canonical.push_str("table:"); - canonical.push_str(&table.entity_name); - canonical.push('|'); - canonical.push_str(&table.table_name); - canonical.push('|'); - canonical.push_str(&table.primary_key); - canonical.push('|'); - canonical.push_str(&table.primary_keys().join(",")); - canonical.push('|'); - canonical.push_str(&table.default_sort); - canonical.push('|'); - canonical.push_str(if table.append_only { - "append_only" - } else { - "mutable" - }); - if table.retention_purge { - canonical.push_str("|retention_purge"); - } - canonical.push('\n'); - - let mut constraints = table - .check_constraints - .iter() - .map(|constraint| { - super::migrations::canonical_check_tokens(&constraint.expression) - .map(|tokens| tokens.join("|")) - .unwrap_or_else(|| constraint.expression.clone()) - }) - .collect::>(); - constraints.sort(); - for constraint in constraints { - canonical.push_str("check:"); - canonical.push_str(&constraint); - canonical.push('\n'); - } - - let mut columns = table.columns.iter().collect::>(); - columns.sort_by(|left, right| left.name.cmp(&right.name)); - for column in columns { - canonical.push_str("column:"); - canonical.push_str(&column.name); - canonical.push('|'); - canonical.push_str(&column.sql_type); - canonical.push('|'); - canonical.push_str(if column.nullable { - "nullable" - } else { - "not_null" - }); - canonical.push('|'); - canonical.push_str(if column.is_primary_key { - "pk" - } else { - "not_pk" - }); - canonical.push('|'); - canonical.push_str(if column.is_unique { - "unique" - } else { - "not_unique" - }); - canonical.push('|'); - canonical.push_str( - &column - .default - .as_deref() - .map(super::dialect::canonicalize_column_default_expression) - .unwrap_or_default(), - ); - canonical.push('|'); - if let Some(spatial) = column.spatial { - canonical.push_str(spatial.kind.as_sql()); - canonical.push(':'); - canonical.push_str(spatial.geometry_type.as_sql()); - canonical.push(':'); - canonical.push_str(&spatial.srid.to_string()); - } - canonical.push('\n'); - } - - let mut indexes = table.indexes.iter().collect::>(); - indexes.sort_by(|left, right| left.name.cmp(right.name)); - for index in indexes { - canonical.push_str("index:"); - canonical.push_str(index.name); - canonical.push('|'); - canonical.push_str(&index.columns.join(",")); - canonical.push('|'); - for position in 0..index.columns.len() { - canonical.push_str(index.direction_at(position).as_sql()); - canonical.push(','); - } - canonical.push('|'); - canonical.push_str(if index.is_unique { "unique" } else { "index" }); - canonical.push('|'); - canonical.push_str(match index.method { - IndexMethod::Default => "default", - IndexMethod::Gist => "gist", - }); - canonical.push('|'); - canonical.push_str(if index.is_spatial { - "spatial" - } else { - "not_spatial" - }); - canonical.push('|'); - if let Some(predicate) = &index.predicate { - canonical.push_str("where_in:"); - canonical.push_str(predicate.column); - canonical.push(':'); - canonical.push_str(&predicate.values.join(",")); - } else { - canonical.push_str("no_predicate"); - } - canonical.push('\n'); - } - - let mut search_indexes = table.search_indexes.iter().collect::>(); - search_indexes.sort_by(|left, right| left.name.cmp(&right.name)); - for index in search_indexes { - canonical.push_str("search_index:"); - canonical.push_str(&index.name); - canonical.push('|'); - canonical.push_str(index.strategy.as_str()); - canonical.push('|'); - canonical.push_str(&index.language); - canonical.push('|'); - canonical.push_str(&index.tokenizer); - canonical.push('|'); - canonical.push_str(&index.min_token_len.to_string()); - canonical.push('|'); - canonical.push_str(if index.fallback_enabled { - "fallback" - } else { - "native_only" - }); - canonical.push('\n'); - - let mut fields = index.fields.iter().collect::>(); - fields.sort_by(|left, right| left.field_name.cmp(&right.field_name)); - for field in fields { - canonical.push_str("search_field:"); - canonical.push_str(&field.field_name); - canonical.push('|'); - canonical.push_str(&field.column_name); - canonical.push('|'); - canonical.push_str(field.weight.as_str()); - canonical.push('|'); - canonical.push_str(field.alias.as_deref().unwrap_or("")); - canonical.push('|'); - canonical.push_str(field.policy.as_deref().unwrap_or("")); - canonical.push('\n'); - } - - let mut json_paths = index.json_paths.iter().collect::>(); - json_paths.sort_by(|left, right| { - left.field_name - .cmp(&right.field_name) - .then_with(|| left.path.cmp(&right.path)) - }); - for json_path in json_paths { - canonical.push_str("search_json_path:"); - canonical.push_str(&json_path.field_name); - canonical.push('|'); - canonical.push_str(&json_path.column_name); - canonical.push('|'); - canonical.push_str(&json_path.path); - canonical.push('|'); - canonical.push_str(json_path.weight.as_str()); - canonical.push('|'); - canonical.push_str(json_path.policy.as_deref().unwrap_or("")); - canonical.push('\n'); - } - - let mut relations = index.relations.iter().collect::>(); - relations.sort_by(|left, right| left.relation_field.cmp(&right.relation_field)); - for relation in relations { - canonical.push_str("search_relation:"); - canonical.push_str(&relation.relation_field); - canonical.push('|'); - canonical.push_str(&relation.target_type); - canonical.push('|'); - canonical.push_str(&relation.fields.join(",")); - canonical.push('|'); - canonical.push_str(relation.weight.as_str()); - canonical.push('|'); - canonical.push_str(&relation.max_items.to_string()); - canonical.push('|'); - canonical.push_str(relation.policy.as_deref().unwrap_or("")); - canonical.push('\n'); - } - } - - let mut foreign_keys = table.foreign_keys.iter().collect::>(); - foreign_keys.sort_by(|left, right| { - (&left.column_pairs, &left.target_table, &left.on_delete).cmp(&( - &right.column_pairs, - &right.target_table, - &right.on_delete, - )) - }); - for foreign_key in foreign_keys { - canonical.push_str("foreign_key:"); - canonical.push_str(&foreign_key.target_table); - canonical.push('|'); - for pair in &foreign_key.column_pairs { - canonical.push_str(&pair.source_column); - canonical.push_str("->"); - canonical.push_str(&pair.target_column); - canonical.push(','); - } - canonical.push_str(foreign_key.on_delete.as_sql()); - canonical.push('\n'); - } - } - - format!("{:016x}", fnv1a64(canonical.as_bytes())) + super::owned_schema::stable_schema_view_hash(&super::owned_schema::SchemaRef::from(schema)) } /// Compute the stable hash used for generated RLS schema models. diff --git a/crates/graphql-orm/src/graphql/orm/execution.rs b/crates/graphql-orm/src/graphql/orm/execution.rs index 570729b4..a97e0a82 100644 --- a/crates/graphql-orm/src/graphql/orm/execution.rs +++ b/crates/graphql-orm/src/graphql/orm/execution.rs @@ -3,6 +3,8 @@ use super::core::{ SchemaPolicy, SchemaStage, SqlValue, record_executed_query, }; use super::migrations::build_migration_plan; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +use super::runtime_migration::{OwnedPlannedMigration, RuntimeMigrationError}; use super::{MigrationBackend, OrmBackend, SqlxBackend, WriteBackend}; use std::collections::HashSet; @@ -254,8 +256,11 @@ impl MigrationBackend for super::SqliteBackend { statements, metadata, record_history, + None, ) .await + .map(|_| ()) + .map_err(runtime_error_to_legacy) } } @@ -289,8 +294,11 @@ impl MigrationBackend for super::PostgresBackend { statements, metadata, record_history, + None, ) .await + .map(|_| ()) + .map_err(runtime_error_to_legacy) } } @@ -744,11 +752,12 @@ async fn apply_sqlite_migration_statements_transactionally( statements: &[S], metadata: Option<&MigrationApplicationMetadata>, record_history: bool, -) -> crate::Result<()> + owned: Option<&OwnedPlannedMigration>, +) -> Result, RuntimeMigrationError> where S: AsRef, { - use sqlx::Acquire; + use sqlx::{Acquire, Connection}; let mut conn = pool.acquire().await?; let suspend_foreign_keys = statements.iter().any(|statement| { @@ -759,13 +768,38 @@ where }); if suspend_foreign_keys { + if owned.is_some() { + conn.close_on_drop(); + } sqlx::query("PRAGMA foreign_keys = OFF") .execute(&mut *conn) .await?; } - let mut tx = conn.begin().await?; + let mut tx = if owned.is_some() { + conn.begin_with("BEGIN IMMEDIATE").await? + } else { + conn.begin().await? + }; let migration_result = async { + if let Some(plan) = owned { + let current = super::migrations::introspect_owned_sqlite_connection(&mut tx).await?; + plan.check_baseline(¤t)?; + validate_owned_sqlite_plan(&mut tx, plan).await?; + let recorded: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {MIGRATION_HISTORY_TABLE} WHERE version = ?")) + .bind(version).fetch_one(&mut *tx).await?; + if let Some(report) = plan.recorded_version_report(recorded != 0)? { return Ok(Some(report)); } + // Controlled rebuilds must not erase host triggers, views, or reserved collision tables. + if suspend_foreign_keys { + for table in plan.ownership().tables() { + let temp_name = format!("__graphql_orm_{table}_new"); + let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?") + .bind(temp_name).fetch_one(&mut *tx).await?; + if exists != 0 { return Err(sqlx::Error::Protocol("owned rebuild temporary table collision".into()).into()); } + } + } + } + for statement in statements { let statement = statement.as_ref().trim(); if statement.is_empty() @@ -784,7 +818,7 @@ where if !violations.is_empty() { return Err(sqlx::Error::Protocol(format!( "SQLite foreign_key_check failed after controlled rebuild during migration {version}" - ))); + )).into()); } } @@ -792,12 +826,12 @@ where insert_sqlite_history_row(&mut tx, version, description, metadata).await?; } - Ok::<(), sqlx::Error>(()) + Ok::<_, RuntimeMigrationError>(None) } .await; let final_result = match migration_result { - Ok(()) => tx.commit().await, + Ok(report) => tx.commit().await.map(|_| report).map_err(Into::into), Err(error) => { let _ = tx.rollback().await; Err(error) @@ -813,6 +847,76 @@ where final_result } +#[cfg(any(feature = "sqlite", feature = "postgres"))] +fn runtime_error_to_legacy(error: RuntimeMigrationError) -> sqlx::Error { + match error { + RuntimeMigrationError::Database(error) => error, + RuntimeMigrationError::Diagnostics(error) => sqlx::Error::Protocol(error.to_string()), + } +} + +#[cfg(feature = "sqlite")] +pub(super) async fn apply_owned_sqlite_migration( + pool: &sqlx::SqlitePool, + plan: &OwnedPlannedMigration, + options: &super::ApplyOptions, +) -> Result { + let current = super::migrations::introspect_owned_sqlite_schema(pool).await?; + plan.check_baseline(¤t)?; + ensure_sqlite_migration_history_table(pool).await?; + let metadata = plan.application_metadata(); + if let Some(report) = apply_sqlite_migration_statements_transactionally( + pool, + plan.version(), + plan.description(), + plan.statements(), + Some(&metadata), + options.record_history, + Some(plan), + ) + .await? + { + return Ok(report); + } + Ok(super::AppliedMigrationReport { + version: plan.version().into(), + dry_run: false, + statements_applied: plan.statements().len(), + already_applied: false, + }) +} + +#[cfg(feature = "postgres")] +pub(super) async fn apply_owned_postgres_migration( + pool: &sqlx::PgPool, + plan: &OwnedPlannedMigration, + options: &super::ApplyOptions, +) -> Result { + let current = super::migrations::introspect_owned_postgres_schema(pool).await?; + plan.check_baseline(¤t)?; + ensure_postgres_migration_history_table(pool).await?; + let metadata = plan.application_metadata(); + if let Some(report) = apply_postgres_migration_statements_transactionally( + pool, + plan.version(), + plan.description(), + plan.statements(), + Some(&metadata), + options.record_history, + Some(plan), + ) + .await? + { + return Ok(report); + } + Ok(super::AppliedMigrationReport { + version: plan.version().into(), + dry_run: false, + statements_applied: plan.statements().len(), + already_applied: false, + }) +} + #[cfg(feature = "sqlite")] async fn insert_sqlite_history_row( tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, @@ -848,11 +952,45 @@ async fn apply_postgres_migration_statements_transactionally( statements: &[S], metadata: Option<&MigrationApplicationMetadata>, record_history: bool, -) -> crate::Result<()> + owned: Option<&OwnedPlannedMigration>, +) -> Result, RuntimeMigrationError> where S: AsRef, { let mut tx = pool.begin().await?; + if let Some(plan) = owned { + // Cooperative migration serialization plus table locks keep catalog validation + // and DDL on this pinned transaction. Hosts fence non-ORM external DDL. + sqlx::query("SELECT pg_advisory_xact_lock(718644299020133922)") + .execute(&mut *tx) + .await?; + for table in plan.ownership().tables() { + let row: Option<(bool,)> = sqlx::query_as("SELECT c.relrowsecurity OR c.relforcerowsecurity FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = current_schema() AND c.relname = $1 AND c.relkind = 'r'") + .bind(table).fetch_optional(&mut *tx).await?; + if row.is_some() { + let identifier = table.replace('"', "\"\""); + sqlx::query(&format!( + "LOCK TABLE \"{identifier}\" IN ACCESS EXCLUSIVE MODE" + )) + .execute(&mut *tx) + .await?; + } + } + let current = super::migrations::introspect_owned_postgres_connection(&mut tx).await?; + plan.check_baseline(¤t)?; + validate_owned_postgres_plan(&mut tx, plan).await?; + let recorded: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {MIGRATION_HISTORY_TABLE} WHERE version = $1" + )) + .bind(version) + .fetch_one(&mut *tx) + .await?; + if let Some(report) = plan.recorded_version_report(recorded != 0)? { + tx.commit().await?; + return Ok(Some(report)); + } + } + for statement in statements { let statement = statement.as_ref().trim(); if statement.is_empty() { @@ -866,7 +1004,7 @@ where } tx.commit().await?; - Ok(()) + Ok(None) } #[cfg(feature = "postgres")] @@ -1022,3 +1160,70 @@ fn fnv1a64(bytes: &[u8]) -> u64 { } hash } + +#[cfg(feature = "sqlite")] +pub(super) async fn validate_owned_sqlite_plan( + connection: &mut sqlx::SqliteConnection, + plan: &OwnedPlannedMigration, +) -> Result<(), RuntimeMigrationError> { + use super::runtime_migration::{RuntimeMigrationDiagnosticCode, rejection}; + let rebuilt = plan.rebuilt_sqlite_tables().collect::>(); + if !rebuilt.is_empty() { + // Without a SQL view-dependency IR, preserving arbitrary view definitions + // cannot be proven through a drop/rebuild. Reject conservatively. + let views: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'view'") + .fetch_one(&mut *connection) + .await?; + if views != 0 { + return Err(rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract).into()); + } + } + for table in rebuilt { + let rows: Vec<(String,)> = sqlx::query_as( + "SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ?", + ) + .bind(table) + .fetch_all(&mut *connection) + .await?; + let current = + super::migrations::introspect_owned_sqlite_connection(&mut *connection).await?; + let canonical_append_only = current + .tables() + .iter() + .find(|t| t.table_name() == table) + .is_some_and(|t| t.append_only()); + let name = super::migrations::append_only_name(table); + if rows.iter().any(|(trigger,)| { + !canonical_append_only + || (*trigger != format!("{name}_update") && *trigger != format!("{name}_delete")) + }) { + return Err(rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract).into()); + } + let collision: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?") + .bind(format!("__graphql_orm_{table}_new")) + .fetch_one(&mut *connection) + .await?; + if collision != 0 { + return Err(rejection(RuntimeMigrationDiagnosticCode::OwnershipMismatch).into()); + } + } + Ok(()) +} +#[cfg(feature = "postgres")] +pub(super) async fn validate_owned_postgres_plan( + connection: &mut sqlx::PgConnection, + plan: &OwnedPlannedMigration, +) -> Result<(), RuntimeMigrationError> { + use super::runtime_migration::{RuntimeMigrationDiagnosticCode, rejection}; + let protected: Vec<(String,)> = sqlx::query_as("SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=current_schema() AND c.relkind='r' AND (c.relrowsecurity OR c.relforcerowsecurity OR EXISTS(SELECT 1 FROM pg_policy p WHERE p.polrelid=c.oid))") + .fetch_all(connection).await?; + if protected + .iter() + .any(|(table,)| plan.changes_protected_rls_structure(table)) + { + return Err(rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract).into()); + } + Ok(()) +} diff --git a/crates/graphql-orm/src/graphql/orm/migrations.rs b/crates/graphql-orm/src/graphql/orm/migrations.rs index 7da8d949..fa61d69e 100644 --- a/crates/graphql-orm/src/graphql/orm/migrations.rs +++ b/crates/graphql-orm/src/graphql/orm/migrations.rs @@ -1,5 +1,7 @@ #[cfg(feature = "mssql")] use super::OrmBackend; +#[cfg(feature = "mssql")] +use super::TableModel; #[cfg(any(feature = "sqlite", feature = "postgres"))] use super::core::ForeignKeyColumnPairModel; #[cfg(feature = "postgres")] @@ -9,13 +11,17 @@ use super::core::SqlValue; use super::core::{ ColumnModel, ForeignKeyModel, IndexMethod, MigrationPlan, MigrationRisk, MigrationStep, PlannedMigrationStep, SchemaDiff, SchemaModel, SearchIndexModel, SearchIndexStrategy, - TableModel, }; #[cfg(any(feature = "sqlite", feature = "postgres"))] use super::core::{SearchFieldModel, SearchJsonPathModel, SearchRelationFieldModel, SearchWeight}; #[cfg(feature = "postgres")] use super::core::{SpatialColumnDef, SpatialGeometryType}; use super::dialect::{DatabaseBackend, SqlDialect}; +#[cfg(any(feature = "sqlite", feature = "postgres"))] +use super::owned_schema::{IndexModel, IndexPredicateModel, OwnedSchemaModel, OwnedTableModel}; +use super::owned_schema::{ + IndexRef, OwnedMigrationPlan, OwnedMigrationStep, SchemaRef, StepRef, TableRef, +}; use super::query::PoolProvider; #[cfg(feature = "postgres")] use super::rls::{LiveRlsPolicy, LiveRlsTable}; @@ -395,13 +401,13 @@ fn render_column_definition( parts.join(" ") } -fn render_create_table_statement(backend: DatabaseBackend, table: &TableModel) -> String { +fn render_create_table_statement(backend: DatabaseBackend, table: &TableRef<'_>) -> String { render_create_table_statement_for_name(backend, table, &table.table_name) } fn render_create_table_statement_for_name( backend: DatabaseBackend, - table: &TableModel, + table: &TableRef<'_>, table_name: &str, ) -> String { let has_composite_primary_key = table.primary_keys().len() > 1; @@ -443,7 +449,7 @@ fn render_create_table_statement_for_name( format!("CREATE TABLE {} ({})", table_name, parts.join(", ")) } -fn append_only_name(table_name: &str) -> String { +pub(super) fn append_only_name(table_name: &str) -> String { let sanitized = table_name .chars() .map(|ch| { @@ -559,7 +565,7 @@ fn decode_constraint_comment(comment: &str) -> Option { String::from_utf8(bytes).ok() } -fn render_constraint_comments(backend: DatabaseBackend, table: &TableModel) -> Vec { +fn render_constraint_comments(backend: DatabaseBackend, table: &TableRef<'_>) -> Vec { if backend != DatabaseBackend::Postgres { return Vec::new(); } @@ -577,7 +583,7 @@ fn render_constraint_comments(backend: DatabaseBackend, table: &TableModel) -> V .collect() } -fn column_changed_for_backend( +pub(super) fn column_changed_for_backend( backend: DatabaseBackend, before: &ColumnModel, after: &ColumnModel, @@ -610,14 +616,14 @@ fn column_changed_for_backend( } fn order_tables_by_foreign_keys<'a>( - tables: &std::collections::BTreeMap, -) -> Vec<&'a TableModel> { + tables: &std::collections::BTreeMap>, +) -> Vec<&'a TableRef<'a>> { fn visit<'a>( table_name: &str, - tables: &std::collections::BTreeMap, + tables: &std::collections::BTreeMap>, visiting: &mut std::collections::BTreeSet, visited: &mut std::collections::BTreeSet, - ordered: &mut Vec<&'a TableModel>, + ordered: &mut Vec<&'a TableRef<'a>>, ) { if visited.contains(table_name) || visiting.contains(table_name) { return; @@ -627,7 +633,7 @@ fn order_tables_by_foreign_keys<'a>( }; visiting.insert(table_name.to_string()); - for foreign_key in &table.foreign_keys { + for foreign_key in table.foreign_keys { if tables.contains_key(&foreign_key.target_table) { visit( &foreign_key.target_table, @@ -667,6 +673,18 @@ pub fn diff_schema_models_for_backend( current: &SchemaModel, target: &SchemaModel, ) -> SchemaDiff { + SchemaDiff { + steps: diff_schema_views(backend, &SchemaRef::from(current), &SchemaRef::from(target)) + .into_iter() + .map(|step| step.into_legacy(current, target)) + .collect(), + } +} +pub(super) fn diff_schema_views( + backend: DatabaseBackend, + current: &SchemaRef<'_>, + target: &SchemaRef<'_>, +) -> Vec { let current_tables = current .tables .iter() @@ -685,18 +703,18 @@ pub fn diff_schema_models_for_backend( .iter() .map(|extension| extension.to_ascii_lowercase()) .collect::>(); - for extension in &target.extensions { + for extension in target.extensions { if !current_extensions.contains(&extension.to_ascii_lowercase()) { - steps.push(MigrationStep::EnableExtension { + steps.push(OwnedMigrationStep::EnableExtension { name: extension.clone(), }); } } for table in order_tables_by_foreign_keys(&target_tables) { - let table_name = &table.table_name; + let table_name = table.table_name; if !current_tables.contains_key(table_name) { - steps.push(MigrationStep::CreateTable(table.clone())); + steps.push(OwnedMigrationStep::CreateTable(table.to_owned())); } } @@ -704,15 +722,15 @@ pub fn diff_schema_models_for_backend( .into_iter() .rev() { - let table_name = &table.table_name; + let table_name = table.table_name; if !target_tables.contains_key(table_name) { - for search_index in &table.search_indexes { - steps.push(MigrationStep::DropSearchIndex { + for search_index in table.search_indexes { + steps.push(OwnedMigrationStep::DropSearchIndex { table_name: table_name.clone(), index_name: search_index.name.clone(), }); } - steps.push(MigrationStep::DropTable { + steps.push(OwnedMigrationStep::DropTable { table_name: table_name.clone(), }); continue; @@ -732,7 +750,7 @@ pub fn diff_schema_models_for_backend( for (column_name, column) in &target_columns { if !current_columns.contains_key(column_name) { - steps.push(MigrationStep::AddColumn { + steps.push(OwnedMigrationStep::AddColumn { table_name: table_name.clone(), column: (*column).clone(), }); @@ -741,7 +759,7 @@ pub fn diff_schema_models_for_backend( for (column_name, column) in ¤t_columns { if !target_columns.contains_key(column_name) { - steps.push(MigrationStep::DropColumn { + steps.push(OwnedMigrationStep::DropColumn { table_name: table_name.clone(), column_name: column_name.clone(), }); @@ -750,7 +768,7 @@ pub fn diff_schema_models_for_backend( let target_column = target_columns[column_name]; if column_changed_for_backend(backend, column, target_column) { - steps.push(MigrationStep::AlterColumn { + steps.push(OwnedMigrationStep::AlterColumn { table_name: table_name.clone(), before: (*column).clone(), after: (*target_column).clone(), @@ -772,26 +790,26 @@ pub fn diff_schema_models_for_backend( for (index_name, index) in &target_indexes { if let Some(current_index) = current_indexes.get(index_name) { if *current_index != *index { - steps.push(MigrationStep::DropIndex { + steps.push(OwnedMigrationStep::DropIndex { table_name: table_name.clone(), index_name: index_name.clone(), }); - steps.push(MigrationStep::CreateIndex { + steps.push(OwnedMigrationStep::CreateIndex { table_name: table_name.clone(), - index: (*index).clone(), + index: (*index).to_owned(), }); } } else { - steps.push(MigrationStep::CreateIndex { + steps.push(OwnedMigrationStep::CreateIndex { table_name: table_name.clone(), - index: (*index).clone(), + index: (*index).to_owned(), }); } } for index_name in current_indexes.keys() { if !target_indexes.contains_key(index_name) { - steps.push(MigrationStep::DropIndex { + steps.push(OwnedMigrationStep::DropIndex { table_name: table_name.clone(), index_name: index_name.clone(), }); @@ -812,23 +830,23 @@ pub fn diff_schema_models_for_backend( for (index_name, index) in &target_search_indexes { if let Some(current_index) = current_search_indexes.get(index_name) { if *current_index != *index { - steps.push(MigrationStep::AlterSearchIndex { + steps.push(OwnedMigrationStep::AlterSearchIndex { table_name: table_name.clone(), before: (*current_index).clone(), after: (*index).clone(), }); } } else { - steps.push(MigrationStep::CreateSearchIndex { + steps.push(OwnedMigrationStep::CreateSearchIndex { table_name: table_name.clone(), - index: (*index).clone(), + index: (*index).to_owned(), }); } } for index_name in current_search_indexes.keys() { if !target_search_indexes.contains_key(index_name) { - steps.push(MigrationStep::DropSearchIndex { + steps.push(OwnedMigrationStep::DropSearchIndex { table_name: table_name.clone(), index_name: index_name.clone(), }); @@ -875,13 +893,13 @@ pub fn diff_schema_models_for_backend( target.sort_by_key(|foreign_key| &foreign_key.constraint_name); let common = current.len().min(target.len()); for foreign_key in current.into_iter().skip(common) { - steps.push(MigrationStep::DropForeignKey { + steps.push(OwnedMigrationStep::DropForeignKey { table_name: table_name.clone(), foreign_key: foreign_key.clone(), }); } for foreign_key in target.into_iter().skip(common) { - steps.push(MigrationStep::AddForeignKey { + steps.push(OwnedMigrationStep::AddForeignKey { table_name: table_name.clone(), foreign_key: foreign_key.clone(), }); @@ -890,7 +908,7 @@ pub fn diff_schema_models_for_backend( if table.append_only != target_table.append_only || table.retention_purge != target_table.retention_purge { - steps.push(MigrationStep::SetAppendOnly { + steps.push(OwnedMigrationStep::SetAppendOnly { table_name: table_name.clone(), enabled: target_table.append_only, retention_purge: target_table.retention_purge, @@ -898,7 +916,7 @@ pub fn diff_schema_models_for_backend( } if !check_constraints_equivalent(&table.check_constraints, &target_table.check_constraints) { - steps.push(MigrationStep::SetCheckConstraints { + steps.push(OwnedMigrationStep::SetCheckConstraints { table_name: table_name.clone(), before: table.check_constraints.clone(), after: target_table.check_constraints.clone(), @@ -906,7 +924,7 @@ pub fn diff_schema_models_for_backend( } } - SchemaDiff { steps } + steps } fn foreign_key_constraint_name(table_name: &str, foreign_key: &ForeignKeyModel) -> String { @@ -930,41 +948,41 @@ fn foreign_key_constraint_name(table_name: &str, foreign_key: &ForeignKeyModel) format!("{}_{}", &full[..prefix_len], hash) } -fn migration_step_table_name(step: &MigrationStep) -> Option<&str> { +pub(super) fn migration_step_table_name<'a>(step: &StepRef<'a>) -> Option<&'a str> { match step { - MigrationStep::EnableExtension { .. } => None, - MigrationStep::CreateTable(table) => Some(&table.table_name), - MigrationStep::DropTable { table_name } => Some(table_name), - MigrationStep::AddColumn { table_name, .. } => Some(table_name), - MigrationStep::DropColumn { table_name, .. } => Some(table_name), - MigrationStep::AlterColumn { table_name, .. } => Some(table_name), - MigrationStep::CreateIndex { table_name, .. } => Some(table_name), - MigrationStep::DropIndex { table_name, .. } => Some(table_name), - MigrationStep::CreateSearchIndex { table_name, .. } => Some(table_name), - MigrationStep::DropSearchIndex { table_name, .. } => Some(table_name), - MigrationStep::AlterSearchIndex { table_name, .. } => Some(table_name), - MigrationStep::AddForeignKey { table_name, .. } => Some(table_name), - MigrationStep::DropForeignKey { table_name, .. } => Some(table_name), - MigrationStep::SetAppendOnly { table_name, .. } => Some(table_name), - MigrationStep::SetCheckConstraints { table_name, .. } => Some(table_name), - } -} - -fn sqlite_requires_table_rebuild(step: &MigrationStep) -> bool { + StepRef::EnableExtension { .. } => None, + StepRef::CreateTable(table) => Some(&table.table_name), + StepRef::DropTable { table_name } => Some(table_name), + StepRef::AddColumn { table_name, .. } => Some(table_name), + StepRef::DropColumn { table_name, .. } => Some(table_name), + StepRef::AlterColumn { table_name, .. } => Some(table_name), + StepRef::CreateIndex { table_name, .. } => Some(table_name), + StepRef::DropIndex { table_name, .. } => Some(table_name), + StepRef::CreateSearchIndex { table_name, .. } => Some(table_name), + StepRef::DropSearchIndex { table_name, .. } => Some(table_name), + StepRef::AlterSearchIndex { table_name, .. } => Some(table_name), + StepRef::AddForeignKey { table_name, .. } => Some(table_name), + StepRef::DropForeignKey { table_name, .. } => Some(table_name), + StepRef::SetAppendOnly { table_name, .. } => Some(table_name), + StepRef::SetCheckConstraints { table_name, .. } => Some(table_name), + } +} + +fn sqlite_requires_table_rebuild(step: &StepRef<'_>) -> bool { matches!( step, - MigrationStep::DropColumn { .. } - | MigrationStep::AlterColumn { .. } - | MigrationStep::AddForeignKey { .. } - | MigrationStep::DropForeignKey { .. } - | MigrationStep::SetCheckConstraints { .. } + StepRef::DropColumn { .. } + | StepRef::AlterColumn { .. } + | StepRef::AddForeignKey { .. } + | StepRef::DropForeignKey { .. } + | StepRef::SetCheckConstraints { .. } ) } fn render_create_index_statement( backend: DatabaseBackend, table_name: &str, - index: &super::core::IndexDef, + index: &IndexRef<'_>, ) -> String { let unique = if index.is_unique { "UNIQUE " } else { "" }; let method = match (backend, index.method) { @@ -1169,7 +1187,7 @@ fn parse_structural_string_value( } #[cfg(any(feature = "sqlite", feature = "postgres"))] -fn parse_closed_set_index_predicate(expression: &str) -> Option { +fn parse_closed_set_index_predicate(expression: &str) -> Option { let tokens = tokenize_structural_sql(expression)?; let tokens = strip_redundant_outer_parentheses(&tokens); let StructuralSqlToken::Ident(column) = tokens.first()? else { @@ -1231,15 +1249,10 @@ fn parse_closed_set_index_predicate(expression: &str) -> Option>() - .into_boxed_slice(), - ); - Some(super::core::IndexPredicateDef { column, values }) + Some(IndexPredicateModel { + column: column.clone(), + values, + }) } #[cfg(feature = "sqlite")] @@ -1699,7 +1712,7 @@ fn parse_search_index_config(config_json: &str) -> Option { } #[cfg(any(feature = "sqlite", feature = "postgres"))] -fn attach_search_indexes(tables: &mut [TableModel], search_indexes: Vec) { +fn attach_search_indexes(tables: &mut [OwnedTableModel], search_indexes: Vec) { for search_index in search_indexes { if let Some(table) = tables .iter_mut() @@ -1711,8 +1724,8 @@ fn attach_search_indexes(tables: &mut [TableModel], search_indexes: Vec, + target_table: &TableRef<'_>, ) -> Vec { let temp_table_name = format!("__graphql_orm_{}_new", target_table.table_name); let target_table_sql = render_create_table_statement_for_name( @@ -1760,8 +1773,14 @@ fn render_sqlite_table_rebuild_statements( } pub fn render_migration_step(backend: DatabaseBackend, step: &MigrationStep) -> Vec { + render_migration_step_view(backend, &StepRef::from(step)) +} +pub(super) fn render_migration_step_view( + backend: DatabaseBackend, + step: &StepRef<'_>, +) -> Vec { match step { - MigrationStep::EnableExtension { name } => match backend { + StepRef::EnableExtension { name } => match backend { DatabaseBackend::Postgres => vec![format!("CREATE EXTENSION IF NOT EXISTS {name}")], DatabaseBackend::Sqlite | DatabaseBackend::Mysql | DatabaseBackend::Mssql => { vec![format!( @@ -1771,7 +1790,7 @@ pub fn render_migration_step(backend: DatabaseBackend, step: &MigrationStep) -> )] } }, - MigrationStep::CreateTable(table) => { + StepRef::CreateTable(table) => { let mut statements = vec![render_create_table_statement(backend, table)]; statements.extend( table @@ -1796,15 +1815,15 @@ pub fn render_migration_step(backend: DatabaseBackend, step: &MigrationStep) -> statements.extend(render_constraint_comments(backend, table)); statements } - MigrationStep::DropTable { table_name } => { + StepRef::DropTable { table_name } => { vec![format!("DROP TABLE {}", table_name)] } - MigrationStep::AddColumn { table_name, column } => vec![format!( + StepRef::AddColumn { table_name, column } => vec![format!( "ALTER TABLE {} ADD COLUMN {}", table_name, render_column_definition(backend, column, true) )], - MigrationStep::DropColumn { + StepRef::DropColumn { table_name, column_name, } => match backend { @@ -1821,7 +1840,7 @@ pub fn render_migration_step(backend: DatabaseBackend, step: &MigrationStep) -> table_name, column_name )], }, - MigrationStep::AlterColumn { + StepRef::AlterColumn { table_name, before: _, after, @@ -1852,10 +1871,10 @@ pub fn render_migration_step(backend: DatabaseBackend, step: &MigrationStep) -> table_name, after.name, after.sql_type )], }, - MigrationStep::CreateIndex { table_name, index } => { + StepRef::CreateIndex { table_name, index } => { vec![render_create_index_statement(backend, table_name, index)] } - MigrationStep::DropIndex { + StepRef::DropIndex { table_name, index_name, } => match backend { @@ -1882,15 +1901,15 @@ pub fn render_migration_step(backend: DatabaseBackend, step: &MigrationStep) -> )] } }, - MigrationStep::CreateSearchIndex { + StepRef::CreateSearchIndex { table_name: _, index, } => render_create_search_index_statement(backend, index), - MigrationStep::DropSearchIndex { + StepRef::DropSearchIndex { table_name, index_name, } => render_drop_search_index_statement(backend, table_name, index_name), - MigrationStep::AlterSearchIndex { + StepRef::AlterSearchIndex { table_name, before, after, @@ -1900,7 +1919,7 @@ pub fn render_migration_step(backend: DatabaseBackend, step: &MigrationStep) -> statements.extend(render_create_search_index_statement(backend, after)); statements } - MigrationStep::AddForeignKey { + StepRef::AddForeignKey { table_name, foreign_key, } => { @@ -1933,7 +1952,7 @@ pub fn render_migration_step(backend: DatabaseBackend, step: &MigrationStep) -> )], } } - MigrationStep::DropForeignKey { + StepRef::DropForeignKey { table_name, foreign_key, } => { @@ -1958,12 +1977,12 @@ pub fn render_migration_step(backend: DatabaseBackend, step: &MigrationStep) -> )], } } - MigrationStep::SetAppendOnly { + StepRef::SetAppendOnly { table_name, enabled, retention_purge, } => render_append_only_statements(backend, table_name, *enabled, *retention_purge), - MigrationStep::SetCheckConstraints { + StepRef::SetCheckConstraints { table_name, before, after, @@ -2007,32 +2026,40 @@ pub fn render_migration_step(backend: DatabaseBackend, step: &MigrationStep) -> } pub fn classify_migration_step(step: &MigrationStep) -> PlannedMigrationStep { + let (risk, reason) = classify_migration_step_view(&StepRef::from(step)); + PlannedMigrationStep { + step: step.clone(), + risk, + reason: reason.into(), + } +} +pub(super) fn classify_migration_step_view(step: &StepRef<'_>) -> (MigrationRisk, &'static str) { let (risk, reason) = match step { - MigrationStep::EnableExtension { .. } => ( + StepRef::EnableExtension { .. } => ( MigrationRisk::Additive, "enables a database extension without changing row data", ), - MigrationStep::CreateTable(_) => ( + StepRef::CreateTable(_) => ( MigrationRisk::Additive, "creates a new table without changing existing data", ), - MigrationStep::DropTable { .. } => ( + StepRef::DropTable { .. } => ( MigrationRisk::Destructive, "drops an existing table and its data", ), - MigrationStep::AddColumn { column, .. } if column.nullable || column.default.is_some() => ( + StepRef::AddColumn { column, .. } if column.nullable || column.default.is_some() => ( MigrationRisk::Additive, "adds a nullable or defaulted column", ), - MigrationStep::AddColumn { .. } => ( + StepRef::AddColumn { .. } => ( MigrationRisk::Risky, "adds a required column without a default", ), - MigrationStep::DropColumn { .. } => ( + StepRef::DropColumn { .. } => ( MigrationRisk::Destructive, "drops an existing column and its data", ), - MigrationStep::AlterColumn { before, after, .. } => { + StepRef::AlterColumn { before, after, .. } => { if before.nullable && !after.nullable { ( MigrationRisk::Risky, @@ -2047,49 +2074,45 @@ pub fn classify_migration_step(step: &MigrationStep) -> PlannedMigrationStep { (MigrationRisk::Risky, "changes an existing column type") } } - MigrationStep::CreateIndex { .. } => ( + StepRef::CreateIndex { .. } => ( MigrationRisk::Additive, "creates an index without changing row data", ), - MigrationStep::DropIndex { .. } => (MigrationRisk::Risky, "drops an existing index"), - MigrationStep::CreateSearchIndex { .. } => ( + StepRef::DropIndex { .. } => (MigrationRisk::Risky, "drops an existing index"), + StepRef::CreateSearchIndex { .. } => ( MigrationRisk::Additive, "creates full-text search structures without backfilling row data", ), - MigrationStep::DropSearchIndex { .. } => { + StepRef::DropSearchIndex { .. } => { (MigrationRisk::Risky, "drops full-text search structures") } - MigrationStep::AlterSearchIndex { .. } => ( + StepRef::AlterSearchIndex { .. } => ( MigrationRisk::Risky, "recreates full-text search structures and requires an explicit rebuild", ), - MigrationStep::AddForeignKey { .. } => ( + StepRef::AddForeignKey { .. } => ( MigrationRisk::Risky, "adds a constraint that may reject existing rows", ), - MigrationStep::DropForeignKey { .. } => ( + StepRef::DropForeignKey { .. } => ( MigrationRisk::Risky, "drops an existing referential constraint", ), - MigrationStep::SetAppendOnly { enabled: true, .. } => ( + StepRef::SetAppendOnly { enabled: true, .. } => ( MigrationRisk::Additive, "adds database enforcement that prevents row mutation", ), - MigrationStep::SetAppendOnly { enabled: false, .. } => ( + StepRef::SetAppendOnly { enabled: false, .. } => ( MigrationRisk::Risky, "removes append-only database enforcement", ), - MigrationStep::SetCheckConstraints { .. } => ( + StepRef::SetCheckConstraints { .. } => ( MigrationRisk::Risky, "adds, removes, or changes same-row check constraints", ), }; - PlannedMigrationStep { - step: step.clone(), - risk, - reason: reason.to_string(), - } + (risk, reason) } pub fn classify_migration_steps(steps: &[MigrationStep]) -> Vec { @@ -2101,11 +2124,29 @@ pub fn build_migration_plan( current: &SchemaModel, target: &SchemaModel, ) -> MigrationPlan { + let plan = + build_migration_plan_views(backend, &SchemaRef::from(current), &SchemaRef::from(target)); + MigrationPlan { + backend: plan.backend, + steps: plan + .steps + .into_iter() + .map(|step| step.into_legacy(current, target)) + .collect(), + statements: plan.statements, + } +} +pub(super) fn build_migration_plan_views( + backend: DatabaseBackend, + current: &SchemaRef<'_>, + target: &SchemaRef<'_>, +) -> OwnedMigrationPlan { + let empty_extensions = Vec::new(); let mut target_for_backend = target.clone(); if backend != DatabaseBackend::Postgres { - target_for_backend.extensions.clear(); + target_for_backend.extensions = &empty_extensions; } - let diff = diff_schema_models_for_backend(backend, current, &target_for_backend); + let steps = diff_schema_views(backend, current, &target_for_backend); let statements = match backend { DatabaseBackend::Sqlite => { let current_tables = current @@ -2118,11 +2159,10 @@ pub fn build_migration_plan( .iter() .map(|table| (table.table_name.as_str(), table)) .collect::>(); - let rebuild_tables = diff - .steps + let rebuild_tables = steps .iter() - .filter(|step| sqlite_requires_table_rebuild(step)) - .filter_map(migration_step_table_name) + .filter(|step| sqlite_requires_table_rebuild(&StepRef::from(*step))) + .filter_map(|step| migration_step_table_name(&StepRef::from(step))) .collect::>(); let mut statements = Vec::new(); let mut rebuilt_tables = std::collections::BTreeSet::new(); @@ -2131,8 +2171,8 @@ pub fn build_migration_plan( statements.push("PRAGMA foreign_keys = OFF".to_string()); } - for step in &diff.steps { - let table_name = migration_step_table_name(step); + for step in &steps { + let table_name = migration_step_table_name(&StepRef::from(step)); if let Some(table_name) = table_name { if rebuild_tables.contains(table_name) { if rebuilt_tables.insert(table_name.to_string()) { @@ -2149,7 +2189,7 @@ pub fn build_migration_plan( continue; } } - statements.extend(render_migration_step(backend, step)); + statements.extend(render_migration_step_view(backend, &StepRef::from(step))); } if !rebuild_tables.is_empty() { @@ -2158,16 +2198,15 @@ pub fn build_migration_plan( statements } - _ => diff - .steps + _ => steps .iter() - .flat_map(|step| render_migration_step(backend, step)) + .flat_map(|step| render_migration_step_view(backend, &StepRef::from(step))) .collect::>(), }; - MigrationPlan { + OwnedMigrationPlan { backend, - steps: diff.steps, + steps, statements, } } @@ -2226,17 +2265,19 @@ where } #[cfg(feature = "sqlite")] -async fn sqlite_retention_context_is_valid(pool: &sqlx::SqlitePool) -> crate::Result { +async fn sqlite_retention_context_is_valid( + connection: &mut sqlx::SqliteConnection, +) -> crate::Result { let exists: bool = sqlx::query_scalar( "SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '__graphql_orm_retention_context')", ) - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; if !exists { return Ok(false); } let columns = sqlx::query("PRAGMA table_info(__graphql_orm_retention_context)") - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; if columns.len() != 1 { return Ok(false); @@ -2249,7 +2290,7 @@ async fn sqlite_retention_context_is_valid(pool: &sqlx::SqlitePool) -> crate::Re && column.try_get::("notnull")? == 1 && column.try_get::("pk")? == 1; let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM __graphql_orm_retention_context") - .fetch_one(pool) + .fetch_one(&mut *connection) .await?; Ok(valid_column && rows == 0) } @@ -2258,24 +2299,62 @@ async fn sqlite_retention_context_is_valid(pool: &sqlx::SqlitePool) -> crate::Re pub async fn introspect_sqlite_schema( provider: &impl PoolProvider, ) -> crate::Result { - let pool = provider.pool(); - let retention_context_valid = sqlite_retention_context_is_valid(pool).await?; + introspect_owned_sqlite_schema(provider) + .await + .map(OwnedSchemaModel::into_legacy) +} +#[cfg(feature = "sqlite")] +pub(super) async fn introspect_owned_sqlite_schema( + provider: &impl PoolProvider, +) -> crate::Result { + let mut connection = provider.pool().acquire().await?; + introspect_owned_sqlite_connection(&mut connection).await +} +#[cfg(feature = "sqlite")] +pub(super) async fn introspect_owned_sqlite_connection( + connection: &mut sqlx::SqliteConnection, +) -> crate::Result { + introspect_owned_sqlite_connection_with_internal(connection, false).await +} +#[cfg(feature = "sqlite")] +pub(super) async fn introspect_owned_sqlite_connection_with_internal( + connection: &mut sqlx::SqliteConnection, + include_internal: bool, +) -> crate::Result { + let retention_context_valid = sqlite_retention_context_is_valid(&mut *connection).await?; let table_rows = sqlx::query( "SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", ) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let mut tables = Vec::new(); for row in table_rows { let table_name: String = row.try_get("name")?; let create_sql: String = row.try_get::, _>("sql")?.unwrap_or_default(); - if is_internal_graphql_orm_table(&table_name) { + if !include_internal && is_internal_graphql_orm_table(&table_name) { continue; } let pragma_table_info = format!("PRAGMA table_info({})", table_name); - let column_rows = sqlx::query(&pragma_table_info).fetch_all(pool).await?; + let column_rows = sqlx::query(&pragma_table_info) + .fetch_all(&mut *connection) + .await?; + let mut ordered_keys = column_rows + .iter() + .map(|row| { + Ok(( + row.try_get::("pk")?, + row.try_get::("name")?, + )) + }) + .collect::>>()?; + ordered_keys.retain(|(ordinal, _)| *ordinal > 0); + ordered_keys.sort_by_key(|(ordinal, _)| *ordinal); + let primary_keys = ordered_keys + .into_iter() + .map(|(_, name)| name) + .collect::>(); let mut columns = column_rows .into_iter() .map(|row| { @@ -2298,18 +2377,15 @@ pub async fn introspect_sqlite_schema( }) }) .collect::>>()?; - let primary_keys = columns - .iter() - .filter(|column| column.is_primary_key) - .map(|column| column.name.clone()) - .collect::>(); let primary_key = primary_keys .first() .cloned() .unwrap_or_else(|| "id".to_string()); let pragma_index_list = format!("PRAGMA index_list({})", table_name); - let index_rows = sqlx::query(&pragma_index_list).fetch_all(pool).await?; + let index_rows = sqlx::query(&pragma_index_list) + .fetch_all(&mut *connection) + .await?; let mut indexes = Vec::new(); let mut composite_unique_indexes = Vec::new(); for row in index_rows { @@ -2322,7 +2398,9 @@ pub async fn introspect_sqlite_schema( .unwrap_or_else(|_| "c".to_string()); let pragma_index_info = format!("PRAGMA index_xinfo({})", index_name); - let index_info_rows = sqlx::query(&pragma_index_info).fetch_all(pool).await?; + let index_info_rows = sqlx::query(&pragma_index_info) + .fetch_all(&mut *connection) + .await?; let mut index_columns = Vec::new(); for index_row in index_info_rows { // index_xinfo also returns the auxiliary rowid column with @@ -2384,7 +2462,7 @@ pub async fn introspect_sqlite_schema( "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?", ) .bind(&index_name) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await? .flatten(); let predicate = index_sql.as_deref().and_then(|sql| { @@ -2394,20 +2472,10 @@ pub async fn introspect_sqlite_schema( .and_then(|offset| parse_closed_set_index_predicate(&sql[offset + 7..])) }); - let leaked_name: &'static str = Box::leak(index_name.into_boxed_str()); - let leaked_columns: &'static [&'static str] = Box::leak( - column_names - .into_iter() - .map(|column| Box::leak(column.into_boxed_str()) as &'static str) - .collect::>() - .into_boxed_slice(), - ); - let leaked_directions: &'static [super::core::IndexDirection] = - Box::leak(column_directions.into_boxed_slice()); - indexes.push(super::core::IndexDef { - name: leaked_name, - columns: leaked_columns, - column_directions: leaked_directions, + indexes.push(IndexModel { + name: index_name, + columns: column_names, + column_directions, is_unique: unique, method: IndexMethod::Default, is_spatial: false, @@ -2416,7 +2484,9 @@ pub async fn introspect_sqlite_schema( } let pragma_fk_list = format!("PRAGMA foreign_key_list({})", table_name); - let foreign_key_rows = sqlx::query(&pragma_fk_list).fetch_all(pool).await?; + let foreign_key_rows = sqlx::query(&pragma_fk_list) + .fetch_all(&mut *connection) + .await?; let mut grouped_foreign_keys = std::collections::BTreeMap::>::new(); for row in foreign_key_rows { @@ -2486,7 +2556,7 @@ pub async fn introspect_sqlite_schema( ) .bind(format!("{append_name}_update")) .bind(format!("{append_name}_delete")) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let mut valid_update = false; let mut valid_delete = false; @@ -2519,7 +2589,7 @@ pub async fn introspect_sqlite_schema( } } - tables.push(TableModel { + tables.push(OwnedTableModel { entity_name: table_name.clone(), table_name, primary_key: primary_key.clone(), @@ -2539,14 +2609,14 @@ pub async fn introspect_sqlite_schema( let metadata_exists = sqlx::query( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__graphql_orm_search_metadata'", ) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await? .is_some(); if metadata_exists { let rows = sqlx::query( "SELECT config_json FROM __graphql_orm_search_metadata ORDER BY entity_name", ) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let search_indexes = rows .into_iter() @@ -2559,9 +2629,10 @@ pub async fn introspect_sqlite_schema( attach_search_indexes(&mut tables, search_indexes); } - Ok(SchemaModel { + Ok(OwnedSchemaModel { extensions: Vec::new(), tables, + limits: Default::default(), }) } @@ -2641,14 +2712,35 @@ mod postgres_unique_constraint_tests { pub async fn introspect_postgres_schema( provider: &impl PoolProvider, ) -> crate::Result { - let pool = provider.pool(); + introspect_owned_postgres_schema(provider) + .await + .map(OwnedSchemaModel::into_legacy) +} +#[cfg(feature = "postgres")] +pub(super) async fn introspect_owned_postgres_schema( + provider: &impl PoolProvider, +) -> crate::Result { + let mut connection = provider.pool().acquire().await?; + introspect_owned_postgres_connection(&mut connection).await +} +#[cfg(feature = "postgres")] +pub(super) async fn introspect_owned_postgres_connection( + connection: &mut sqlx::PgConnection, +) -> crate::Result { + introspect_owned_postgres_connection_with_internal(connection, false).await +} +#[cfg(feature = "postgres")] +pub(super) async fn introspect_owned_postgres_connection_with_internal( + connection: &mut sqlx::PgConnection, + include_internal: bool, +) -> crate::Result { let schema_name = sqlx::query("SELECT current_schema() AS schema_name") - .fetch_one(pool) + .fetch_one(&mut *connection) .await? .try_get::, _>("schema_name")? .unwrap_or_else(|| "public".to_string()); let extension_rows = sqlx::query("SELECT extname FROM pg_extension ORDER BY extname") - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let extensions = extension_rows .into_iter() @@ -2662,13 +2754,13 @@ pub async fn introspect_postgres_schema( ORDER BY table_name", ) .bind(&schema_name) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let mut tables = Vec::new(); for row in table_rows { let table_name: String = row.try_get("table_name")?; - if is_internal_graphql_orm_table(&table_name) { + if !include_internal && is_internal_graphql_orm_table(&table_name) { continue; } let column_rows = sqlx::query( @@ -2690,7 +2782,7 @@ pub async fn introspect_postgres_schema( ) .bind(&table_name) .bind(&schema_name) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let primary_key_rows = sqlx::query( @@ -2705,7 +2797,7 @@ pub async fn introspect_postgres_schema( ) .bind(&table_name) .bind(&schema_name) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let primary_key_columns = primary_key_rows .into_iter() @@ -2731,7 +2823,7 @@ pub async fn introspect_postgres_schema( ) .bind(&table_name) .bind(&schema_name) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let unique_members = unique_rows .into_iter() @@ -2798,7 +2890,7 @@ pub async fn introspect_postgres_schema( ) .bind(&table_name) .bind(&schema_name) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let mut indexes = Vec::new(); for row in index_rows { @@ -2831,31 +2923,20 @@ pub async fn introspect_postgres_schema( .iter() .any(|column| column.name == *column_name && column.spatial.is_some()) }); - let leaked_name: &'static str = Box::leak(index_name.into_boxed_str()); - let leaked_columns: &'static [&'static str] = Box::leak( - column_names - .into_iter() - .map(|column| Box::leak(column.into_boxed_str()) as &'static str) - .collect::>() - .into_boxed_slice(), - ); - let leaked_directions: &'static [super::core::IndexDirection] = Box::leak( - descending - .into_iter() - .map(|descending| { - if descending { - super::core::IndexDirection::Desc - } else { - super::core::IndexDirection::Asc - } - }) - .collect::>() - .into_boxed_slice(), - ); - indexes.push(super::core::IndexDef { - name: leaked_name, - columns: leaked_columns, - column_directions: leaked_directions, + let column_directions = descending + .into_iter() + .map(|desc| { + if desc { + super::core::IndexDirection::Desc + } else { + super::core::IndexDirection::Asc + } + }) + .collect(); + indexes.push(IndexModel { + name: index_name, + columns: column_names, + column_directions, is_unique: unique, method, is_spatial, @@ -2897,7 +2978,7 @@ pub async fn introspect_postgres_schema( ) .bind(&table_name) .bind(&schema_name) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let foreign_keys = foreign_key_rows .into_iter() @@ -2968,7 +3049,7 @@ pub async fn introspect_postgres_schema( .bind(&table_name) .bind(&schema_name) .bind(&append_trigger_name) - .fetch_optional(pool) + .fetch_optional(&mut *connection) .await?; let append_only = append_function_body.as_deref().is_some_and(|body| { postgres_append_only_function_body_matches(body) @@ -2989,7 +3070,7 @@ pub async fn introspect_postgres_schema( ) .bind(&table_name) .bind(&schema_name) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let check_constraints = check_rows .into_iter() @@ -3014,7 +3095,7 @@ pub async fn introspect_postgres_schema( }) .collect::>>()?; - tables.push(TableModel { + tables.push(OwnedTableModel { entity_name: table_name.clone(), table_name, primary_key: primary_key.clone(), @@ -3042,14 +3123,14 @@ pub async fn introspect_postgres_schema( ) .bind(&schema_name) .bind(super::search_metadata_table_name()) - .fetch_one(pool) + .fetch_one(&mut *connection) .await? .try_get::("exists")?; if metadata_exists { let rows = sqlx::query( "SELECT config_json::text AS config_json FROM __graphql_orm_search_metadata ORDER BY entity_name", ) - .fetch_all(pool) + .fetch_all(&mut *connection) .await?; let search_indexes = rows .into_iter() @@ -3062,7 +3143,11 @@ pub async fn introspect_postgres_schema( attach_search_indexes(&mut tables, search_indexes); } - Ok(SchemaModel { extensions, tables }) + Ok(OwnedSchemaModel { + extensions, + tables, + limits: Default::default(), + }) } #[cfg(feature = "postgres")] diff --git a/crates/graphql-orm/src/graphql/orm/mod.rs b/crates/graphql-orm/src/graphql/orm/mod.rs index 6827a471..52a46de6 100644 --- a/crates/graphql-orm/src/graphql/orm/mod.rs +++ b/crates/graphql-orm/src/graphql/orm/mod.rs @@ -13,8 +13,10 @@ mod lease; #[cfg(any(feature = "sqlite", feature = "postgres", feature = "mssql"))] mod migrations; mod operation_metadata; +mod owned_schema; mod query; mod rls; +mod runtime_migration; mod runtime_query; mod runtime_record; mod runtime_relation; @@ -38,8 +40,10 @@ pub use lease::*; #[cfg(any(feature = "sqlite", feature = "postgres", feature = "mssql"))] pub use migrations::*; pub use operation_metadata::*; +pub use owned_schema::{IndexModel, IndexPredicateModel, OwnedSchemaModel, OwnedTableModel}; pub use query::*; pub use rls::*; +pub use runtime_migration::*; pub use runtime_query::*; pub use runtime_record::*; pub use runtime_relation::*; diff --git a/crates/graphql-orm/src/graphql/orm/owned_schema.rs b/crates/graphql-orm/src/graphql/orm/owned_schema.rs new file mode 100644 index 00000000..77d47c3a --- /dev/null +++ b/crates/graphql-orm/src/graphql/orm/owned_schema.rs @@ -0,0 +1,1545 @@ +//! Owned storage and borrowed views of the canonical physical schema contract. +//! +//! Legacy enums and struct literals retain their original public definitions. +//! Both storage forms use the same validation, hashing, diffing and SQL renderer. +use super::core::*; +use super::dialect::DatabaseBackend; +use std::fmt; + +/// Owned closed-set partial-index predicate. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexPredicateModel { + /// Physical column. + pub column: String, + /// Literal values, quoted by the database dialect. + pub values: Vec, +} + +/// Owned storage form of [`IndexDef`]; it is the same physical contract. +#[derive(Clone, Debug)] +pub struct IndexModel { + /// Physical index name. + pub name: String, + /// Ordered physical columns. + pub columns: Vec, + /// Empty means ascending, as for [`IndexDef`]. + pub column_directions: Vec, + /// Whether the index enforces uniqueness. + pub is_unique: bool, + /// Backend index method. + pub method: IndexMethod, + /// Whether this is a spatial index. + pub is_spatial: bool, + /// Optional closed-set predicate. + pub predicate: Option, +} +impl IndexModel { + /// Construct an ordinary all-ascending index. + pub fn new(name: impl Into, columns: Vec) -> Self { + Self { + name: name.into(), + columns, + column_directions: vec![], + is_unique: false, + method: IndexMethod::Default, + is_spatial: false, + predicate: None, + } + } + /// Effective direction, preserving the legacy empty-slice convention. + pub fn direction_at(&self, index: usize) -> IndexDirection { + self.column_directions + .get(index) + .copied() + .unwrap_or(IndexDirection::Asc) + } + pub(super) fn borrowed(&self) -> IndexRef<'_> { + IndexRef { + name: &self.name, + columns: self.columns.iter().map(String::as_str).collect(), + column_directions: &self.column_directions, + is_unique: self.is_unique, + method: self.method, + is_spatial: self.is_spatial, + predicate: self.predicate.as_ref().map(|p| IndexPredicateRef { + column: &p.column, + values: p.values.iter().map(String::as_str).collect(), + }), + } + } + // Only compatibility APIs with legacy &'static storage call this adapter. + // The owned runtime conversion/introspection/planning/apply paths never do. + pub(super) fn into_legacy(self) -> IndexDef { + IndexDef { + name: Box::leak(self.name.into_boxed_str()), + columns: Box::leak( + self.columns + .into_iter() + .map(|c| &*Box::leak(c.into_boxed_str())) + .collect::>() + .into_boxed_slice(), + ), + column_directions: Box::leak(self.column_directions.into_boxed_slice()), + is_unique: self.is_unique, + method: self.method, + is_spatial: self.is_spatial, + predicate: self.predicate.map(|p| IndexPredicateDef { + column: Box::leak(p.column.into_boxed_str()), + values: Box::leak( + p.values + .into_iter() + .map(|v| &*Box::leak(v.into_boxed_str())) + .collect::>() + .into_boxed_slice(), + ), + }), + } + } +} +impl From<&IndexDef> for IndexModel { + fn from(value: &IndexDef) -> Self { + IndexRef::from(value).to_owned() + } +} +impl PartialEq for IndexModel { + fn eq(&self, other: &Self) -> bool { + self.borrowed() == other.borrowed() + } +} + +/// Owned canonical physical table. Inspection does not transfer ownership. +#[derive(Clone, Debug, PartialEq)] +pub struct OwnedTableModel { + pub(super) entity_name: String, + pub(super) table_name: String, + pub(super) primary_key: String, + pub(super) primary_keys: Vec, + pub(super) default_sort: String, + pub(super) columns: Vec, + pub(super) indexes: Vec, + pub(super) composite_unique_indexes: Vec>, + pub(super) foreign_keys: Vec, + pub(super) search_indexes: Vec, + pub(super) append_only: bool, + pub(super) retention_purge: bool, + pub(super) check_constraints: Vec, +} +impl OwnedTableModel { + /// Inspect the canonical `entity_name` component. + pub fn entity_name(&self) -> &str { + &self.entity_name + } + /// Inspect the canonical `table_name` component. + pub fn table_name(&self) -> &str { + &self.table_name + } + /// Inspect the canonical `primary_key` component. + pub fn primary_key(&self) -> &str { + &self.primary_key + } + /// Inspect the canonical `primary_keys` component. + pub fn primary_keys(&self) -> &[String] { + &self.primary_keys + } + /// Inspect the canonical `default_sort` component. + pub fn default_sort(&self) -> &str { + &self.default_sort + } + /// Inspect the canonical `columns` component. + pub fn columns(&self) -> &[ColumnModel] { + &self.columns + } + /// Inspect the canonical `indexes` component. + pub fn indexes(&self) -> &[IndexModel] { + &self.indexes + } + /// Inspect the canonical `composite_unique_indexes` component. + pub fn composite_unique_indexes(&self) -> &[Vec] { + &self.composite_unique_indexes + } + /// Inspect the canonical `foreign_keys` component. + pub fn foreign_keys(&self) -> &[ForeignKeyModel] { + &self.foreign_keys + } + /// Inspect the canonical `search_indexes` component. + pub fn search_indexes(&self) -> &[SearchIndexModel] { + &self.search_indexes + } + /// Inspect the canonical `append_only` component. + pub fn append_only(&self) -> bool { + self.append_only + } + /// Inspect the canonical `retention_purge` component. + pub fn retention_purge(&self) -> bool { + self.retention_purge + } + /// Inspect the canonical `check_constraints` component. + pub fn check_constraints(&self) -> &[CheckConstraintModel] { + &self.check_constraints + } + pub(super) fn borrowed(&self) -> TableRef<'_> { + TableRef { + entity_name: &self.entity_name, + table_name: &self.table_name, + primary_key: &self.primary_key, + primary_keys: &self.primary_keys, + default_sort: &self.default_sort, + columns: &self.columns, + indexes: self.indexes.iter().map(IndexModel::borrowed).collect(), + composite_unique_indexes: &self.composite_unique_indexes, + foreign_keys: &self.foreign_keys, + search_indexes: &self.search_indexes, + append_only: self.append_only, + retention_purge: self.retention_purge, + check_constraints: &self.check_constraints, + } + } + pub(super) fn into_legacy(self) -> TableModel { + TableModel { + entity_name: self.entity_name, + table_name: self.table_name, + primary_key: self.primary_key, + primary_keys: self.primary_keys, + default_sort: self.default_sort, + columns: self.columns, + indexes: self + .indexes + .into_iter() + .map(IndexModel::into_legacy) + .collect(), + composite_unique_indexes: self.composite_unique_indexes, + foreign_keys: self.foreign_keys, + search_indexes: self.search_indexes, + append_only: self.append_only, + retention_purge: self.retention_purge, + check_constraints: self.check_constraints, + } + } +} +impl From<&TableModel> for OwnedTableModel { + fn from(value: &TableModel) -> Self { + TableRef::from(value).to_owned() + } +} + +/// Owned storage form of the canonical [`SchemaModel`], including owned indexes. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct OwnedSchemaModel { + pub(super) extensions: Vec, + pub(super) tables: Vec, + pub(super) limits: super::runtime_migration::RuntimeMigrationLimits, +} +impl OwnedSchemaModel { + /// Inspect the physical target tables. + pub fn tables(&self) -> &[OwnedTableModel] { + &self.tables + } + /// Inspect the required extensions. + pub fn extensions(&self) -> &[String] { + &self.extensions + } + /// Existing physical hash algorithm, independent of owned/static storage. + pub fn stable_hash(&self) -> String { + self.borrowed().stable_hash() + } + /// Validate the existing canonical physical contract. + pub fn validate_physical_contract(&self) -> crate::Result<()> { + self.borrowed().validate_physical_contract() + } + pub(super) fn borrowed(&self) -> SchemaRef<'_> { + SchemaRef { + extensions: &self.extensions, + tables: self.tables.iter().map(OwnedTableModel::borrowed).collect(), + } + } + pub(super) fn into_legacy(self) -> SchemaModel { + SchemaModel { + extensions: self.extensions, + tables: self + .tables + .into_iter() + .map(OwnedTableModel::into_legacy) + .collect(), + } + } +} +impl From<&SchemaModel> for OwnedSchemaModel { + fn from(value: &SchemaModel) -> Self { + Self { + extensions: value.extensions.clone(), + tables: value.tables.iter().map(OwnedTableModel::from).collect(), + limits: Default::default(), + } + } +} + +#[derive(Clone, PartialEq, Eq)] +pub(super) struct IndexPredicateRef<'a> { + pub column: &'a str, + pub values: Vec<&'a str>, +} +impl fmt::Debug for IndexPredicateRef<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("IndexPredicateDef") + .field("column", &self.column) + .field("values", &self.values) + .finish() + } +} +#[derive(Clone)] +pub(super) struct IndexRef<'a> { + pub name: &'a str, + pub columns: Vec<&'a str>, + pub column_directions: &'a [IndexDirection], + pub is_unique: bool, + pub method: IndexMethod, + pub is_spatial: bool, + pub predicate: Option>, +} +impl IndexRef<'_> { + pub fn direction_at(&self, index: usize) -> IndexDirection { + self.column_directions + .get(index) + .copied() + .unwrap_or(IndexDirection::Asc) + } + pub fn to_owned(&self) -> IndexModel { + IndexModel { + name: self.name.into(), + columns: self.columns.iter().map(|c| (*c).into()).collect(), + column_directions: self.column_directions.into(), + is_unique: self.is_unique, + method: self.method, + is_spatial: self.is_spatial, + predicate: self.predicate.as_ref().map(|p| IndexPredicateModel { + column: p.column.into(), + values: p.values.iter().map(|v| (*v).into()).collect(), + }), + } + } +} +impl PartialEq for IndexRef<'_> { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + && self.columns == other.columns + && (self.column_directions.is_empty() + || self.column_directions.len() == self.columns.len()) + && (other.column_directions.is_empty() + || other.column_directions.len() == other.columns.len()) + && self.is_unique == other.is_unique + && self.method == other.method + && self.is_spatial == other.is_spatial + && self.predicate == other.predicate + && (0..self.columns.len()).all(|i| self.direction_at(i) == other.direction_at(i)) + } +} +impl<'a> From<&'a IndexDef> for IndexRef<'a> { + fn from(i: &'a IndexDef) -> Self { + Self { + name: i.name, + columns: i.columns.to_vec(), + column_directions: i.column_directions, + is_unique: i.is_unique, + method: i.method, + is_spatial: i.is_spatial, + predicate: i.predicate.as_ref().map(|p| IndexPredicateRef { + column: p.column, + values: p.values.to_vec(), + }), + } + } +} +impl fmt::Debug for IndexRef<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("IndexDef") + .field("name", &self.name) + .field("columns", &self.columns) + .field("column_directions", &self.column_directions) + .field("is_unique", &self.is_unique) + .field("method", &self.method) + .field("is_spatial", &self.is_spatial) + .field("predicate", &self.predicate) + .finish() + } +} +#[derive(Clone)] +pub(super) struct TableRef<'a> { + pub entity_name: &'a String, + pub table_name: &'a String, + pub primary_key: &'a String, + pub primary_keys: &'a Vec, + pub default_sort: &'a String, + pub columns: &'a Vec, + pub indexes: Vec>, + pub composite_unique_indexes: &'a Vec>, + pub foreign_keys: &'a Vec, + pub search_indexes: &'a Vec, + pub append_only: bool, + pub retention_purge: bool, + pub check_constraints: &'a Vec, +} +impl TableRef<'_> { + pub fn primary_keys(&self) -> &[String] { + if self.primary_keys.is_empty() { + std::slice::from_ref(self.primary_key) + } else { + self.primary_keys + } + } + pub fn to_owned(&self) -> OwnedTableModel { + OwnedTableModel { + entity_name: self.entity_name.clone(), + table_name: self.table_name.clone(), + primary_key: self.primary_key.clone(), + primary_keys: self.primary_keys.clone(), + default_sort: self.default_sort.clone(), + columns: self.columns.clone(), + indexes: self.indexes.iter().map(IndexRef::to_owned).collect(), + composite_unique_indexes: self.composite_unique_indexes.clone(), + foreign_keys: self.foreign_keys.clone(), + search_indexes: self.search_indexes.clone(), + append_only: self.append_only, + retention_purge: self.retention_purge, + check_constraints: self.check_constraints.clone(), + } + } +} +impl<'a> From<&'a TableModel> for TableRef<'a> { + fn from(value: &'a TableModel) -> Self { + Self { + entity_name: &value.entity_name, + table_name: &value.table_name, + primary_key: &value.primary_key, + primary_keys: &value.primary_keys, + default_sort: &value.default_sort, + columns: &value.columns, + indexes: value.indexes.iter().map(IndexRef::from).collect(), + composite_unique_indexes: &value.composite_unique_indexes, + foreign_keys: &value.foreign_keys, + search_indexes: &value.search_indexes, + append_only: value.append_only, + retention_purge: value.retention_purge, + check_constraints: &value.check_constraints, + } + } +} +impl fmt::Debug for TableRef<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TableModel") + .field("entity_name", &self.entity_name) + .field("table_name", &self.table_name) + .field("primary_key", &self.primary_key) + .field("primary_keys", &self.primary_keys) + .field("default_sort", &self.default_sort) + .field("columns", &self.columns) + .field("indexes", &self.indexes) + .field("composite_unique_indexes", &self.composite_unique_indexes) + .field("foreign_keys", &self.foreign_keys) + .field("search_indexes", &self.search_indexes) + .field("append_only", &self.append_only) + .field("retention_purge", &self.retention_purge) + .field("check_constraints", &self.check_constraints) + .finish() + } +} +#[derive(Clone, Debug)] +pub(super) struct SchemaRef<'a> { + pub extensions: &'a Vec, + pub tables: Vec>, +} +impl<'a> From<&'a SchemaModel> for SchemaRef<'a> { + fn from(s: &'a SchemaModel) -> Self { + Self { + extensions: &s.extensions, + tables: s.tables.iter().map(TableRef::from).collect(), + } + } +} +impl SchemaRef<'_> { + pub(super) fn validate_physical_contract(&self) -> crate::Result<()> { + let tables = self + .tables + .iter() + .map(|table| (table.table_name.as_str(), table)) + .collect::>(); + for table in &self.tables { + for index in &table.indexes { + if index.name.trim().is_empty() + || index.columns.is_empty() + || (!index.column_directions.is_empty() + && index.column_directions.len() != index.columns.len()) + { + return Err(sqlx::Error::Protocol(format!( + "invalid index column metadata for {}.{}", + table.table_name, index.name + ))); + } + let mut index_members = std::collections::BTreeSet::new(); + for column_name in &index.columns { + if !index_members.insert(*column_name) + || !table + .columns + .iter() + .any(|column| column.name == *column_name) + { + return Err(sqlx::Error::Protocol(format!( + "index {}.{} contains a duplicate or missing column {}", + table.table_name, index.name, column_name + ))); + } + } + } + let mut foreign_key_identities = std::collections::BTreeSet::new(); + for foreign_key in table.foreign_keys { + if foreign_key + .constraint_name + .as_deref() + .is_some_and(|name| name.trim().is_empty()) + || foreign_key.column_pairs.is_empty() + { + return Err(sqlx::Error::Protocol(format!( + "foreign key on {} has invalid constraint identity or no column pairs", + table.table_name + ))); + } + if !foreign_key_identities.insert(( + foreign_key.column_pairs.clone(), + foreign_key.target_table.clone(), + foreign_key.on_delete.clone(), + )) { + return Err(sqlx::Error::Protocol(format!( + "table {} contains duplicate semantic foreign keys", + table.table_name + ))); + } + let Some(target_table) = tables.get(foreign_key.target_table.as_str()) else { + return Err(sqlx::Error::Protocol(format!( + "foreign key on {} references unmanaged target table {}", + table.table_name, foreign_key.target_table + ))); + }; + let mut source_members = std::collections::BTreeSet::new(); + let mut target_members = std::collections::BTreeSet::new(); + for pair in &foreign_key.column_pairs { + if !source_members.insert(pair.source_column.as_str()) + || !target_members.insert(pair.target_column.as_str()) + { + return Err(sqlx::Error::Protocol(format!( + "foreign key on {} contains duplicate column members", + table.table_name + ))); + } + let source = table + .columns + .iter() + .find(|column| column.name == pair.source_column) + .ok_or_else(|| { + sqlx::Error::Protocol(format!( + "foreign key on {} references missing source column {}", + table.table_name, pair.source_column + )) + })?; + let target = target_table + .columns + .iter() + .find(|column| column.name == pair.target_column) + .ok_or_else(|| { + sqlx::Error::Protocol(format!( + "foreign key on {} references missing target column {}.{}", + table.table_name, target_table.table_name, pair.target_column + )) + })?; + if !source.sql_type.eq_ignore_ascii_case(&target.sql_type) { + return Err(sqlx::Error::Protocol(format!( + "foreign key type mismatch between {}.{} ({}) and {}.{} ({})", + table.table_name, + pair.source_column, + source.sql_type, + target_table.table_name, + pair.target_column, + target.sql_type + ))); + } + if foreign_key.on_delete == DeletePolicy::SetNull && !source.nullable { + return Err(sqlx::Error::Protocol(format!( + "SET NULL foreign key source {}.{} is not nullable", + table.table_name, pair.source_column + ))); + } + } + let target_columns = foreign_key.target_columns().collect::>(); + let target_is_unique = target_table + .primary_keys() + .iter() + .map(String::as_str) + .eq(target_columns.iter().copied()) + || (target_columns.len() == 1 + && target_table.columns.iter().any(|column| { + column.name == target_columns[0] + && (column.is_primary_key || column.is_unique) + })) + || target_table.composite_unique_indexes.iter().any(|columns| { + columns.iter().map(String::as_str).collect::>() == target_columns + }) + || target_table.indexes.iter().any(|index| { + index.is_unique + && index.predicate.is_none() + && index.columns == target_columns.as_slice() + }); + if !target_is_unique { + return Err(sqlx::Error::Protocol(format!( + "foreign key on {} does not reference an exact unique key on {}", + table.table_name, target_table.table_name + ))); + } + } + } + Ok(()) + } +} +pub(super) fn stable_schema_view_hash(schema: &SchemaRef<'_>) -> String { + let mut canonical = String::new(); + let mut extensions = schema.extensions.iter().collect::>(); + extensions.sort(); + for extension in extensions { + canonical.push_str("extension:"); + canonical.push_str(extension); + canonical.push('\n'); + } + + let mut tables = schema.tables.iter().collect::>(); + tables.sort_by(|left, right| left.table_name.cmp(&right.table_name)); + + for table in tables { + canonical.push_str("table:"); + canonical.push_str(&table.entity_name); + canonical.push('|'); + canonical.push_str(&table.table_name); + canonical.push('|'); + canonical.push_str(&table.primary_key); + canonical.push('|'); + canonical.push_str(&table.primary_keys().join(",")); + canonical.push('|'); + canonical.push_str(&table.default_sort); + canonical.push('|'); + canonical.push_str(if table.append_only { + "append_only" + } else { + "mutable" + }); + if table.retention_purge { + canonical.push_str("|retention_purge"); + } + canonical.push('\n'); + + let mut constraints = table + .check_constraints + .iter() + .map(|constraint| { + super::migrations::canonical_check_tokens(&constraint.expression) + .map(|tokens| tokens.join("|")) + .unwrap_or_else(|| constraint.expression.clone()) + }) + .collect::>(); + constraints.sort(); + for constraint in constraints { + canonical.push_str("check:"); + canonical.push_str(&constraint); + canonical.push('\n'); + } + + let mut columns = table.columns.iter().collect::>(); + columns.sort_by(|left, right| left.name.cmp(&right.name)); + for column in columns { + canonical.push_str("column:"); + canonical.push_str(&column.name); + canonical.push('|'); + canonical.push_str(&column.sql_type); + canonical.push('|'); + canonical.push_str(if column.nullable { + "nullable" + } else { + "not_null" + }); + canonical.push('|'); + canonical.push_str(if column.is_primary_key { + "pk" + } else { + "not_pk" + }); + canonical.push('|'); + canonical.push_str(if column.is_unique { + "unique" + } else { + "not_unique" + }); + canonical.push('|'); + canonical.push_str( + &column + .default + .as_deref() + .map(super::dialect::canonicalize_column_default_expression) + .unwrap_or_default(), + ); + canonical.push('|'); + if let Some(spatial) = column.spatial { + canonical.push_str(spatial.kind.as_sql()); + canonical.push(':'); + canonical.push_str(spatial.geometry_type.as_sql()); + canonical.push(':'); + canonical.push_str(&spatial.srid.to_string()); + } + canonical.push('\n'); + } + + let mut indexes = table.indexes.iter().collect::>(); + indexes.sort_by(|left, right| left.name.cmp(right.name)); + for index in indexes { + canonical.push_str("index:"); + canonical.push_str(index.name); + canonical.push('|'); + canonical.push_str(&index.columns.join(",")); + canonical.push('|'); + for position in 0..index.columns.len() { + canonical.push_str(index.direction_at(position).as_sql()); + canonical.push(','); + } + canonical.push('|'); + canonical.push_str(if index.is_unique { "unique" } else { "index" }); + canonical.push('|'); + canonical.push_str(match index.method { + IndexMethod::Default => "default", + IndexMethod::Gist => "gist", + }); + canonical.push('|'); + canonical.push_str(if index.is_spatial { + "spatial" + } else { + "not_spatial" + }); + canonical.push('|'); + if let Some(predicate) = &index.predicate { + canonical.push_str("where_in:"); + canonical.push_str(predicate.column); + canonical.push(':'); + canonical.push_str(&predicate.values.join(",")); + } else { + canonical.push_str("no_predicate"); + } + canonical.push('\n'); + } + + let mut search_indexes = table.search_indexes.iter().collect::>(); + search_indexes.sort_by(|left, right| left.name.cmp(&right.name)); + for index in search_indexes { + canonical.push_str("search_index:"); + canonical.push_str(&index.name); + canonical.push('|'); + canonical.push_str(index.strategy.as_str()); + canonical.push('|'); + canonical.push_str(&index.language); + canonical.push('|'); + canonical.push_str(&index.tokenizer); + canonical.push('|'); + canonical.push_str(&index.min_token_len.to_string()); + canonical.push('|'); + canonical.push_str(if index.fallback_enabled { + "fallback" + } else { + "native_only" + }); + canonical.push('\n'); + + let mut fields = index.fields.iter().collect::>(); + fields.sort_by(|left, right| left.field_name.cmp(&right.field_name)); + for field in fields { + canonical.push_str("search_field:"); + canonical.push_str(&field.field_name); + canonical.push('|'); + canonical.push_str(&field.column_name); + canonical.push('|'); + canonical.push_str(field.weight.as_str()); + canonical.push('|'); + canonical.push_str(field.alias.as_deref().unwrap_or("")); + canonical.push('|'); + canonical.push_str(field.policy.as_deref().unwrap_or("")); + canonical.push('\n'); + } + + let mut json_paths = index.json_paths.iter().collect::>(); + json_paths.sort_by(|left, right| { + left.field_name + .cmp(&right.field_name) + .then_with(|| left.path.cmp(&right.path)) + }); + for json_path in json_paths { + canonical.push_str("search_json_path:"); + canonical.push_str(&json_path.field_name); + canonical.push('|'); + canonical.push_str(&json_path.column_name); + canonical.push('|'); + canonical.push_str(&json_path.path); + canonical.push('|'); + canonical.push_str(json_path.weight.as_str()); + canonical.push('|'); + canonical.push_str(json_path.policy.as_deref().unwrap_or("")); + canonical.push('\n'); + } + + let mut relations = index.relations.iter().collect::>(); + relations.sort_by(|left, right| left.relation_field.cmp(&right.relation_field)); + for relation in relations { + canonical.push_str("search_relation:"); + canonical.push_str(&relation.relation_field); + canonical.push('|'); + canonical.push_str(&relation.target_type); + canonical.push('|'); + canonical.push_str(&relation.fields.join(",")); + canonical.push('|'); + canonical.push_str(relation.weight.as_str()); + canonical.push('|'); + canonical.push_str(&relation.max_items.to_string()); + canonical.push('|'); + canonical.push_str(relation.policy.as_deref().unwrap_or("")); + canonical.push('\n'); + } + } + + let mut foreign_keys = table.foreign_keys.iter().collect::>(); + foreign_keys.sort_by(|left, right| { + (&left.column_pairs, &left.target_table, &left.on_delete).cmp(&( + &right.column_pairs, + &right.target_table, + &right.on_delete, + )) + }); + for foreign_key in foreign_keys { + canonical.push_str("foreign_key:"); + canonical.push_str(&foreign_key.target_table); + canonical.push('|'); + for pair in &foreign_key.column_pairs { + canonical.push_str(&pair.source_column); + canonical.push_str("->"); + canonical.push_str(&pair.target_column); + canonical.push(','); + } + canonical.push_str(foreign_key.on_delete.as_sql()); + canonical.push('\n'); + } + } + + format!("{:016x}", fnv1a64(canonical.as_bytes())) +} +impl SchemaRef<'_> { + pub fn stable_hash(&self) -> String { + stable_schema_view_hash(self) + } +} +impl From<&EntityMetadata> for OwnedTableModel { + fn from(value: &EntityMetadata) -> Self { + let primary_keys = value + .primary_keys + .iter() + .map(|column| (*column).to_string()) + .collect::>(); + let mut table = Self { + entity_name: value.entity_name.to_string(), + table_name: value.table_name.to_string(), + primary_key: value.primary_key.to_string(), + primary_keys, + default_sort: value.default_sort.to_string(), + columns: value + .fields + .iter() + .map(|field| ColumnModel { + name: field.name.to_string(), + sql_type: field.sql_type.to_string(), + spatial: field.spatial, + nullable: field.nullable, + is_primary_key: field.is_primary_key, + is_unique: field.is_unique, + default: field + .default + .map(super::dialect::canonicalize_column_default_expression), + }) + .collect(), + indexes: value.indexes.iter().map(IndexModel::from).collect(), + composite_unique_indexes: value + .composite_unique_indexes + .iter() + .map(|columns| columns.iter().map(|column| (*column).to_string()).collect()) + .collect(), + foreign_keys: value + .relations + .iter() + .filter(|relation| relation.emit_foreign_key) + .map(|relation| { + let column_pairs = relation + .source_columns + .iter() + .zip(relation.target_columns.iter()) + .map(|(source_field, target_column)| { + let source_column = value + .fields + .iter() + .find(|field| field.rust_name == *source_field) + .map_or(*source_field, |field| field.name); + ForeignKeyColumnPairModel { + source_column: source_column.to_string(), + target_column: (*target_column).to_string(), + } + }) + .collect(); + ForeignKeyModel { + constraint_name: None, + column_pairs, + target_table: relation.target_type.to_string(), + is_multiple: relation.is_multiple, + on_delete: relation.on_delete.clone(), + } + }) + .collect(), + search_indexes: value + .search + .filter(|index| index.enabled) + .map(|index| { + vec![SearchIndexModel { + name: index.index_name.to_string(), + table_name: index.table_name.to_string(), + entity_name: index.entity_name.to_string(), + primary_key: index.primary_key.to_string(), + strategy: index.strategy, + language: index.language.to_string(), + tokenizer: index.tokenizer.to_string(), + min_token_len: index.min_token_len, + fallback_enabled: index.fallback_enabled, + fields: index + .fields + .iter() + .map(|field| SearchFieldModel { + field_name: field.field_name.to_string(), + column_name: field.column_name.to_string(), + weight: field.weight, + alias: field.alias.map(str::to_string), + policy: field.policy.map(str::to_string), + }) + .collect(), + json_paths: index + .json_paths + .iter() + .map(|json_path| SearchJsonPathModel { + field_name: json_path.field_name.to_string(), + column_name: json_path.column_name.to_string(), + path: json_path.path.to_string(), + weight: json_path.weight, + policy: json_path.policy.map(str::to_string), + }) + .collect(), + relations: index + .relations + .iter() + .map(|relation| SearchRelationFieldModel { + relation_field: relation.relation_field.to_string(), + target_type: relation.target_type.to_string(), + fields: relation + .fields + .iter() + .map(|field| (*field).to_string()) + .collect(), + weight: relation.weight, + max_items: relation.max_items, + policy: relation.policy.map(str::to_string), + }) + .collect(), + }] + }) + .unwrap_or_default(), + append_only: value.append_only, + retention_purge: value.retention_policy.is_some(), + check_constraints: value + .check_constraints + .iter() + .map(|constraint| CheckConstraintModel { + name: constraint.name.to_string(), + expression: constraint.expression.to_string(), + }) + .collect(), + }; + table.check_constraints.sort(); + + for field in &value.fields { + if field.is_filterable && field.spatial.is_none() { + add_owned_generated_index(&mut table, &[field.name]); + } + } + + table + } +} +impl OwnedSchemaModel { + pub(super) fn from_entities(entities: &[&EntityMetadata]) -> Self { + let entity_table_names = entities + .iter() + .map(|entity| (entity.entity_name, entity.table_name)) + .collect::>(); + let mut extensions = Vec::new(); + if entities + .iter() + .flat_map(|entity| entity.fields.iter()) + .any(|field| field.spatial.is_some()) + { + extensions.push("postgis".to_string()); + } + + let mut tables = entities + .iter() + .map(|entity| { + let mut table = OwnedTableModel::from(*entity); + for foreign_key in &mut table.foreign_keys { + if let Some(table_name) = + entity_table_names.get(foreign_key.target_table.as_str()) + { + foreign_key.target_table = (*table_name).to_string(); + } + } + table + }) + .collect::>(); + + let table_positions = tables + .iter() + .enumerate() + .map(|(index, table)| (table.table_name.clone(), index)) + .collect::>(); + + for entity in entities { + for relation in &entity.relations { + let (table_name, columns) = if relation.is_multiple { + let Some(target_table) = entity_table_names.get(relation.target_type) else { + continue; + }; + (*target_table, relation.target_columns.to_vec()) + } else { + let source_columns = relation + .source_columns + .iter() + .map(|source_field| { + entity + .fields + .iter() + .find(|field| field.rust_name == *source_field) + .map_or(*source_field, |field| field.name) + }) + .collect::>(); + (entity.table_name, source_columns) + }; + + let Some(table_index) = table_positions.get(table_name) else { + continue; + }; + add_owned_generated_index(&mut tables[*table_index], &columns); + } + } + + Self { + extensions, + tables, + limits: Default::default(), + } + } +} +fn index_columns_match(left: &[&str], right: &[&str]) -> bool { + left.len() == right.len() + && left + .iter() + .zip(right.iter()) + .all(|(left, right)| left == right) +} +fn table_has_index_for_columns(table: &TableRef<'_>, columns: &[&str]) -> bool { + if columns.is_empty() { + return true; + } + + if table.primary_keys().len() == columns.len() + && table + .primary_keys() + .iter() + .zip(columns.iter()) + .all(|(left, right)| left == right) + { + return true; + } + + if columns.len() == 1 + && table + .columns + .iter() + .any(|column| column.name == columns[0] && (column.is_primary_key || column.is_unique)) + { + return true; + } + + if table.composite_unique_indexes.iter().any(|index| { + index_columns_match( + &index.iter().map(String::as_str).collect::>(), + columns, + ) + }) { + return true; + } + + table + .indexes + .iter() + .any(|index| index_columns_match(&index.columns, columns)) +} +pub(super) fn add_owned_generated_index(table: &mut OwnedTableModel, columns: &[&str]) { + if table_has_index_for_columns(&table.borrowed(), columns) { + return; + } + + let name = generated_index_name(&table.table_name, columns); + if table.indexes.iter().any(|index| index.name == name) { + return; + } + + table.indexes.push(IndexModel::new( + name, + columns.iter().map(|c| (*c).into()).collect(), + )); +} +#[derive(Clone, Debug, PartialEq)] +pub(super) enum OwnedMigrationStep { + EnableExtension { + name: String, + }, + CreateTable(OwnedTableModel), + DropTable { + table_name: String, + }, + AddColumn { + table_name: String, + column: ColumnModel, + }, + DropColumn { + table_name: String, + column_name: String, + }, + AlterColumn { + table_name: String, + before: ColumnModel, + after: ColumnModel, + }, + CreateIndex { + table_name: String, + index: IndexModel, + }, + DropIndex { + table_name: String, + index_name: String, + }, + CreateSearchIndex { + table_name: String, + index: SearchIndexModel, + }, + DropSearchIndex { + table_name: String, + index_name: String, + }, + AlterSearchIndex { + table_name: String, + before: SearchIndexModel, + after: SearchIndexModel, + }, + AddForeignKey { + table_name: String, + foreign_key: ForeignKeyModel, + }, + DropForeignKey { + table_name: String, + foreign_key: ForeignKeyModel, + }, + SetAppendOnly { + /// Managed physical table whose enforcement contract changes. + table_name: String, + /// Whether append-only enforcement is enabled after this step. + enabled: bool, + /// Whether enforcement admits exact ORM bounded-retention context. + retention_purge: bool, + }, + SetCheckConstraints { + table_name: String, + before: Vec, + after: Vec, + }, +} +#[derive(Clone, Debug)] +pub(super) enum StepRef<'a> { + EnableExtension { + name: &'a String, + }, + CreateTable(TableRef<'a>), + DropTable { + table_name: &'a String, + }, + AddColumn { + table_name: &'a String, + column: &'a ColumnModel, + }, + DropColumn { + table_name: &'a String, + column_name: &'a String, + }, + AlterColumn { + table_name: &'a String, + before: &'a ColumnModel, + after: &'a ColumnModel, + }, + CreateIndex { + table_name: &'a String, + index: IndexRef<'a>, + }, + DropIndex { + table_name: &'a String, + index_name: &'a String, + }, + CreateSearchIndex { + table_name: &'a String, + index: &'a SearchIndexModel, + }, + DropSearchIndex { + table_name: &'a String, + index_name: &'a String, + }, + AlterSearchIndex { + table_name: &'a String, + before: &'a SearchIndexModel, + after: &'a SearchIndexModel, + }, + AddForeignKey { + table_name: &'a String, + foreign_key: &'a ForeignKeyModel, + }, + DropForeignKey { + table_name: &'a String, + foreign_key: &'a ForeignKeyModel, + }, + SetAppendOnly { + /// Managed physical table whose enforcement contract changes. + table_name: &'a String, + /// Whether append-only enforcement is enabled after this step. + enabled: bool, + /// Whether enforcement admits exact ORM bounded-retention context. + retention_purge: bool, + }, + SetCheckConstraints { + table_name: &'a String, + before: &'a Vec, + after: &'a Vec, + }, +} +impl<'a> From<&'a MigrationStep> for StepRef<'a> { + fn from(step: &'a MigrationStep) -> Self { + match step { + MigrationStep::EnableExtension { name } => Self::EnableExtension { name: name }, + MigrationStep::CreateTable(value) => Self::CreateTable(TableRef::from(value)), + MigrationStep::DropTable { table_name } => Self::DropTable { + table_name: table_name, + }, + MigrationStep::AddColumn { table_name, column } => Self::AddColumn { + table_name: table_name, + column: column, + }, + MigrationStep::DropColumn { + table_name, + column_name, + } => Self::DropColumn { + table_name: table_name, + column_name: column_name, + }, + MigrationStep::AlterColumn { + table_name, + before, + after, + } => Self::AlterColumn { + table_name: table_name, + before: before, + after: after, + }, + MigrationStep::CreateIndex { table_name, index } => Self::CreateIndex { + table_name: table_name, + index: IndexRef::from(index), + }, + MigrationStep::DropIndex { + table_name, + index_name, + } => Self::DropIndex { + table_name: table_name, + index_name: index_name, + }, + MigrationStep::CreateSearchIndex { table_name, index } => Self::CreateSearchIndex { + table_name: table_name, + index: index, + }, + MigrationStep::DropSearchIndex { + table_name, + index_name, + } => Self::DropSearchIndex { + table_name: table_name, + index_name: index_name, + }, + MigrationStep::AlterSearchIndex { + table_name, + before, + after, + } => Self::AlterSearchIndex { + table_name: table_name, + before: before, + after: after, + }, + MigrationStep::AddForeignKey { + table_name, + foreign_key, + } => Self::AddForeignKey { + table_name: table_name, + foreign_key: foreign_key, + }, + MigrationStep::DropForeignKey { + table_name, + foreign_key, + } => Self::DropForeignKey { + table_name: table_name, + foreign_key: foreign_key, + }, + MigrationStep::SetAppendOnly { + table_name, + enabled, + retention_purge, + } => Self::SetAppendOnly { + table_name: table_name, + enabled: *enabled, + retention_purge: *retention_purge, + }, + MigrationStep::SetCheckConstraints { + table_name, + before, + after, + } => Self::SetCheckConstraints { + table_name: table_name, + before: before, + after: after, + }, + } + } +} +impl<'a> From<&'a OwnedMigrationStep> for StepRef<'a> { + fn from(step: &'a OwnedMigrationStep) -> Self { + match step { + OwnedMigrationStep::EnableExtension { name } => Self::EnableExtension { name: name }, + OwnedMigrationStep::CreateTable(value) => Self::CreateTable(value.borrowed()), + OwnedMigrationStep::DropTable { table_name } => Self::DropTable { + table_name: table_name, + }, + OwnedMigrationStep::AddColumn { table_name, column } => Self::AddColumn { + table_name: table_name, + column: column, + }, + OwnedMigrationStep::DropColumn { + table_name, + column_name, + } => Self::DropColumn { + table_name: table_name, + column_name: column_name, + }, + OwnedMigrationStep::AlterColumn { + table_name, + before, + after, + } => Self::AlterColumn { + table_name: table_name, + before: before, + after: after, + }, + OwnedMigrationStep::CreateIndex { table_name, index } => Self::CreateIndex { + table_name: table_name, + index: index.borrowed(), + }, + OwnedMigrationStep::DropIndex { + table_name, + index_name, + } => Self::DropIndex { + table_name: table_name, + index_name: index_name, + }, + OwnedMigrationStep::CreateSearchIndex { table_name, index } => { + Self::CreateSearchIndex { + table_name: table_name, + index: index, + } + } + OwnedMigrationStep::DropSearchIndex { + table_name, + index_name, + } => Self::DropSearchIndex { + table_name: table_name, + index_name: index_name, + }, + OwnedMigrationStep::AlterSearchIndex { + table_name, + before, + after, + } => Self::AlterSearchIndex { + table_name: table_name, + before: before, + after: after, + }, + OwnedMigrationStep::AddForeignKey { + table_name, + foreign_key, + } => Self::AddForeignKey { + table_name: table_name, + foreign_key: foreign_key, + }, + OwnedMigrationStep::DropForeignKey { + table_name, + foreign_key, + } => Self::DropForeignKey { + table_name: table_name, + foreign_key: foreign_key, + }, + OwnedMigrationStep::SetAppendOnly { + table_name, + enabled, + retention_purge, + } => Self::SetAppendOnly { + table_name: table_name, + enabled: *enabled, + retention_purge: *retention_purge, + }, + OwnedMigrationStep::SetCheckConstraints { + table_name, + before, + after, + } => Self::SetCheckConstraints { + table_name: table_name, + before: before, + after: after, + }, + } + } +} + +impl OwnedMigrationStep { + pub(super) fn into_legacy(self, current: &SchemaModel, target: &SchemaModel) -> MigrationStep { + match self { + Self::EnableExtension { name } => MigrationStep::EnableExtension { name }, + Self::CreateTable(table) => MigrationStep::CreateTable( + target + .tables + .iter() + .find(|t| t.table_name == table.table_name) + .expect("diff create table belongs to target") + .clone(), + ), + Self::DropTable { table_name } => MigrationStep::DropTable { table_name }, + Self::AddColumn { table_name, column } => { + MigrationStep::AddColumn { table_name, column } + } + Self::DropColumn { + table_name, + column_name, + } => MigrationStep::DropColumn { + table_name, + column_name, + }, + Self::AlterColumn { + table_name, + before, + after, + } => MigrationStep::AlterColumn { + table_name, + before, + after, + }, + Self::CreateIndex { table_name, index } => { + let legacy = target + .tables + .iter() + .chain(¤t.tables) + .filter(|t| t.table_name == table_name) + .flat_map(|t| &t.indexes) + .find(|i| IndexRef::from(*i) == index.borrowed()) + .expect("diff create index belongs to the compared schema") + .clone(); + MigrationStep::CreateIndex { + table_name, + index: legacy, + } + } + Self::DropIndex { + table_name, + index_name, + } => MigrationStep::DropIndex { + table_name, + index_name, + }, + Self::CreateSearchIndex { table_name, index } => { + MigrationStep::CreateSearchIndex { table_name, index } + } + Self::DropSearchIndex { + table_name, + index_name, + } => MigrationStep::DropSearchIndex { + table_name, + index_name, + }, + Self::AlterSearchIndex { + table_name, + before, + after, + } => MigrationStep::AlterSearchIndex { + table_name, + before, + after, + }, + Self::AddForeignKey { + table_name, + foreign_key, + } => MigrationStep::AddForeignKey { + table_name, + foreign_key, + }, + Self::DropForeignKey { + table_name, + foreign_key, + } => MigrationStep::DropForeignKey { + table_name, + foreign_key, + }, + Self::SetAppendOnly { + table_name, + enabled, + retention_purge, + } => MigrationStep::SetAppendOnly { + table_name, + enabled, + retention_purge, + }, + Self::SetCheckConstraints { + table_name, + before, + after, + } => MigrationStep::SetCheckConstraints { + table_name, + before, + after, + }, + } + } +} +#[derive(Clone, Debug)] +pub(super) struct OwnedMigrationPlan { + pub backend: DatabaseBackend, + pub steps: Vec, + pub statements: Vec, +} diff --git a/crates/graphql-orm/src/graphql/orm/runtime_migration.rs b/crates/graphql-orm/src/graphql/orm/runtime_migration.rs new file mode 100644 index 00000000..aeba77f4 --- /dev/null +++ b/crates/graphql-orm/src/graphql/orm/runtime_migration.rs @@ -0,0 +1,1252 @@ +//! Bounded runtime conversion and owned canonical migration plans. +//! +//! Planning reads catalog state only. The host supplies persistent table ownership; +//! applying is a separate managed-policy operation and never reconciles host RLS. +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; + +use super::core::*; +use super::dialect::{DatabaseBackend, SqlDialect}; +use super::migrations::{ + build_migration_plan_views, classify_migration_step_view, migration_step_table_name, +}; +use super::owned_schema::{ + IndexModel, OwnedMigrationStep, OwnedSchemaModel, OwnedTableModel, StepRef, + add_owned_generated_index, +}; +use super::runtime_schema::*; +use super::schema_manager::{ + SchemaManager, reject_migration_risks, stable_plan_hash_views, validate_schema_views, +}; +use super::{NoDefaultBackend, OrmBackend}; + +/// Stable classification of a runtime physical conversion/planning failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimeMigrationDiagnosticCode { + /// This backend has not opted into runtime migration execution. + UnsupportedBackend, + /// No supported generator is defined for the field. + UnsupportedGeneration, + /// Default semantics cannot be represented faithfully. + UnsupportedDefault, + /// Invalid or unsupported canonical physical definition. + InvalidPhysicalContract, + /// Physical or diagnostic names collide across composed targets. + TargetCollision, + /// The target requests tables outside explicit host ownership. + OwnershipMismatch, + /// A change would affect an external dependent object/table. + UnmanagedDependency, + /// A configured resource bound was exceeded. + LimitExceeded, + /// Schema policy refuses the requested operation. + PolicyDenied, + /// The reviewed live baseline or plan integrity no longer matches. + BaselineMismatch, +} + +/// A structured diagnostic identifies schema members without including default values or SQL. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeMigrationDiagnostic { + /// Stable machine-readable classification. + pub code: RuntimeMigrationDiagnosticCode, + /// Stable collection ID, when conversion identified a collection. + pub collection: Option, + /// Stable field/index/relation ID, when known. + pub subject: Option, +} + +/// Complete structured conversion diagnostics. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeMigrationDiagnostics(pub Vec); +impl fmt::Display for RuntimeMigrationDiagnostics { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "runtime migration rejected ({} diagnostics)", + self.0.len() + ) + } +} +impl std::error::Error for RuntimeMigrationDiagnostics {} + +/// Runtime migration failure. Database sources remain available to the trusted host. +#[derive(Debug)] +pub enum RuntimeMigrationError { + /// Structured capability, scope, bounds or integrity rejection. + Diagnostics(RuntimeMigrationDiagnostics), + /// Backend execution failure. + Database(sqlx::Error), +} +impl fmt::Display for RuntimeMigrationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Diagnostics(d) => d.fmt(f), + Self::Database(_) => f.write_str("runtime migration database failure"), + } + } +} +impl std::error::Error for RuntimeMigrationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Diagnostics(d) => Some(d), + Self::Database(e) => Some(e), + } + } +} +impl From for RuntimeMigrationError { + fn from(error: sqlx::Error) -> Self { + Self::Database(error) + } +} +impl From for RuntimeMigrationError { + fn from(diagnostics: RuntimeMigrationDiagnostics) -> Self { + Self::Diagnostics(diagnostics) + } +} +pub(super) fn rejection(code: RuntimeMigrationDiagnosticCode) -> RuntimeMigrationDiagnostics { + RuntimeMigrationDiagnostics(vec![RuntimeMigrationDiagnostic { + code, + collection: None, + subject: None, + }]) +} + +/// Bounds applied before conversion allocations and to the completed canonical target/plan. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RuntimeMigrationLimits { + /// Maximum collections/tables. + pub max_collections: usize, + /// Maximum fields in one collection/table. + pub max_fields: usize, + /// Maximum indexes on one collection/table. + pub max_indexes: usize, + /// Maximum relations/FKs on one collection/table. + pub max_relations: usize, + /// Maximum ordered key/index arity. + pub max_key_members: usize, + /// Maximum literal/default expression bytes per field. + pub max_default_bytes: usize, + /// Maximum aggregate serialized definition bytes. + pub max_target_bytes: usize, + /// Maximum migration steps. + pub max_plan_steps: usize, + /// Maximum rendered statements. + pub max_plan_statements: usize, + /// Maximum aggregate rendered statement bytes. + pub max_plan_bytes: usize, +} +impl Default for RuntimeMigrationLimits { + fn default() -> Self { + Self { + max_collections: 512, + max_fields: 256, + max_indexes: 128, + max_relations: 128, + max_key_members: 32, + max_default_bytes: 65536, + max_target_bytes: 8 * 1024 * 1024, + max_plan_steps: 16384, + max_plan_statements: 32768, + max_plan_bytes: 8 * 1024 * 1024, + } + } +} + +/// Explicit trusted managed-table ownership, independent of the current target. +/// +/// Retain intentionally removed tables in this set to review guarded drops. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ManagedTableSet(BTreeSet); +impl ManagedTableSet { + /// Validate portable trusted table identifiers, rejecting ORM-reserved infrastructure. + pub fn new( + tables: impl IntoIterator, + ) -> Result { + let mut set = BTreeSet::new(); + for table in tables { + if !portable_identifier(&table) + || table.starts_with("__graphql_orm_") + || !set.insert(table) + { + return Err(rejection(RuntimeMigrationDiagnosticCode::OwnershipMismatch)); + } + if set.len() > RuntimeMigrationLimits::default().max_collections { + return Err(rejection(RuntimeMigrationDiagnosticCode::LimitExceeded)); + } + } + Ok(Self(set)) + } + /// Owned physical identifiers in deterministic order. + pub fn tables(&self) -> impl Iterator { + self.0.iter().map(String::as_str) + } + /// Whether the trusted set owns this exact physical table. + pub fn contains(&self, table: &str) -> bool { + self.0.contains(table) + } +} +fn portable_identifier(value: &str) -> bool { + let mut chars = value.chars(); + !value.is_empty() + && value.len() <= MAX_PHYSICAL_IDENTIFIER_LEN + && chars + .next() + .is_some_and(|c| c.is_ascii_lowercase() || c == '_') + && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + +impl ValidatedRuntimeSchema { + /// Convert the existing runtime IR into the canonical physical target without I/O or leaks. + pub fn physical_schema( + &self, + limits: RuntimeMigrationLimits, + ) -> Result { + if !B::RUNTIME_MIGRATIONS_SUPPORTED { + return Err(rejection( + RuntimeMigrationDiagnosticCode::UnsupportedBackend, + )); + } + let mut diagnostics = Vec::new(); + let schema = self.schema(); + if schema.collections.len() > limits.max_collections { + return Err(rejection(RuntimeMigrationDiagnosticCode::LimitExceeded)); + } + let mut definition_bytes = BoundedDefinitionWriter::new(limits.max_target_bytes); + for collection in &schema.collections { + let mut scoped = |code, subject: Option| { + diagnostics.push(RuntimeMigrationDiagnostic { + code, + collection: Some(collection.id.clone()), + subject, + }) + }; + if collection.fields.len() > limits.max_fields + || collection.indexes.len() > limits.max_indexes + || collection.relations.len() > limits.max_relations + || collection.primary_key.len() > limits.max_key_members + || collection + .composite_unique + .iter() + .any(|key| key.len() > limits.max_key_members) + || collection + .indexes + .iter() + .any(|index| index.fields.len() > limits.max_key_members) + || collection + .relations + .iter() + .any(|relation| relation.key_pairs.len() > limits.max_key_members) + { + scoped(RuntimeMigrationDiagnosticCode::LimitExceeded, None); + } + if collection.physical_table.starts_with("__graphql_orm_") { + scoped( + RuntimeMigrationDiagnosticCode::InvalidPhysicalContract, + None, + ); + } + // Count without allocating a serialized catalog copy. + serde_json::to_writer(&mut definition_bytes, collection) + .map_err(|_| rejection(RuntimeMigrationDiagnosticCode::LimitExceeded))?; + for field in &collection.fields { + if field.generated + && field.default.is_none() + && field.value_kind != RuntimeValueKind::Uuid + { + scoped( + RuntimeMigrationDiagnosticCode::UnsupportedGeneration, + Some(field.id.as_str().into()), + ); + } + if matches!(&field.default, Some(RuntimeDefault::Literal(value)) if value.len() > limits.max_default_bytes) + { + scoped( + RuntimeMigrationDiagnosticCode::LimitExceeded, + Some(field.id.as_str().into()), + ); + } + } + } + if definition_bytes.size > limits.max_target_bytes { + diagnostics.push(RuntimeMigrationDiagnostic { + code: RuntimeMigrationDiagnosticCode::LimitExceeded, + collection: None, + subject: None, + }); + } + if !diagnostics.is_empty() { + return Err(RuntimeMigrationDiagnostics(diagnostics)); + } + let by_id = schema + .collections + .iter() + .map(|c| (&c.id, c)) + .collect::>(); + let mut tables = Vec::with_capacity(schema.collections.len()); + for collection in &schema.collections { + let fields = collection + .fields + .iter() + .map(|f| (&f.id, f)) + .collect::>(); + let column_name = |id: &FieldId| fields[id].physical_column.clone(); + let keys = collection + .primary_key + .iter() + .map(column_name) + .collect::>(); + let mut table = OwnedTableModel { + entity_name: collection.api_type_name.clone(), + table_name: collection.physical_table.clone(), + primary_key: keys[0].clone(), + primary_keys: keys, + default_sort: collection + .default_order + .iter() + .map(|term| { + format!( + "{} {}", + fields[&term.field].physical_column, + match term.direction { + RuntimeOrderDirection::Asc => "ASC", + RuntimeOrderDirection::Desc => "DESC", + } + ) + }) + .collect::>() + .join(", "), + columns: collection + .fields + .iter() + .map(|field| { + Ok(ColumnModel { + name: field.physical_column.clone(), + sql_type: runtime_sql_type(B::DIALECT, field.value_kind).into(), + spatial: None, + nullable: field.nullable, + is_primary_key: collection.primary_key.contains(&field.id), + is_unique: field.unique, + default: runtime_default(B::DIALECT, field)?.map(|v| { + super::dialect::canonicalize_column_default_expression(&v) + }), + }) + }) + .collect::, RuntimeMigrationDiagnostics>>()?, + indexes: collection + .indexes + .iter() + .map(|index| { + let mut owned = IndexModel::new( + index.name.clone(), + index.fields.iter().map(column_name).collect(), + ); + owned.is_unique = index.unique; + owned + }) + .collect(), + composite_unique_indexes: collection + .composite_unique + .iter() + .map(|key| key.iter().map(column_name).collect()) + .collect(), + foreign_keys: collection + .relations + .iter() + .filter(|r| r.enforce_foreign_key) + .map(|relation| { + let target = by_id[&relation.target]; + ForeignKeyModel { + constraint_name: None, + column_pairs: relation + .key_pairs + .iter() + .map(|pair| ForeignKeyColumnPairModel { + source_column: column_name(&pair.source), + target_column: target + .fields + .iter() + .find(|field| field.id == pair.target) + .expect("validated relation target member") + .physical_column + .clone(), + }) + .collect(), + target_table: target.physical_table.clone(), + is_multiple: relation.cardinality == RelationCardinality::Many, + on_delete: relation + .on_delete + .clone() + .expect("validated enforcing relation delete action"), + } + }) + .collect(), + search_indexes: vec![], + append_only: collection.append_only, + retention_purge: collection.retention_purge, + check_constraints: vec![], + }; + for field in &collection.fields { + if field.filterable { + add_owned_generated_index(&mut table, &[&field.physical_column]); + } + } + for relation in collection + .relations + .iter() + .filter(|r| r.enforce_foreign_key) + { + let columns = relation + .key_pairs + .iter() + .map(|pair| fields[&pair.source].physical_column.as_str()) + .collect::>(); + add_owned_generated_index(&mut table, &columns); + } + tables.push(table); + } + // The same generated-index rule serves forward and inverse relations, + // including non-enforcing logical relations on target columns. + for collection in &schema.collections { + for relation in &collection.relations { + let (table_name, columns) = if relation.cardinality == RelationCardinality::Many { + let target = by_id[&relation.target]; + ( + &target.physical_table, + relation + .key_pairs + .iter() + .map(|pair| { + target + .fields + .iter() + .find(|field| field.id == pair.target) + .expect("validated member") + .physical_column + .as_str() + }) + .collect::>(), + ) + } else { + ( + &collection.physical_table, + relation + .key_pairs + .iter() + .map(|pair| { + collection + .fields + .iter() + .find(|field| field.id == pair.source) + .expect("validated member") + .physical_column + .as_str() + }) + .collect::>(), + ) + }; + let table = tables + .iter_mut() + .find(|table| &table.table_name == table_name) + .expect("validated target"); + add_owned_generated_index(table, &columns); + } + } + let target = OwnedSchemaModel { + extensions: vec![], + tables, + limits, + }; + target + .validate_physical_contract() + .map_err(|_| rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract))?; + target.check_limits(limits)?; + Ok(target) + } +} +fn runtime_sql_type(backend: DatabaseBackend, kind: RuntimeValueKind) -> &'static str { + match (backend, kind) { + (DatabaseBackend::Postgres, RuntimeValueKind::Boolean) => "BOOLEAN", + (_, RuntimeValueKind::Boolean | RuntimeValueKind::Integer) + if backend == DatabaseBackend::Sqlite => + { + "INTEGER" + } + (_, RuntimeValueKind::Integer) => "BIGINT", + (DatabaseBackend::Postgres, RuntimeValueKind::Float) => "DOUBLE PRECISION", + (_, RuntimeValueKind::Float) => "REAL", + (DatabaseBackend::Postgres, RuntimeValueKind::Uuid) => "UUID", + (DatabaseBackend::Postgres, RuntimeValueKind::Json) => "JSONB", + (DatabaseBackend::Postgres, RuntimeValueKind::Bytes) => "BYTEA", + (_, RuntimeValueKind::Bytes) => "BLOB", + (DatabaseBackend::Postgres, RuntimeValueKind::DateTime) => "TIMESTAMPTZ", + _ => "TEXT", + } +} +fn runtime_default( + backend: DatabaseBackend, + field: &RuntimeField, +) -> Result, RuntimeMigrationDiagnostics> { + match &field.default { + None => Ok(None), + Some(RuntimeDefault::CurrentTimestamp) => match field.value_kind { + RuntimeValueKind::Integer => Ok(Some(backend.current_epoch_expr().into())), + RuntimeValueKind::DateTime if backend == DatabaseBackend::Sqlite => { + Ok(Some("strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')".into())) + } + RuntimeValueKind::DateTime => Ok(Some("CURRENT_TIMESTAMP".into())), + _ => Err(rejection( + RuntimeMigrationDiagnosticCode::UnsupportedDefault, + )), + }, + Some(RuntimeDefault::Literal(value)) => Ok(Some(match field.value_kind { + RuntimeValueKind::String | RuntimeValueKind::Uuid => { + format!("'{}'", value.replace('\'', "''")) + } + RuntimeValueKind::Boolean | RuntimeValueKind::Integer | RuntimeValueKind::Float => { + value.clone() + } + _ => { + return Err(rejection( + RuntimeMigrationDiagnosticCode::UnsupportedDefault, + )); + } + })), + } +} + +impl OwnedSchemaModel { + /// Compose host-owned static/system entities, preserving all physical policy metadata. + /// RLS declarations remain on the separate existing [`SchemaTarget`] path. + pub fn with_static_entities( + mut self, + entities: &[&EntityMetadata], + ) -> Result { + let other = Self::from_entities(entities); + self.tables.extend(other.tables); + self.extensions.extend(other.extensions); + self.extensions.sort(); + self.extensions.dedup(); + let mut names = BTreeSet::new(); + let mut labels = BTreeSet::new(); + if self.tables.iter().any(|table| { + !names.insert(table.table_name.clone()) || !labels.insert(table.entity_name.clone()) + }) { + return Err(rejection(RuntimeMigrationDiagnosticCode::TargetCollision)); + } + let physical = self + .tables + .iter() + .map(|t| (t.entity_name.clone(), t.table_name.clone())) + .collect::>(); + for table in &mut self.tables { + for foreign_key in &mut table.foreign_keys { + if let Some(name) = physical.get(&foreign_key.target_table) { + foreign_key.target_table.clone_from(name); + } + } + } + self.check_limits(self.limits)?; + self.validate_physical_contract() + .map_err(|_| rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract))?; + Ok(self) + } + fn check_limits( + &self, + limits: RuntimeMigrationLimits, + ) -> Result<(), RuntimeMigrationDiagnostics> { + if self.tables.len() > limits.max_collections + || self.tables.iter().any(|table| { + table.columns.len() > limits.max_fields + || table.indexes.len() > limits.max_indexes + || table.foreign_keys.len() > limits.max_relations + || table.primary_keys().len() > limits.max_key_members + || table.columns.iter().any(|column| { + column + .default + .as_ref() + .is_some_and(|value| value.len() > limits.max_default_bytes) + }) + || table + .indexes + .iter() + .any(|index| index.columns.len() > limits.max_key_members) + || table + .composite_unique_indexes + .iter() + .any(|key| key.len() > limits.max_key_members) + || table + .foreign_keys + .iter() + .any(|fk| fk.column_pairs.len() > limits.max_key_members) + }) + { + return Err(rejection(RuntimeMigrationDiagnosticCode::LimitExceeded)); + } + // The canonical Debug view includes every stored string, including checks, + // predicates, FK members and search metadata; writing counts, never copies. + let mut writer = BoundedDefinitionWriter::new(limits.max_target_bytes); + std::fmt::write(&mut writer, format_args!("{:?}", self.borrowed())) + .map_err(|_| rejection(RuntimeMigrationDiagnosticCode::LimitExceeded)) + } +} + +/// Read-only inspection of one classified owned canonical step. +#[derive(Clone, Debug)] +pub struct OwnedPlannedMigrationStep { + step: OwnedMigrationStep, + risk: MigrationRisk, + reason: String, +} +impl OwnedPlannedMigrationStep { + /// Existing canonical risk classification. + pub fn risk(&self) -> MigrationRisk { + self.risk + } + /// Existing classification explanation. + pub fn reason(&self) -> &str { + &self.reason + } + /// Physical table affected, if any. + pub fn table_name(&self) -> Option<&str> { + migration_step_table_name(&StepRef::from(&self.step)) + } +} + +/// Immutable owned plan bound to explicit ownership, backend and introspected baseline. +#[derive(Clone, Debug)] +pub struct OwnedPlannedMigration { + version: String, + description: String, + backend: &'static str, + source_schema_hash: String, + target_schema_hash: String, + plan_hash: String, + ownership: ManagedTableSet, + steps: Vec, + statements: Vec, + target: OwnedSchemaModel, +} +impl OwnedPlannedMigration { + /// Host version identifier. + pub fn version(&self) -> &str { + &self.version + } + /// Human description. + pub fn description(&self) -> &str { + &self.description + } + /// Selected backend. + pub fn backend(&self) -> &'static str { + self.backend + } + /// Complete introspected physical baseline hash. + pub fn source_schema_hash(&self) -> &str { + &self.source_schema_hash + } + /// Existing canonical physical target hash. + pub fn target_schema_hash(&self) -> &str { + &self.target_schema_hash + } + /// Plan integrity bound to the explicit managed set. + pub fn plan_hash(&self) -> &str { + &self.plan_hash + } + /// Trusted owned tables. + pub fn ownership(&self) -> &ManagedTableSet { + &self.ownership + } + /// Classified immutable canonical steps. + pub fn steps(&self) -> &[OwnedPlannedMigrationStep] { + &self.steps + } + /// Exact statements reviewed by the host. + pub fn statements(&self) -> &[String] { + &self.statements + } + fn integrity_hash(&self) -> String { + let semantic = stable_plan_hash_views( + self.backend, + self.steps + .iter() + .map(|step| (step.risk, StepRef::from(&step.step), step.reason.as_str())), + &self.statements, + ); + let canonical = format!( + "owned-migration-v1|{}|{}|{}|{}|{}|{}", + self.version, + self.description, + self.source_schema_hash, + self.target_schema_hash, + self.ownership.tables().collect::>().join(","), + semantic + ); + format!("{:016x}", fnv1a64(canonical.as_bytes())) + } + pub(super) fn validate_apply( + &self, + policy: SchemaPolicy, + backend: &'static str, + options: &ApplyOptions, + ) -> Result<(), RuntimeMigrationError> { + if !policy.allows_application() { + return Err(rejection(RuntimeMigrationDiagnosticCode::PolicyDenied).into()); + } + if self.backend != backend + || self.plan_hash != self.integrity_hash() + || self.target_schema_hash != self.target.stable_hash() + || self + .steps + .iter() + .filter_map(OwnedPlannedMigrationStep::table_name) + .any(|table| !self.ownership.contains(table)) + { + return Err(rejection(RuntimeMigrationDiagnosticCode::BaselineMismatch).into()); + } + if options + .expected_current_schema_hash + .as_deref() + .is_some_and(|expected| expected != self.source_schema_hash) + { + return Err(rejection(RuntimeMigrationDiagnosticCode::BaselineMismatch).into()); + } + reject_migration_risks( + &self.version, + self.steps + .iter() + .map(|step| (step.risk, StepRef::from(&step.step))), + options, + )?; + Ok(()) + } + #[cfg(feature = "sqlite")] + pub(super) fn rebuilt_sqlite_tables(&self) -> impl Iterator { + self.ownership.tables().filter(|table| { + let temporary = format!("__graphql_orm_{table}_new"); + self.statements + .iter() + .any(|sql| sql.starts_with(&format!("CREATE TABLE {temporary} "))) + }) + } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(super) fn application_metadata(&self) -> MigrationApplicationMetadata { + MigrationApplicationMetadata { + backend: self.backend, + graphql_orm_version: env!("CARGO_PKG_VERSION"), + source_schema_hash: Some(self.source_schema_hash.clone()), + target_schema_hash: self.target_schema_hash.clone(), + plan_hash: self.plan_hash.clone(), + policy: SchemaPolicy::Managed, + } + } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(super) fn recorded_version_report( + &self, + recorded: bool, + ) -> crate::Result> { + super::schema_manager::recorded_version_report( + &self.version, + super::schema_manager::RemainingPlanWork { + migration_steps: self.steps.len(), + migration_statements: self.statements.len(), + rls_statements: 0, + combined_statements: self.statements.len(), + }, + recorded, + ) + } + #[cfg(feature = "postgres")] + pub(super) fn changes_protected_rls_structure(&self, table: &str) -> bool { + self.steps.iter().any(|step| match &step.step { + OwnedMigrationStep::DropTable { table_name } + | OwnedMigrationStep::DropColumn { table_name, .. } + | OwnedMigrationStep::AlterColumn { table_name, .. } => table_name == table, + _ => false, + }) + } + #[cfg(any(feature = "sqlite", feature = "postgres"))] + pub(super) fn check_baseline( + &self, + current: &OwnedSchemaModel, + ) -> Result<(), RuntimeMigrationError> { + if current.stable_hash() != self.source_schema_hash { + return Err(rejection(RuntimeMigrationDiagnosticCode::BaselineMismatch).into()); + } + validate_owned_effects( + if self.backend == "postgres" { + DatabaseBackend::Postgres + } else { + DatabaseBackend::Sqlite + }, + current, + &self.target, + &self.ownership, + ) + .map_err(Into::into) + } +} + +/// Additional capability; existing backend trait implementations acquire no new requirements. +#[allow(async_fn_in_trait)] +pub trait RuntimeMigrationBackend: OrmBackend { + /// True only for implemented owned migration lanes. + const RUNTIME_MIGRATIONS_SUPPORTED: bool = false; + /// Read the existing canonical physical schema using owned index storage. + #[doc(hidden)] + async fn introspect_owned( + pool: &Self::Pool, + ) -> Result { + let _ = pool; + Err(rejection(RuntimeMigrationDiagnosticCode::UnsupportedBackend).into()) + } + /// Read all physical dependency sources, including ORM infrastructure, without leaks. + #[doc(hidden)] + async fn introspect_dependencies( + pool: &Self::Pool, + ) -> Result { + let _ = pool; + Err(rejection(RuntimeMigrationDiagnosticCode::UnsupportedBackend).into()) + } + /// Reject live semantics that cannot be safely preserved by owned application. + #[doc(hidden)] + async fn validate_owned_plan( + pool: &Self::Pool, + plan: &OwnedPlannedMigration, + ) -> Result<(), RuntimeMigrationError> { + let _ = (pool, plan); + Err(rejection(RuntimeMigrationDiagnosticCode::UnsupportedBackend).into()) + } + /// Guarded application through the existing migration transaction/history engine. + #[doc(hidden)] + async fn apply_owned( + pool: &Self::Pool, + plan: &OwnedPlannedMigration, + options: &ApplyOptions, + ) -> Result { + let _ = (pool, plan, options); + Err(rejection(RuntimeMigrationDiagnosticCode::UnsupportedBackend).into()) + } +} +#[cfg(feature = "mssql")] +impl RuntimeMigrationBackend for super::MssqlBackend {} +impl RuntimeMigrationBackend for NoDefaultBackend {} + +#[cfg(feature = "sqlite")] +impl RuntimeMigrationBackend for super::SqliteBackend { + const RUNTIME_MIGRATIONS_SUPPORTED: bool = true; + async fn introspect_owned( + pool: &Self::Pool, + ) -> Result { + Ok(super::migrations::introspect_owned_sqlite_schema(pool).await?) + } + async fn introspect_dependencies( + pool: &Self::Pool, + ) -> Result { + let mut connection = pool.acquire().await?; + Ok( + super::migrations::introspect_owned_sqlite_connection_with_internal( + &mut connection, + true, + ) + .await?, + ) + } + async fn validate_owned_plan( + pool: &Self::Pool, + plan: &OwnedPlannedMigration, + ) -> Result<(), RuntimeMigrationError> { + let mut connection = pool.acquire().await?; + super::execution::validate_owned_sqlite_plan(&mut connection, plan).await + } + async fn apply_owned( + pool: &Self::Pool, + plan: &OwnedPlannedMigration, + options: &ApplyOptions, + ) -> Result { + Ok(super::execution::apply_owned_sqlite_migration(pool, plan, options).await?) + } +} +#[cfg(feature = "postgres")] +impl RuntimeMigrationBackend for super::PostgresBackend { + const RUNTIME_MIGRATIONS_SUPPORTED: bool = true; + async fn introspect_owned( + pool: &Self::Pool, + ) -> Result { + Ok(super::migrations::introspect_owned_postgres_schema(pool).await?) + } + async fn introspect_dependencies( + pool: &Self::Pool, + ) -> Result { + let mut connection = pool.acquire().await?; + Ok( + super::migrations::introspect_owned_postgres_connection_with_internal( + &mut connection, + true, + ) + .await?, + ) + } + async fn validate_owned_plan( + pool: &Self::Pool, + plan: &OwnedPlannedMigration, + ) -> Result<(), RuntimeMigrationError> { + let mut connection = pool.acquire().await?; + super::execution::validate_owned_postgres_plan(&mut connection, plan).await + } + async fn apply_owned( + pool: &Self::Pool, + plan: &OwnedPlannedMigration, + options: &ApplyOptions, + ) -> Result { + Ok(super::execution::apply_owned_postgres_migration(pool, plan, options).await?) + } +} + +impl<'db, B: RuntimeMigrationBackend> SchemaManager<'db, B> { + /// Read-only live validation within explicit table ownership. + pub async fn validate_owned_schema( + &self, + target: &OwnedSchemaModel, + ownership: &ManagedTableSet, + ) -> Result { + supported::()?; + let current = B::introspect_owned(self.database.pool()).await?; + validate_owned_effects(B::DIALECT, ¤t, target, ownership)?; + let scoped = scoped_current(¤t, ownership, PlanOptions::strict(), target); + Ok(validate_schema_views( + B::DIALECT.name(), + self.policy(), + &scoped.borrowed(), + &target.borrowed(), + )) + } + /// Read-only bounded planning, never creating history tables or changing RLS. + pub async fn plan_owned_migration( + &self, + version: impl Into, + description: impl Into, + target: &OwnedSchemaModel, + ownership: &ManagedTableSet, + options: PlanOptions, + ) -> Result { + supported::()?; + if !self.policy().allows_planning() { + return Err(rejection(RuntimeMigrationDiagnosticCode::PolicyDenied).into()); + } + target.check_limits(target.limits)?; + target + .validate_physical_contract() + .map_err(|_| rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract))?; + let version = version.into(); + let description = description.into(); + if version.trim().is_empty() || version.len() > 256 || description.len() > 4096 { + return Err(rejection(RuntimeMigrationDiagnosticCode::LimitExceeded).into()); + } + let current = B::introspect_owned(self.database.pool()).await?; + validate_owned_effects(B::DIALECT, ¤t, target, ownership)?; + let scoped = scoped_current(¤t, ownership, options, target); + let physical = + build_migration_plan_views(B::DIALECT, &scoped.borrowed(), &target.borrowed()); + let limits = target.limits; + if physical.steps.len() > limits.max_plan_steps + || physical.statements.len() > limits.max_plan_statements + || physical + .statements + .iter() + .try_fold(0usize, |sum, sql| sum.checked_add(sql.len())) + .is_none_or(|size| size > limits.max_plan_bytes) + { + return Err(rejection(RuntimeMigrationDiagnosticCode::LimitExceeded).into()); + } + let steps = physical + .steps + .into_iter() + .map(|step| { + let (risk, reason) = classify_migration_step_view(&StepRef::from(&step)); + OwnedPlannedMigrationStep { + step, + risk, + reason: reason.into(), + } + }) + .collect(); + let mut plan = OwnedPlannedMigration { + version, + description, + backend: B::DIALECT.name(), + source_schema_hash: current.stable_hash(), + target_schema_hash: target.stable_hash(), + plan_hash: String::new(), + ownership: ownership.clone(), + steps, + statements: physical.statements, + target: target.clone(), + }; + plan.plan_hash = plan.integrity_hash(); + B::validate_owned_plan(self.database.pool(), &plan).await?; + Ok(plan) + } + /// Separate guarded application of an immutable owned plan; never reconciles RLS. + pub async fn apply_owned_migration( + &self, + plan: &OwnedPlannedMigration, + options: ApplyOptions, + ) -> Result { + supported::()?; + plan.validate_apply(self.policy(), B::DIALECT.name(), &options)?; + if options.dry_run { + return Ok(AppliedMigrationReport { + version: plan.version.clone(), + dry_run: true, + statements_applied: 0, + already_applied: false, + }); + } + B::apply_owned(self.database.pool(), plan, &options).await + } + /// Verify complete composed incoming dependencies for later single-record runtime writes. + pub async fn runtime_mutation_environment( + &self, + schema: Arc, + target: &OwnedSchemaModel, + ownership: &ManagedTableSet, + ) -> Result { + supported::()?; + target + .validate_physical_contract() + .map_err(|_| rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract))?; + let current = B::introspect_owned(self.database.pool()).await?; + validate_owned_effects(B::DIALECT, ¤t, target, ownership)?; + let scoped = scoped_current(¤t, ownership, PlanOptions::strict(), target); + if !build_migration_plan_views(B::DIALECT, &scoped.borrowed(), &target.borrowed()) + .steps + .is_empty() + { + return Err(rejection(RuntimeMigrationDiagnosticCode::BaselineMismatch).into()); + } + let runtime = schema.physical_schema::(RuntimeMigrationLimits::default())?; + let relevant = OwnedSchemaModel { + limits: target.limits, + extensions: runtime.extensions.clone(), + tables: target + .tables + .iter() + .filter(|table| { + runtime + .tables + .iter() + .any(|r| r.table_name == table.table_name) + }) + .cloned() + .collect(), + }; + if relevant.tables.len() != runtime.tables.len() + || !build_migration_plan_views(B::DIALECT, &runtime.borrowed(), &relevant.borrowed()) + .steps + .is_empty() + || !build_migration_plan_views(B::DIALECT, &relevant.borrowed(), &runtime.borrowed()) + .steps + .is_empty() + { + return Err(rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract).into()); + } + let mut incoming = BTreeMap::>::new(); + // Live dependencies include every system/external source, even reserved infrastructure. + let dependencies = B::introspect_dependencies(self.database.pool()).await?; + for table in &dependencies.tables { + for foreign_key in &table.foreign_keys { + incoming + .entry(foreign_key.target_table.clone()) + .or_default() + .push(VerifiedRuntimeDependency { + source_table: table.table_name.clone(), + target_table: foreign_key.target_table.clone(), + column_pairs: foreign_key.column_pairs.clone(), + on_delete: foreign_key.on_delete.clone(), + }); + } + } + Ok(RuntimeMutationEnvironment { + schema, + physical_baseline_hash: current.stable_hash(), + ownership: ownership.clone(), + incoming, + }) + } +} +fn supported() -> Result<(), RuntimeMigrationError> { + if B::RUNTIME_MIGRATIONS_SUPPORTED { + Ok(()) + } else { + Err(rejection(RuntimeMigrationDiagnosticCode::UnsupportedBackend).into()) + } +} +fn scoped_current( + current: &OwnedSchemaModel, + ownership: &ManagedTableSet, + options: PlanOptions, + target: &OwnedSchemaModel, +) -> OwnedSchemaModel { + OwnedSchemaModel { + limits: target.limits, + extensions: current.extensions.clone(), + tables: current + .tables + .iter() + .filter(|table| { + ownership.contains(&table.table_name) + && (!options.ignore_unmanaged_tables + || target + .tables + .iter() + .any(|t| t.table_name == table.table_name)) + }) + .cloned() + .collect(), + } +} +fn validate_owned_effects( + backend: DatabaseBackend, + current: &OwnedSchemaModel, + target: &OwnedSchemaModel, + ownership: &ManagedTableSet, +) -> Result<(), RuntimeMigrationDiagnostics> { + if target + .tables + .iter() + .any(|table| !ownership.contains(&table.table_name)) + { + return Err(rejection(RuntimeMigrationDiagnosticCode::OwnershipMismatch)); + } + for table in ¤t.tables { + if let Some(wanted) = target + .tables + .iter() + .find(|t| t.table_name == table.table_name) + { + if table.primary_keys != wanted.primary_keys { + return Err(rejection( + RuntimeMigrationDiagnosticCode::InvalidPhysicalContract, + )); + } + } + // Unmanaged incoming dependencies prevent destructive/structural target changes. + if !ownership.contains(&table.table_name) { + for foreign_key in &table.foreign_keys { + if ownership.contains(&foreign_key.target_table) { + let unchanged = current + .tables + .iter() + .find(|t| t.table_name == foreign_key.target_table) + .zip( + target + .tables + .iter() + .find(|t| t.table_name == foreign_key.target_table), + ) + .is_some_and(|(before, after)| { + before.primary_keys == after.primary_keys + && before.columns.len() == after.columns.len() + && before.columns.iter().zip(&after.columns).all( + |(before, after)| { + before.name == after.name + && !super::migrations::column_changed_for_backend( + backend, before, after, + ) + }, + ) + }); + if !unchanged { + return Err(rejection( + RuntimeMigrationDiagnosticCode::UnmanagedDependency, + )); + } + } + } + } + } + Ok(()) +} + +/// One verified incoming physical FK, including host system tables. +#[derive(Clone, Debug, PartialEq)] +pub struct VerifiedRuntimeDependency { + /// Owning source table. + pub source_table: String, + /// Referenced physical table. + pub target_table: String, + /// Ordered source/target members. + pub column_pairs: Vec, + /// Incoming action; mutation engines reject modifying cascades initially. + pub on_delete: DeletePolicy, +} +/// Immutable schema-bound physical dependency certificate, not another schema IR. +#[derive(Clone, Debug)] +pub struct RuntimeMutationEnvironment { + schema: Arc, + physical_baseline_hash: String, + ownership: ManagedTableSet, + incoming: BTreeMap>, +} +impl RuntimeMutationEnvironment { + /// Validated schema pinned by the host generation. + pub fn schema(&self) -> &Arc { + &self.schema + } + /// Live introspection identity; revalidate if external DDL can bypass the host fence. + pub fn physical_baseline_hash(&self) -> &str { + &self.physical_baseline_hash + } + /// Explicit ownership supporting this certificate. + pub fn ownership(&self) -> &ManagedTableSet { + &self.ownership + } + /// Incoming physical FKs, including sources absent from the runtime schema. + pub fn incoming_dependencies(&self, table: &str) -> &[VerifiedRuntimeDependency] { + self.incoming.get(table).map(Vec::as_slice).unwrap_or(&[]) + } +} + +struct BoundedDefinitionWriter { + size: usize, + maximum: usize, +} +impl BoundedDefinitionWriter { + fn new(maximum: usize) -> Self { + Self { size: 0, maximum } + } + fn count(&mut self, size: usize) -> bool { + if let Some(total) = self + .size + .checked_add(size) + .filter(|total| *total <= self.maximum) + { + self.size = total; + true + } else { + false + } + } +} +impl std::io::Write for BoundedDefinitionWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if self.count(bytes.len()) { + Ok(bytes.len()) + } else { + Err(std::io::Error::other("definition limit exceeded")) + } + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} +impl fmt::Write for BoundedDefinitionWriter { + fn write_str(&mut self, value: &str) -> fmt::Result { + if self.count(value.len()) { + Ok(()) + } else { + Err(fmt::Error) + } + } +} diff --git a/crates/graphql-orm/src/graphql/orm/schema_manager.rs b/crates/graphql-orm/src/graphql/orm/schema_manager.rs index 7f29257e..79ee0c7d 100644 --- a/crates/graphql-orm/src/graphql/orm/schema_manager.rs +++ b/crates/graphql-orm/src/graphql/orm/schema_manager.rs @@ -9,6 +9,7 @@ use super::execution::{ applied_migration_records, applied_version_set, ensure_managed_policy, ensure_planning_policy, }; use super::migrations::{build_migration_plan, classify_migration_steps}; +use super::owned_schema::{SchemaRef, StepRef}; use super::rls::{build_rls_policy_plan, validate_rls_models}; use super::{IntrospectionBackend, MigrationBackend, OrmBackend, RlsIntrospectionBackend}; use crate::db::Database; @@ -20,7 +21,7 @@ use crate::db::Database; /// the database; migration application requires a backend implementing /// [`MigrationBackend`]. pub struct SchemaManager<'db, B: OrmBackend> { - database: &'db Database, + pub(super) database: &'db Database, } impl<'db, B: OrmBackend> SchemaManager<'db, B> { @@ -478,6 +479,19 @@ pub fn validate_schema_models( policy: SchemaPolicy, current: &SchemaModel, target: &SchemaModel, +) -> SchemaValidationReport { + validate_schema_views( + backend, + policy, + &SchemaRef::from(current), + &SchemaRef::from(target), + ) +} +pub(super) fn validate_schema_views( + backend: &'static str, + policy: SchemaPolicy, + current: &SchemaRef<'_>, + target: &SchemaRef<'_>, ) -> SchemaValidationReport { let current_tables = current .tables @@ -497,7 +511,7 @@ pub fn validate_schema_models( .iter() .map(|extension| extension.to_ascii_lowercase()) .collect::>(); - for extension in &target.extensions { + for extension in target.extensions { if !current_extensions.contains(&extension.to_ascii_lowercase()) { diagnostics.push(diagnostic( SchemaDiagnosticSeverity::Error, @@ -701,11 +715,11 @@ fn schema_current_for_plan( /// Summary of remaining work used for recorded-version apply decisions. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct RemainingPlanWork { - migration_steps: usize, - migration_statements: usize, - rls_statements: usize, - combined_statements: usize, +pub(super) struct RemainingPlanWork { + pub(super) migration_steps: usize, + pub(super) migration_statements: usize, + pub(super) rls_statements: usize, + pub(super) combined_statements: usize, } impl RemainingPlanWork { @@ -746,7 +760,19 @@ async fn resolve_recorded_version_apply( version: &str, work: RemainingPlanWork, ) -> crate::Result> { - if !applied_version_set::(pool).await?.contains(version) { + recorded_version_report( + version, + work, + applied_version_set::(pool).await?.contains(version), + ) +} + +pub(super) fn recorded_version_report( + version: &str, + work: RemainingPlanWork, + recorded: bool, +) -> crate::Result> { + if !recorded { return Ok(None); } @@ -797,29 +823,37 @@ fn plan_migration_for_backend( } fn reject_disallowed_risks(plan: &PlannedMigration, options: &ApplyOptions) -> crate::Result<()> { - if options.additive_only { - if let Some(step) = plan - .steps + reject_migration_risks( + &plan.version, + plan.steps .iter() - .find(|step| step.risk != MigrationRisk::Additive) + .map(|step| (step.risk, StepRef::from(&step.step))), + options, + ) +} +pub(super) fn reject_migration_risks<'a>( + version: &str, + mut steps: impl Iterator)> + Clone, + options: &ApplyOptions, +) -> crate::Result<()> { + if options.additive_only { + if let Some(step) = steps + .clone() + .find(|(risk, _)| *risk != MigrationRisk::Additive) { return Err(sqlx::Error::Protocol(format!( "Migration {} contains non-additive step {:?}; disable additive_only to apply it", - plan.version, step.step + version, step.1 ))); } } if options.allow_destructive { return Ok(()); } - if let Some(step) = plan - .steps - .iter() - .find(|step| step.risk == MigrationRisk::Destructive) - { + if let Some(step) = steps.find(|(risk, _)| *risk == MigrationRisk::Destructive) { return Err(sqlx::Error::Protocol(format!( "Migration {} contains destructive step {:?}; set allow_destructive to apply it", - plan.version, step.step + version, step.1 ))); } Ok(()) @@ -829,14 +863,24 @@ fn stable_plan_hash( backend: &'static str, steps: &[PlannedMigrationStep], statements: &[String], +) -> String { + stable_plan_hash_views( + backend, + steps + .iter() + .map(|step| (step.risk, StepRef::from(&step.step), step.reason.as_str())), + statements, + ) +} +pub(super) fn stable_plan_hash_views<'a>( + backend: &'static str, + steps: impl IntoIterator, &'a str)>, + statements: &[String], ) -> String { let mut canonical = backend.to_string(); canonical.push('\n'); - for step in steps { - canonical.push_str(&format!( - "{:?}|{:?}|{}\n", - step.risk, step.step, step.reason - )); + for (risk, step, reason) in steps { + canonical.push_str(&format!("{:?}|{:?}|{}\n", risk, step, reason)); } for statement in statements { canonical.push_str(statement); diff --git a/crates/graphql-orm/tests/runtime_migrations.rs b/crates/graphql-orm/tests/runtime_migrations.rs new file mode 100644 index 00000000..83e05de0 --- /dev/null +++ b/crates/graphql-orm/tests/runtime_migrations.rs @@ -0,0 +1,469 @@ +#![cfg(any(feature = "sqlite", feature = "postgres"))] + +use graphql_orm::GraphQLSchemaEntity; +use graphql_orm::db::Database; +use graphql_orm::graphql::orm::*; + +#[cfg(feature = "postgres")] +#[path = "support/owned_postgres.rs"] +mod owned_postgres; + +#[derive(GraphQLSchemaEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity(table = "owned_notes", plural = "Notes", default_sort = "id ASC")] +struct Note { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + #[filterable(type = "string")] + label: String, + #[graphql_orm(default = "7")] + score: i64, + optional: Option, +} + +fn schema() -> ValidatedRuntimeSchema { + RuntimeSchema::from_static_entities(&[Note::metadata()]) + .unwrap() + .validate() + .unwrap() +} +fn target() -> OwnedSchemaModel { + schema() + .physical_schema::(RuntimeMigrationLimits::default()) + .unwrap() +} +fn ownership() -> ManagedTableSet { + ManagedTableSet::new(["owned_notes".into()]).unwrap() +} +fn rejected(error: RuntimeMigrationError, code: RuntimeMigrationDiagnosticCode) { + assert!( + matches!(error, RuntimeMigrationError::Diagnostics(ref d) if d.0.iter().any(|d| d.code == code)), + "{error:?}" + ); +} + +#[test] +fn canonical_owned_storage_matches_static_target_and_legacy_hash() { + let static_target = SchemaModel::from_entities(&[Note::metadata()]); + let owned = OwnedSchemaModel::from(&static_target); + assert_eq!(owned.stable_hash(), static_target.stable_hash()); + #[cfg(feature = "sqlite")] + assert_eq!( + target::().stable_hash(), + static_target.stable_hash() + ); + #[cfg(feature = "postgres")] + assert_eq!( + target::().stable_hash(), + static_target.stable_hash() + ); + assert!(OwnedSchemaModel::default().tables().is_empty()); +} + +#[test] +fn unsupported_generation_and_conversion_limits_are_structured() { + let mut definition = schema().schema().clone(); + definition.collections[0].fields[0].generated = true; + let schema = definition.validate().unwrap(); + #[cfg(feature = "sqlite")] + type B = SqliteBackend; + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + type B = PostgresBackend; + let error = schema + .physical_schema::(RuntimeMigrationLimits::default()) + .unwrap_err(); + assert_eq!( + error.0[0].code, + RuntimeMigrationDiagnosticCode::UnsupportedGeneration + ); + let error = target::() + .with_static_entities(&[Note::metadata()]) + .unwrap_err(); + assert_eq!( + error.0[0].code, + RuntimeMigrationDiagnosticCode::TargetCollision + ); + let error = self::schema() + .physical_schema::(RuntimeMigrationLimits { + max_target_bytes: 1, + ..Default::default() + }) + .unwrap_err(); + assert_eq!( + error.0[0].code, + RuntimeMigrationDiagnosticCode::LimitExceeded + ); + assert!(ManagedTableSet::new(["__graphql_orm_migrations".into()]).is_err()); + assert!(ManagedTableSet::new(["owned_notes".into(), "owned_notes".into()]).is_err()); +} + +#[cfg(feature = "sqlite")] +async fn sqlite() -> Database { + Database::::connect_sqlite("sqlite::memory:") + .await + .unwrap() +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sqlite_read_only_plan_shared_apply_noop_and_public_rename() +-> Result<(), Box> { + let db = sqlite().await; + let target = target::(); + let ownership = ownership(); + let manager = db.schema(); + let plan = manager + .plan_owned_migration( + "v1", + "owned notes", + &target, + &ownership, + PlanOptions::strict(), + ) + .await?; + let static_plan = manager + .plan_migration_to_entities("static", "same", &[Note::metadata()]) + .await?; + assert_eq!(plan.statements(), static_plan.statements); + assert_eq!( + plan.steps().iter().map(|s| s.risk()).collect::>(), + static_plan.steps.iter().map(|s| s.risk).collect::>() + ); + let before: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'") + .fetch_one(db.pool()) + .await?; + assert_eq!( + before, 0, + "planning must not create application or history tables" + ); + manager + .apply_owned_migration( + &plan, + ApplyOptions { + additive_only: true, + ..Default::default() + }, + ) + .await?; + sqlx::query("INSERT INTO owned_notes(id,label) VALUES ('a','kept')") + .execute(db.pool()) + .await?; + let stored: (i64, Option) = + sqlx::query_as("SELECT score, optional FROM owned_notes WHERE id = 'a'") + .fetch_one(db.pool()) + .await?; + assert_eq!(stored, (7, None)); + let noop = manager + .plan_owned_migration("v1", "replan", &target, &ownership, PlanOptions::strict()) + .await?; + assert!(noop.steps().is_empty()); + assert!(noop.statements().is_empty()); + assert!( + manager + .apply_owned_migration(&noop, Default::default()) + .await? + .already_applied + ); + let static_noop = manager + .plan_migration_to_entities("static", "same", &[Note::metadata()]) + .await?; + assert!( + static_noop.steps.is_empty(), + "owned-to-static replan: {static_noop:?}" + ); + let mut renamed = schema().schema().clone(); + renamed.collections[0].api_type_name = "RenamedNote".into(); + renamed.collections[0].api_plural_name = "RenamedNotes".into(); + renamed.collections[0].fields[1].api_name = "renamedLabel".into(); + let renamed = renamed + .validate()? + .physical_schema::(Default::default())?; + let renamed_plan = manager + .plan_owned_migration( + "rename", + "public only", + &renamed, + &ownership, + PlanOptions::strict(), + ) + .await?; + assert!(renamed_plan.steps().is_empty()); + assert!(renamed_plan.statements().is_empty()); + let value: String = sqlx::query_scalar("SELECT label FROM owned_notes WHERE id='a'") + .fetch_one(db.pool()) + .await?; + assert_eq!(value, "kept"); + Ok(()) +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sqlite_guards_baseline_removed_ownership_and_rollback() +-> Result<(), Box> { + let db = sqlite().await; + let target = target::(); + let ownership = ownership(); + let manager = db.schema(); + let initial = manager + .plan_owned_migration("v1", "create", &target, &ownership, PlanOptions::strict()) + .await?; + let wrong = ManagedTableSet::new(["different_table".into()])?; + rejected( + manager + .plan_owned_migration("v1", "wrong", &target, &wrong, PlanOptions::strict()) + .await + .unwrap_err(), + RuntimeMigrationDiagnosticCode::OwnershipMismatch, + ); + manager + .apply_owned_migration(&initial, Default::default()) + .await?; + sqlx::query("CREATE TABLE external_data(id TEXT PRIMARY KEY NOT NULL)") + .execute(db.pool()) + .await?; + let noop = manager + .plan_owned_migration("noop", "scope", &target, &ownership, PlanOptions::strict()) + .await?; + assert!(noop.steps().is_empty()); + sqlx::query("ALTER TABLE external_data ADD COLUMN extra TEXT") + .execute(db.pool()) + .await?; + rejected( + manager + .apply_owned_migration(&noop, Default::default()) + .await + .unwrap_err(), + RuntimeMigrationDiagnosticCode::BaselineMismatch, + ); + let removed = manager + .plan_owned_migration( + "drop", + "removed but still owned", + &OwnedSchemaModel::default(), + &ownership, + PlanOptions::strict(), + ) + .await?; + assert_eq!(removed.steps().len(), 1); + assert_eq!(removed.steps()[0].risk(), MigrationRisk::Destructive); + assert!( + manager + .apply_owned_migration(&removed, Default::default()) + .await + .is_err() + ); + let ignored = manager + .plan_owned_migration( + "ignore", + "legacy option", + &OwnedSchemaModel::default(), + &ownership, + PlanOptions::managed_tables_only(), + ) + .await?; + assert!(ignored.steps().is_empty()); + sqlx::query("INSERT INTO owned_notes(id,label) VALUES ('a','kept')") + .execute(db.pool()) + .await?; + let mut definition = schema().schema().clone(); + definition.collections[0].fields.push(RuntimeField { + id: FieldId::new("required")?, + api_name: "required".into(), + physical_column: "required".into(), + value_kind: RuntimeValueKind::String, + nullable: false, + unique: false, + filterable: false, + sortable: false, + generated: false, + default: None, + }); + let upgrade = definition + .validate()? + .physical_schema::(Default::default())?; + let plan = manager + .plan_owned_migration( + "fail", + "cannot fill required column", + &upgrade, + &ownership, + PlanOptions::strict(), + ) + .await?; + assert!( + manager + .apply_owned_migration(&plan, Default::default()) + .await + .is_err() + ); + let row: String = sqlx::query_scalar("SELECT label FROM owned_notes WHERE id='a'") + .fetch_one(db.pool()) + .await?; + assert_eq!(row, "kept"); + let exists: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE name='external_data'") + .fetch_one(db.pool()) + .await?; + assert_eq!(exists, 1); + let drop = manager + .plan_owned_migration( + "drop", + "review", + &OwnedSchemaModel::default(), + &ownership, + PlanOptions::strict(), + ) + .await?; + manager + .apply_owned_migration( + &drop, + ApplyOptions { + allow_destructive: true, + ..Default::default() + }, + ) + .await?; + let exists: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE name='external_data'") + .fetch_one(db.pool()) + .await?; + assert_eq!(exists, 1); + Ok(()) +} + +#[cfg(feature = "postgres")] +#[tokio::test] +async fn postgres_owned_static_noop_baseline_and_host_rls_preservation() +-> Result<(), Box> { + let mut owned = owned_postgres::OwnedPostgres::start("runtime-migrations")?; + let db = Database::::connect_postgres(&owned.url).await?; + let target = target::(); + let ownership = ownership(); + let manager = db.schema(); + let initial = manager + .plan_owned_migration("v1", "create", &target, &ownership, PlanOptions::strict()) + .await?; + let tables: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=current_schema()", + ) + .fetch_one(db.pool()) + .await?; + assert_eq!(tables, 0); + let static_plan = manager + .plan_migration_to_entities("static", "same", &[Note::metadata()]) + .await?; + assert_eq!(initial.statements(), static_plan.statements); + manager + .apply_owned_migration(&initial, Default::default()) + .await?; + let no_op = manager + .plan_owned_migration("v1", "replan", &target, &ownership, PlanOptions::strict()) + .await?; + assert!(no_op.steps().is_empty(), "{no_op:?}"); + assert!( + manager + .apply_owned_migration(&no_op, Default::default()) + .await? + .already_applied + ); + assert!( + manager + .plan_migration_to_entities("static", "same", &[Note::metadata()]) + .await? + .steps + .is_empty() + ); + for sql in [ + "CREATE ROLE owned_reader NOLOGIN", + "GRANT SELECT ON owned_notes TO owned_reader", + "ALTER TABLE owned_notes ENABLE ROW LEVEL SECURITY", + "ALTER TABLE owned_notes FORCE ROW LEVEL SECURITY", + "CREATE POLICY host_policy ON owned_notes FOR SELECT TO owned_reader USING (id='allowed')", + "CREATE TABLE system_journal(id TEXT PRIMARY KEY NOT NULL, note_id TEXT REFERENCES owned_notes(id) ON DELETE RESTRICT)", + "ALTER TABLE system_journal ENABLE ROW LEVEL SECURITY", + "ALTER TABLE system_journal FORCE ROW LEVEL SECURITY", + "CREATE POLICY journal_policy ON system_journal USING (false)", + "CREATE FUNCTION host_helper() RETURNS integer LANGUAGE SQL AS 'SELECT 42'", + "INSERT INTO owned_notes(id,label) VALUES ('allowed','public'),('denied','secret')", + ] { + sqlx::query(sql).execute(db.pool()).await?; + } + let snapshot = |pool: sqlx::PgPool| async move { + sqlx::query_as::<_, (String, bool, bool, String, Option)>("SELECT c.relname,c.relrowsecurity,c.relforcerowsecurity,pg_get_userbyid(c.relowner)::text,c.relacl::text FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=current_schema() AND c.relkind='r' AND c.relname IN ('owned_notes','system_journal') ORDER BY c.relname").fetch_all(&pool).await + }; + let before = snapshot(db.pool().clone()).await?; + let policies_before: Vec<(String,String,String)> = sqlx::query_as("SELECT tablename,policyname,qual FROM pg_policies WHERE schemaname=current_schema() ORDER BY tablename,policyname").fetch_all(db.pool()).await?; + let mut definition = schema().schema().clone(); + let score = definition.collections[0].fields[2].id.clone(); + definition.collections[0].indexes.push(RuntimeIndex { + id: IndexId::new("score_index")?, + name: "idx_owned_score".into(), + fields: vec![score], + unique: false, + }); + let upgraded_schema = definition.validate()?; + let upgrade = upgraded_schema.physical_schema::(Default::default())?; + let plan = manager + .plan_owned_migration( + "v2", + "new index preserves host RLS", + &upgrade, + &ownership, + PlanOptions::strict(), + ) + .await?; + manager + .apply_owned_migration( + &plan, + ApplyOptions { + additive_only: true, + ..Default::default() + }, + ) + .await?; + assert_eq!(before, snapshot(db.pool().clone()).await?); + let policies_after: Vec<(String,String,String)> = sqlx::query_as("SELECT tablename,policyname,qual FROM pg_policies WHERE schemaname=current_schema() ORDER BY tablename,policyname").fetch_all(db.pool()).await?; + assert_eq!(policies_before, policies_after); + let helper: i32 = sqlx::query_scalar("SELECT host_helper()") + .fetch_one(db.pool()) + .await?; + assert_eq!(helper, 42); + let mut tx = db.pool().begin().await?; + sqlx::query("SET LOCAL ROLE owned_reader") + .execute(&mut *tx) + .await?; + let ids: Vec = sqlx::query_scalar("SELECT id FROM owned_notes ORDER BY id") + .fetch_all(&mut *tx) + .await?; + assert_eq!(ids, ["allowed"]); + tx.rollback().await?; + let certificate = manager + .runtime_mutation_environment(std::sync::Arc::new(upgraded_schema), &upgrade, &ownership) + .await?; + assert_eq!( + certificate.incoming_dependencies("owned_notes")[0].source_table, + "system_journal" + ); + let noop = manager + .plan_owned_migration( + "noop", + "baseline", + &upgrade, + &ownership, + PlanOptions::strict(), + ) + .await?; + sqlx::query("ALTER TABLE system_journal ADD COLUMN extra TEXT") + .execute(db.pool()) + .await?; + rejected( + manager + .apply_owned_migration(&noop, Default::default()) + .await + .unwrap_err(), + RuntimeMigrationDiagnosticCode::BaselineMismatch, + ); + db.pool().close().await; + owned.cleanup()?; + Ok(()) +} From e36a0a7588f34a2f9c19b3814ffa7724a121234d Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Wed, 30 Sep 2026 23:52:39 +0000 Subject: [PATCH 2/7] test(orm): verify owned migration compatibility and backend guards --- CHANGELOG.md | 20 + Cargo.lock | 4 +- Cargo.toml | 2 +- MIGRATION.md | 20 + crates/graphql-orm-macros/Cargo.toml | 2 +- crates/graphql-orm-macros/README.md | 7 +- crates/graphql-orm/Cargo.toml | 4 +- crates/graphql-orm/README.md | 5 +- .../examples/runtime_owned_migration.rs | 83 +++ .../graphql-orm/src/graphql/orm/migrations.rs | 15 +- .../src/graphql/orm/owned_schema.rs | 132 +++-- .../src/graphql/orm/runtime_migration.rs | 2 +- .../src/graphql/orm/runtime_schema.rs | 4 +- .../fixtures/backend-coexistence/Cargo.lock | 4 +- .../fixtures/federation-end-to-end/Cargo.lock | 4 +- .../migration-api-compatibility/Cargo.lock | 4 +- .../repository-aggregate-consumer/Cargo.lock | 4 +- .../tests/runtime_migration_memory.rs | 90 ++++ .../graphql-orm/tests/runtime_migrations.rs | 509 +++++++++++++++++- .../tests/runtime_migrations_unsupported.rs | 57 ++ .../runtime-platform-capabilities/README.md | 10 +- docs/reference/graphql-orm/README.md | 1 + .../graphql-orm/runtime-migrations.md | 176 ++++++ docs/reference/workspace-packages.md | 4 +- 24 files changed, 1056 insertions(+), 107 deletions(-) create mode 100644 crates/graphql-orm/examples/runtime_owned_migration.rs create mode 100644 crates/graphql-orm/tests/runtime_migration_memory.rs create mode 100644 crates/graphql-orm/tests/runtime_migrations_unsupported.rs create mode 100644 docs/reference/graphql-orm/runtime-migrations.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4289d17b..edbd42bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,26 @@ This file is the authoritative user-facing release chronology. The former [release-notes ledger](docs/archive/2026/graphql-orm-release-notes.md) is retained for historical context. +## 0.34.0 - 2026-10-01 + +Companion macros crate: `graphql-orm-macros` **0.34.0** (alignment only). + +- Add owned canonical physical targets from `ValidatedRuntimeSchema`, checked + static/system composition, explicit `ManagedTableSet`, read-only owned planning + and validation, and separate guarded `apply_owned_migration` on SQLite/PostgreSQL. +- Keep the public legacy `MigrationStep` enum and struct literals unchanged. + Static and owned storage share validation, hashing, diffing, risk classification, + rendering and transactional history application; runtime conversion and owned + catalog introspection do not leak index storage. +- Bind immutable plans to complete live baselines and ownership, retain destructive + and additive guards, preserve host PostgreSQL RLS, and reject unsupported live + preservation cases. Add a verified incoming-dependency environment for later + runtime mutations; record mutation and dynamic GraphQL are still later slices. +- Respect SQLite composite primary-key ordinal order during introspection. + +No automatic application, host catalog, transport, durable delivery or release +publication is introduced. See the owned runtime migration reference and example. + ## 0.33.2 - 2026-10-01 Companion macros crate: `graphql-orm-macros` **0.33.2**. diff --git a/Cargo.lock b/Cargo.lock index bea84f0f..b365058a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3146,7 +3146,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.2" +version = "0.34.0" dependencies = [ "agql-auth", "async-graphql", @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.2" +version = "0.34.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 933540c1..07fa4d42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ cynic-parser = { version = "=0.11.2", features = ["pretty"] } futures = "0.3" getrandom = "0.3" graphql-composition = "=0.12.2" -graphql-orm = { path = "crates/graphql-orm", version = "0.33.2", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.34.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.15.0" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.7.2", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.0" } diff --git a/MIGRATION.md b/MIGRATION.md index e87118c8..953d6f95 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,6 +13,26 @@ supersedes: [] `graphql-orm` is distributed from GitHub only. Use a reviewed full 40-character commit in `rev`; neither the runtime nor macros crate is published to crates.io. +## 0.33.x to 0.34.0: owned runtime migration targets + +Adopt runtime and macros 0.34.0 together. Existing static enums, variant imports, +constructors, exhaustive matches, struct literals and function pointers remain +source-compatible; no call-site or stored-data migration is required. + +Runtime hosts may opt into `ValidatedRuntimeSchema::physical_schema`, compose +static metadata, explicitly declare owned physical tables, preview a plan, then +apply its immutable artifact with ordinary `ApplyOptions`. Retain intentionally +removed tables in the ownership set to review drops. Baseline validation is always +required for owned plans; `require_clean_schema = false` does not disable their +immutable baseline binding. Replan after drift and after successful application. +Owned application does not reconcile PostgreSQL RLS. Unsupported backends fail +before connection acquisition; unsupported live preservation cases return structured +rejections. See [owned runtime migrations](docs/reference/graphql-orm/runtime-migrations.md). + +The physical dependency environment is not a record-write API or an authorization +certificate. Hosts must fence external DDL and validate their complete public/policy +revision before future mutations; ORM fingerprints exclude host policy revisions. + ## 0.33.1 to 0.33.2: repository aggregate enum compatibility Pin runtime and macros 0.33.2 to the same reviewed full workspace revision. diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 9a82de43..9066c949 100644 --- a/crates/graphql-orm-macros/Cargo.toml +++ b/crates/graphql-orm-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-macros" -version = "0.33.2" +version = "0.34.0" edition = "2024" authors = ["Toby Martin"] description = "Procedural macros for async-graphql and ORM-backed entities, relations, and CRUD operations." diff --git a/crates/graphql-orm-macros/README.md b/crates/graphql-orm-macros/README.md index e9bf67a2..0974145c 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -16,13 +16,13 @@ macro/runtime versions aligned: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.33.2", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.34.0", default-features = false, features = ["sqlite"] } ``` Direct use is supported for tooling that needs the macro package: ```toml -graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.33.2", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.34.0", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the @@ -165,3 +165,6 @@ accidentally retained async-graphql `Enum` derive. Ordinary `GraphQLEntity` aggregate enums keep their GraphQL derive and naming attributes. See the [external runtime consumer](../graphql-orm/tests/fixtures/repository-aggregate-consumer/src/lib.rs) for compiled plain Rust aggregate calls without a direct async-graphql dependency. + +Version 0.34.0 aligns with the runtime migration API addition. Macro syntax and +ordinary static GraphQL SDL remain unchanged. diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index 64440a39..05c753bd 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.33.2" +version = "0.34.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -68,7 +68,7 @@ futures = "0.3" geo = { version = "0.33", optional = true, default-features = false } geo-types = { version = "0.7", optional = true } geojson = { version = "1", optional = true, default-features = true } -graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.33.2", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.34.0", default-features = false } graphql-orm-operation-catalog = { workspace = true } rust_decimal = { workspace = true } serde = { version = "1", features = ["derive"] } diff --git a/crates/graphql-orm/README.md b/crates/graphql-orm/README.md index 5f27ebb6..5b2fd694 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -29,7 +29,7 @@ backend: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.33.2", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.34.0", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the @@ -187,3 +187,6 @@ compiles scalar and composite keys on SQLite/PostgreSQL/MSSQL; its [owned-backend tests](tests/fixtures/repository-aggregate-consumer/tests/owned_backend.rs) execute grouped aggregates and denial before SQL on disposable SQLite/PostgreSQL. Ordinary `GraphQLEntity` aggregate SDL remains unchanged. + +Owned runtime targets, explicit table ownership, read-only plans and guarded apply +are documented in [runtime migrations](../../docs/reference/graphql-orm/runtime-migrations.md). diff --git a/crates/graphql-orm/examples/runtime_owned_migration.rs b/crates/graphql-orm/examples/runtime_owned_migration.rs new file mode 100644 index 00000000..bec4c7cf --- /dev/null +++ b/crates/graphql-orm/examples/runtime_owned_migration.rs @@ -0,0 +1,83 @@ +//! Run with `cargo run -p graphql-orm --example runtime_owned_migration -- --apply`. +//! The example uses a disposable in-memory SQLite database; the default is preview-only. +#[cfg(feature = "sqlite")] +#[tokio::main] +async fn main() -> Result<(), Box> { + use graphql_orm::db::Database; + use graphql_orm::graphql::orm::*; + let definition: RuntimeSchema = serde_json::from_value(serde_json::json!({ + "format_version": 1, + "collections": [{ + "id": "notes", "api_type_name": "Note", "api_plural_name": "Notes", + "physical_table": "notes", "primary_key": ["note_id"], + "append_only": false, "retention_purge": false, + "fields": [{ + "id": "note_id", "api_name": "id", "physical_column": "id", + "value_kind": "uuid", "nullable": false, "unique": false, + "filterable": false, "sortable": true, "generated": true, "default": null + }, { + "id": "note_label", "api_name": "label", "physical_column": "label", + "value_kind": "string", "nullable": false, "unique": false, + "filterable": true, "sortable": false, "generated": false, "default": null + }], + "relations": [], "indexes": [], "composite_unique": [], + "default_order": [{"field": "note_id", "direction": "asc"}] + }] + }))?; + let schema = std::sync::Arc::new(definition.validate()?); + let target = schema + .physical_schema::(RuntimeMigrationLimits::default())? + .with_static_entities(&[])?; // Add host system metadata here, without replacing host RLS. + let ownership = ManagedTableSet::new(["notes".to_owned()])?; + let db = Database::::connect_sqlite("sqlite::memory:").await?; + let manager = db.schema(); + let plan = manager + .plan_owned_migration( + "notes-v1", + "initial notes", + &target, + &ownership, + PlanOptions::strict(), + ) + .await?; + println!( + "source={} target={} plan={}", + plan.source_schema_hash(), + plan.target_schema_hash(), + plan.plan_hash() + ); + for step in plan.steps() { + println!("{:?}: {}", step.risk(), step.reason()); + } + if std::env::args().any(|arg| arg == "--apply") { + let report = manager + .apply_owned_migration( + &plan, + ApplyOptions { + additive_only: true, + ..Default::default() + }, + ) + .await?; + println!("committed {} statements", report.statements_applied); + let no_op = manager + .plan_owned_migration( + "notes-v1", + "replan", + &target, + &ownership, + PlanOptions::strict(), + ) + .await?; + assert!(no_op.steps().is_empty()); + let environment = manager + .runtime_mutation_environment(schema, &target, &ownership) + .await?; + println!("verified baseline={}", environment.physical_baseline_hash()); + } + Ok(()) +} +#[cfg(not(feature = "sqlite"))] +fn main() { + println!("This disposable example requires --features sqlite."); +} diff --git a/crates/graphql-orm/src/graphql/orm/migrations.rs b/crates/graphql-orm/src/graphql/orm/migrations.rs index fa61d69e..7b53788e 100644 --- a/crates/graphql-orm/src/graphql/orm/migrations.rs +++ b/crates/graphql-orm/src/graphql/orm/migrations.rs @@ -402,7 +402,7 @@ fn render_column_definition( } fn render_create_table_statement(backend: DatabaseBackend, table: &TableRef<'_>) -> String { - render_create_table_statement_for_name(backend, table, &table.table_name) + render_create_table_statement_for_name(backend, table, table.table_name) } fn render_create_table_statement_for_name( @@ -914,8 +914,7 @@ pub(super) fn diff_schema_views( retention_purge: target_table.retention_purge, }); } - if !check_constraints_equivalent(&table.check_constraints, &target_table.check_constraints) - { + if !check_constraints_equivalent(table.check_constraints, target_table.check_constraints) { steps.push(OwnedMigrationStep::SetCheckConstraints { table_name: table_name.clone(), before: table.check_constraints.clone(), @@ -951,7 +950,7 @@ fn foreign_key_constraint_name(table_name: &str, foreign_key: &ForeignKeyModel) pub(super) fn migration_step_table_name<'a>(step: &StepRef<'a>) -> Option<&'a str> { match step { StepRef::EnableExtension { .. } => None, - StepRef::CreateTable(table) => Some(&table.table_name), + StepRef::CreateTable(table) => Some(table.table_name), StepRef::DropTable { table_name } => Some(table_name), StepRef::AddColumn { table_name, .. } => Some(table_name), StepRef::DropColumn { table_name, .. } => Some(table_name), @@ -1759,12 +1758,12 @@ fn render_sqlite_table_rebuild_statements( temp_table_name, target_table.table_name )); statements.extend(target_table.indexes.iter().map(|index| { - render_create_index_statement(DatabaseBackend::Sqlite, &target_table.table_name, index) + render_create_index_statement(DatabaseBackend::Sqlite, target_table.table_name, index) })); if target_table.append_only { statements.extend(render_append_only_statements( DatabaseBackend::Sqlite, - &target_table.table_name, + target_table.table_name, true, target_table.retention_purge, )); @@ -1796,7 +1795,7 @@ pub(super) fn render_migration_step_view( table .indexes .iter() - .map(|index| render_create_index_statement(backend, &table.table_name, index)), + .map(|index| render_create_index_statement(backend, table.table_name, index)), ); statements.extend( table @@ -1807,7 +1806,7 @@ pub(super) fn render_migration_step_view( if table.append_only { statements.extend(render_append_only_statements( backend, - &table.table_name, + table.table_name, true, table.retention_purge, )); diff --git a/crates/graphql-orm/src/graphql/orm/owned_schema.rs b/crates/graphql-orm/src/graphql/orm/owned_schema.rs index 77d47c3a..61e6c16e 100644 --- a/crates/graphql-orm/src/graphql/orm/owned_schema.rs +++ b/crates/graphql-orm/src/graphql/orm/owned_schema.rs @@ -608,19 +608,19 @@ pub(super) fn stable_schema_view_hash(schema: &SchemaRef<'_>) -> String { } let mut tables = schema.tables.iter().collect::>(); - tables.sort_by(|left, right| left.table_name.cmp(&right.table_name)); + tables.sort_by(|left, right| left.table_name.cmp(right.table_name)); for table in tables { canonical.push_str("table:"); - canonical.push_str(&table.entity_name); + canonical.push_str(table.entity_name); canonical.push('|'); - canonical.push_str(&table.table_name); + canonical.push_str(table.table_name); canonical.push('|'); - canonical.push_str(&table.primary_key); + canonical.push_str(table.primary_key); canonical.push('|'); canonical.push_str(&table.primary_keys().join(",")); canonical.push('|'); - canonical.push_str(&table.default_sort); + canonical.push_str(table.default_sort); canonical.push('|'); canonical.push_str(if table.append_only { "append_only" @@ -1241,82 +1241,78 @@ pub(super) enum StepRef<'a> { impl<'a> From<&'a MigrationStep> for StepRef<'a> { fn from(step: &'a MigrationStep) -> Self { match step { - MigrationStep::EnableExtension { name } => Self::EnableExtension { name: name }, + MigrationStep::EnableExtension { name } => Self::EnableExtension { name }, MigrationStep::CreateTable(value) => Self::CreateTable(TableRef::from(value)), - MigrationStep::DropTable { table_name } => Self::DropTable { - table_name: table_name, - }, - MigrationStep::AddColumn { table_name, column } => Self::AddColumn { - table_name: table_name, - column: column, - }, + MigrationStep::DropTable { table_name } => Self::DropTable { table_name }, + MigrationStep::AddColumn { table_name, column } => { + Self::AddColumn { table_name, column } + } MigrationStep::DropColumn { table_name, column_name, } => Self::DropColumn { - table_name: table_name, - column_name: column_name, + table_name, + column_name, }, MigrationStep::AlterColumn { table_name, before, after, } => Self::AlterColumn { - table_name: table_name, - before: before, - after: after, + table_name, + before, + after, }, MigrationStep::CreateIndex { table_name, index } => Self::CreateIndex { - table_name: table_name, + table_name, index: IndexRef::from(index), }, MigrationStep::DropIndex { table_name, index_name, } => Self::DropIndex { - table_name: table_name, - index_name: index_name, - }, - MigrationStep::CreateSearchIndex { table_name, index } => Self::CreateSearchIndex { - table_name: table_name, - index: index, + table_name, + index_name, }, + MigrationStep::CreateSearchIndex { table_name, index } => { + Self::CreateSearchIndex { table_name, index } + } MigrationStep::DropSearchIndex { table_name, index_name, } => Self::DropSearchIndex { - table_name: table_name, - index_name: index_name, + table_name, + index_name, }, MigrationStep::AlterSearchIndex { table_name, before, after, } => Self::AlterSearchIndex { - table_name: table_name, - before: before, - after: after, + table_name, + before, + after, }, MigrationStep::AddForeignKey { table_name, foreign_key, } => Self::AddForeignKey { - table_name: table_name, - foreign_key: foreign_key, + table_name, + foreign_key, }, MigrationStep::DropForeignKey { table_name, foreign_key, } => Self::DropForeignKey { - table_name: table_name, - foreign_key: foreign_key, + table_name, + foreign_key, }, MigrationStep::SetAppendOnly { table_name, enabled, retention_purge, } => Self::SetAppendOnly { - table_name: table_name, + table_name, enabled: *enabled, retention_purge: *retention_purge, }, @@ -1325,9 +1321,9 @@ impl<'a> From<&'a MigrationStep> for StepRef<'a> { before, after, } => Self::SetCheckConstraints { - table_name: table_name, - before: before, - after: after, + table_name, + before, + after, }, } } @@ -1335,84 +1331,78 @@ impl<'a> From<&'a MigrationStep> for StepRef<'a> { impl<'a> From<&'a OwnedMigrationStep> for StepRef<'a> { fn from(step: &'a OwnedMigrationStep) -> Self { match step { - OwnedMigrationStep::EnableExtension { name } => Self::EnableExtension { name: name }, + OwnedMigrationStep::EnableExtension { name } => Self::EnableExtension { name }, OwnedMigrationStep::CreateTable(value) => Self::CreateTable(value.borrowed()), - OwnedMigrationStep::DropTable { table_name } => Self::DropTable { - table_name: table_name, - }, - OwnedMigrationStep::AddColumn { table_name, column } => Self::AddColumn { - table_name: table_name, - column: column, - }, + OwnedMigrationStep::DropTable { table_name } => Self::DropTable { table_name }, + OwnedMigrationStep::AddColumn { table_name, column } => { + Self::AddColumn { table_name, column } + } OwnedMigrationStep::DropColumn { table_name, column_name, } => Self::DropColumn { - table_name: table_name, - column_name: column_name, + table_name, + column_name, }, OwnedMigrationStep::AlterColumn { table_name, before, after, } => Self::AlterColumn { - table_name: table_name, - before: before, - after: after, + table_name, + before, + after, }, OwnedMigrationStep::CreateIndex { table_name, index } => Self::CreateIndex { - table_name: table_name, + table_name, index: index.borrowed(), }, OwnedMigrationStep::DropIndex { table_name, index_name, } => Self::DropIndex { - table_name: table_name, - index_name: index_name, + table_name, + index_name, }, OwnedMigrationStep::CreateSearchIndex { table_name, index } => { - Self::CreateSearchIndex { - table_name: table_name, - index: index, - } + Self::CreateSearchIndex { table_name, index } } OwnedMigrationStep::DropSearchIndex { table_name, index_name, } => Self::DropSearchIndex { - table_name: table_name, - index_name: index_name, + table_name, + index_name, }, OwnedMigrationStep::AlterSearchIndex { table_name, before, after, } => Self::AlterSearchIndex { - table_name: table_name, - before: before, - after: after, + table_name, + before, + after, }, OwnedMigrationStep::AddForeignKey { table_name, foreign_key, } => Self::AddForeignKey { - table_name: table_name, - foreign_key: foreign_key, + table_name, + foreign_key, }, OwnedMigrationStep::DropForeignKey { table_name, foreign_key, } => Self::DropForeignKey { - table_name: table_name, - foreign_key: foreign_key, + table_name, + foreign_key, }, OwnedMigrationStep::SetAppendOnly { table_name, enabled, retention_purge, } => Self::SetAppendOnly { - table_name: table_name, + table_name, enabled: *enabled, retention_purge: *retention_purge, }, @@ -1421,9 +1411,9 @@ impl<'a> From<&'a OwnedMigrationStep> for StepRef<'a> { before, after, } => Self::SetCheckConstraints { - table_name: table_name, - before: before, - after: after, + table_name, + before, + after, }, } } diff --git a/crates/graphql-orm/src/graphql/orm/runtime_migration.rs b/crates/graphql-orm/src/graphql/orm/runtime_migration.rs index aeba77f4..cac431ab 100644 --- a/crates/graphql-orm/src/graphql/orm/runtime_migration.rs +++ b/crates/graphql-orm/src/graphql/orm/runtime_migration.rs @@ -864,7 +864,7 @@ impl RuntimeMigrationBackend for super::SqliteBackend { plan: &OwnedPlannedMigration, options: &ApplyOptions, ) -> Result { - Ok(super::execution::apply_owned_sqlite_migration(pool, plan, options).await?) + super::execution::apply_owned_sqlite_migration(pool, plan, options).await } } #[cfg(feature = "postgres")] diff --git a/crates/graphql-orm/src/graphql/orm/runtime_schema.rs b/crates/graphql-orm/src/graphql/orm/runtime_schema.rs index a7e10e9d..f5cdb73e 100644 --- a/crates/graphql-orm/src/graphql/orm/runtime_schema.rs +++ b/crates/graphql-orm/src/graphql/orm/runtime_schema.rs @@ -7,8 +7,8 @@ //! //! The IR deliberately covers structural schema semantics only in this slice: collections, //! fields, primary keys, relations with ordered key pairs, secondary/unique indexes, composite -//! unique groups, defaults, and deterministic ordering. Runtime query execution, migration -//! planning from the IR, and dynamic GraphQL registration are later slices. Spatial columns, +//! unique groups, defaults, and deterministic ordering. Schema-bound query execution and owned migration planning reuse this IR; +//! opt-in dynamic GraphQL registration remains a later slice. Spatial columns, //! full-text search, partial/GiST indexes, check constraints, backup ordering, policy hook //! names, and relation change propagation are deliberately not represented yet; conversion //! reports them as [`RuntimeSchemaDiagnosticCode::UnsupportedCapability`] instead of silently diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 238c966e..00ea4771 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1350,7 +1350,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.2" +version = "0.34.0" dependencies = [ "agql-auth", "async-graphql", @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.2" +version = "0.34.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock index ebeba778..e5eb674f 100644 --- a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock @@ -2508,7 +2508,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.2" +version = "0.34.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2531,7 +2531,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.2" +version = "0.34.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock index a0f76a06..760c3ccd 100644 --- a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.2" +version = "0.34.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.2" +version = "0.34.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock index 482cfdcd..d70fa6f6 100644 --- a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.2" +version = "0.34.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.2" +version = "0.34.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/runtime_migration_memory.rs b/crates/graphql-orm/tests/runtime_migration_memory.rs new file mode 100644 index 00000000..c03ad72a --- /dev/null +++ b/crates/graphql-orm/tests/runtime_migration_memory.rs @@ -0,0 +1,90 @@ +#![cfg(any(feature = "sqlite", feature = "postgres"))] + +use graphql_orm::graphql::orm::*; +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; + +thread_local! { + static TRACK: Cell = const { Cell::new(false) }; + static BYTES: Cell = const { Cell::new(0) }; +} +struct CountAlloc; +fn count(delta: isize) { + let _ = TRACK.try_with(|track| { + if track.get() { + let _ = BYTES.try_with(|bytes| bytes.set(bytes.get() + delta)); + } + }); +} +// Safety: allocation and pointer/layout handling delegate unchanged to System. +// Thread-local counters neither allocate nor retain the allocated pointers. +unsafe impl GlobalAlloc for CountAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let pointer = unsafe { System.alloc(layout) }; + if !pointer.is_null() { + count(layout.size() as isize); + } + pointer + } + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + count(-(layout.size() as isize)); + unsafe { + System.dealloc(pointer, layout); + } + } + unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 { + let new = unsafe { System.realloc(pointer, layout, size) }; + if !new.is_null() { + count(size as isize - layout.size() as isize); + } + new + } +} +#[global_allocator] +static ALLOCATOR: CountAlloc = CountAlloc; +struct Tracking; +impl Drop for Tracking { + fn drop(&mut self) { + TRACK.with(|track| track.set(false)); + } +} + +#[test] +fn repeated_owned_conversion_and_hashing_release_every_allocation() { + let definition: RuntimeSchema = serde_json::from_value(serde_json::json!({ + "format_version":1,"collections":[{ + "id":"notes","api_type_name":"Note","api_plural_name":"Notes", + "physical_table":"notes","primary_key":["note_id"],"append_only":false,"retention_purge":false, + "fields":[{"id":"note_id","api_name":"id","physical_column":"id","value_kind":"uuid", + "nullable":false,"unique":false,"filterable":false,"sortable":true,"generated":true,"default":null}, + {"id":"note_label","api_name":"label","physical_column":"label","value_kind":"string", + "nullable":false,"unique":false,"filterable":true,"sortable":false,"generated":false,"default":null}], + "relations":[],"indexes":[],"composite_unique":[],"default_order":[{"field":"note_id","direction":"asc"}] + }] + })).unwrap(); + let schema = definition.validate().unwrap(); + #[cfg(feature = "sqlite")] + type Backend = SqliteBackend; + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + type Backend = PostgresBackend; + let expected = schema + .physical_schema::(Default::default()) + .unwrap() + .stable_hash(); + BYTES.with(|bytes| bytes.set(0)); + TRACK.with(|track| track.set(true)); + let tracking = Tracking; + for _ in 0..1000 { + let target = schema + .physical_schema::(Default::default()) + .unwrap(); + assert_eq!(target.stable_hash(), expected); + assert_eq!(target.tables()[0].indexes().len(), 1); + } + drop(tracking); + assert_eq!( + BYTES.with(Cell::get), + 0, + "owned conversion must not leak per-schema index/name storage" + ); +} diff --git a/crates/graphql-orm/tests/runtime_migrations.rs b/crates/graphql-orm/tests/runtime_migrations.rs index 83e05de0..fe9de52e 100644 --- a/crates/graphql-orm/tests/runtime_migrations.rs +++ b/crates/graphql-orm/tests/runtime_migrations.rs @@ -4,10 +4,15 @@ use graphql_orm::GraphQLSchemaEntity; use graphql_orm::db::Database; use graphql_orm::graphql::orm::*; -#[cfg(feature = "postgres")] +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] #[path = "support/owned_postgres.rs"] mod owned_postgres; +#[cfg_attr(feature = "sqlite", graphql_entity(backend = "sqlite"))] +#[cfg_attr( + all(feature = "postgres", not(feature = "sqlite")), + graphql_entity(backend = "postgres") +)] #[derive(GraphQLSchemaEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] #[graphql_entity(table = "owned_notes", plural = "Notes", default_sort = "id ASC")] struct Note { @@ -48,11 +53,17 @@ fn canonical_owned_storage_matches_static_target_and_legacy_hash() { let owned = OwnedSchemaModel::from(&static_target); assert_eq!(owned.stable_hash(), static_target.stable_hash()); #[cfg(feature = "sqlite")] + assert_eq!( + static_target.stable_hash(), + "e7b75c205412e474", + "legacy 0.33.3 physical hash format" + ); + #[cfg(feature = "sqlite")] assert_eq!( target::().stable_hash(), static_target.stable_hash() ); - #[cfg(feature = "postgres")] + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] assert_eq!( target::().stable_hash(), static_target.stable_hash() @@ -125,6 +136,10 @@ async fn sqlite_read_only_plan_shared_apply_noop_and_public_rename() .plan_migration_to_entities("static", "same", &[Note::metadata()]) .await?; assert_eq!(plan.statements(), static_plan.statements); + assert_eq!( + static_plan.plan_hash, "20e6f11d31144511", + "legacy 0.33.3 plan hash format" + ); assert_eq!( plan.steps().iter().map(|s| s.risk()).collect::>(), static_plan.steps.iter().map(|s| s.risk).collect::>() @@ -331,7 +346,7 @@ async fn sqlite_guards_baseline_removed_ownership_and_rollback() Ok(()) } -#[cfg(feature = "postgres")] +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] #[tokio::test] async fn postgres_owned_static_noop_baseline_and_host_rls_preservation() -> Result<(), Box> { @@ -467,3 +482,491 @@ async fn postgres_owned_static_noop_baseline_and_host_rls_preservation() owned.cleanup()?; Ok(()) } + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sqlite_reverse_origin_plan_limits_and_rebuild_preservation_guards() +-> Result<(), Box> { + let db = sqlite().await; + let manager = db.schema(); + let static_plan = manager + .plan_migration_to_entities("static", "static origin", &[Note::metadata()]) + .await?; + manager + .apply_migration(&static_plan, Default::default()) + .await?; + let owned = target::(); + let ownership = ownership(); + let noop = manager + .plan_owned_migration( + "owned", + "owned origin", + &owned, + &ownership, + PlanOptions::strict(), + ) + .await?; + assert!(noop.steps().is_empty()); + rejected( + manager + .apply_owned_migration( + &noop, + ApplyOptions { + expected_current_schema_hash: Some("wrong".into()), + ..Default::default() + }, + ) + .await + .unwrap_err(), + RuntimeMigrationDiagnosticCode::BaselineMismatch, + ); + let removed = manager + .plan_owned_migration( + "drop", + "guard", + &OwnedSchemaModel::default(), + &ownership, + PlanOptions::strict(), + ) + .await?; + assert!( + manager + .apply_owned_migration( + &removed, + ApplyOptions { + allow_destructive: true, + additive_only: true, + ..Default::default() + } + ) + .await + .is_err() + ); + let empty_db = sqlite().await; + let bounded = schema().physical_schema::(RuntimeMigrationLimits { + max_plan_steps: 0, + ..Default::default() + })?; + rejected( + empty_db + .schema() + .plan_owned_migration( + "bounded", + "limits retained", + &bounded, + &ownership, + PlanOptions::strict(), + ) + .await + .unwrap_err(), + RuntimeMigrationDiagnosticCode::LimitExceeded, + ); + let mut definition = schema().schema().clone(); + definition.collections[0].fields[1].nullable = true; // controlled SQLite rebuild + let nullable = definition + .validate()? + .physical_schema::(Default::default())?; + sqlx::query("CREATE TRIGGER host_trigger AFTER INSERT ON owned_notes BEGIN SELECT 1; END") + .execute(db.pool()) + .await?; + rejected( + manager + .plan_owned_migration( + "rebuild", + "preserve host trigger", + &nullable, + &ownership, + PlanOptions::strict(), + ) + .await + .unwrap_err(), + RuntimeMigrationDiagnosticCode::InvalidPhysicalContract, + ); + sqlx::query("DROP TRIGGER host_trigger") + .execute(db.pool()) + .await?; + sqlx::query("CREATE TABLE __graphql_orm_owned_notes_new(id TEXT)") + .execute(db.pool()) + .await?; + rejected( + manager + .plan_owned_migration( + "rebuild", + "temporary name collision", + &nullable, + &ownership, + PlanOptions::strict(), + ) + .await + .unwrap_err(), + RuntimeMigrationDiagnosticCode::OwnershipMismatch, + ); + let exists: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master WHERE name='__graphql_orm_owned_notes_new'", + ) + .fetch_one(db.pool()) + .await?; + assert_eq!(exists, 1, "owned planning must not run global cleanup"); + Ok(()) +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sqlite_composite_keys_all_runtime_types_and_timestamp_defaults() +-> Result<(), Box> { + let db = sqlite().await; + let mut definition = schema().schema().clone(); + let collection = &mut definition.collections[0]; + collection.primary_key = vec![ + collection.fields[1].id.clone(), + collection.fields[0].id.clone(), + ]; + collection.indexes.clear(); + collection.fields[1].filterable = false; + for (id, kind, nullable, generated, default) in [ + ( + "boolean", + RuntimeValueKind::Boolean, + false, + false, + Some(RuntimeDefault::Literal("true".into())), + ), + ("float", RuntimeValueKind::Float, true, false, None), + ("uuid", RuntimeValueKind::Uuid, false, true, None), + ("json", RuntimeValueKind::Json, true, false, None), + ("bytes", RuntimeValueKind::Bytes, true, false, None), + ( + "datetime", + RuntimeValueKind::DateTime, + false, + true, + Some(RuntimeDefault::CurrentTimestamp), + ), + ( + "epoch", + RuntimeValueKind::Integer, + false, + true, + Some(RuntimeDefault::CurrentTimestamp), + ), + ] { + collection.fields.push(RuntimeField { + id: FieldId::new(id)?, + api_name: id.into(), + physical_column: id.into(), + value_kind: kind, + nullable, + unique: false, + filterable: false, + sortable: false, + generated, + default, + }); + } + let target = definition + .validate()? + .physical_schema::(Default::default())?; + let ownership = ownership(); + assert_eq!(target.tables()[0].primary_keys(), ["label", "id"]); + let manager = db.schema(); + let plan = manager + .plan_owned_migration( + "types", + "types and ordered composite key", + &target, + &ownership, + PlanOptions::strict(), + ) + .await?; + manager + .apply_owned_migration(&plan, Default::default()) + .await?; + sqlx::query("INSERT INTO owned_notes(id,label,uuid) VALUES ('id','label',?)") + .bind(graphql_orm::uuid::Uuid::new_v4().to_string()) + .execute(db.pool()) + .await?; + let row: (String, i64, i64) = + sqlx::query_as("SELECT datetime, epoch, boolean FROM owned_notes") + .fetch_one(db.pool()) + .await?; + assert_eq!(row.0.len(), 27); + assert!(row.0.ends_with('Z')); + assert_eq!(RuntimeDateTime::parse(&row.0)?.as_str(), row.0); + assert!(row.1 > 0); + assert_eq!(row.2, 1); + let noop = manager + .plan_owned_migration( + "noop", + "reverse key order retained", + &target, + &ownership, + PlanOptions::strict(), + ) + .await?; + assert!(noop.steps().is_empty(), "{noop:?}"); + Ok(()) +} + +#[cfg(feature = "sqlite")] +#[graphql_entity(backend = "sqlite")] +#[derive(GraphQLSchemaEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity( + table = "host_journal", + plural = "HostJournal", + default_sort = "id ASC", + append_only = true, + read_policy = "journal_private" +)] +struct HostJournal { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + note_id: String, +} +#[cfg(feature = "sqlite")] +fn build_system_metadata() -> EntityMetadata { + let mut metadata = HostJournal::metadata().clone(); + // Static system metadata can bind directly to stable runtime physical names. + metadata.relations = vec![RelationMetadata { + field_name: "note", + target_type: "owned_notes", + source_column: "note_id", + target_column: "id", + source_columns: &["note_id"], + target_columns: &["id"], + is_multiple: false, + emit_foreign_key: true, + on_delete: DeletePolicy::Restrict, + propagate_change: RelationChangePropagation::None, + search_fields: None, + }] + .into_boxed_slice(); + metadata +} +#[cfg(feature = "sqlite")] +fn system_metadata() -> &'static EntityMetadata { + static METADATA: std::sync::LazyLock = + std::sync::LazyLock::new(build_system_metadata); + &METADATA +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sqlite_composed_static_system_target_dependencies_and_policy_metadata() +-> Result<(), Box> { + let db = sqlite().await; + let metadata = system_metadata(); + let composed = target::().with_static_entities(&[metadata])?; + let ownership = ManagedTableSet::new(["owned_notes".into(), "host_journal".into()])?; + let manager = db.schema(); + let plan = manager + .plan_owned_migration( + "composed", + "runtime plus journal", + &composed, + &ownership, + PlanOptions::strict(), + ) + .await?; + let static_plan = manager + .plan_migration_to_entities("static", "equivalent", &[Note::metadata(), metadata]) + .await?; + assert_eq!(plan.statements(), static_plan.statements); + manager + .apply_owned_migration(&plan, Default::default()) + .await?; + let environment = manager + .runtime_mutation_environment(std::sync::Arc::new(schema()), &composed, &ownership) + .await?; + assert_eq!( + environment.incoming_dependencies("owned_notes")[0].source_table, + "host_journal" + ); + assert_eq!( + environment.incoming_dependencies("owned_notes")[0].on_delete, + DeletePolicy::Restrict + ); + assert_eq!(metadata.read_policy, Some("journal_private")); + assert!( + manager + .plan_owned_migration( + "noop", + "replan", + &composed, + &ownership, + PlanOptions::strict() + ) + .await? + .steps() + .is_empty() + ); + assert!( + manager + .plan_migration_to_entities("static", "replan", &[Note::metadata(), metadata]) + .await? + .steps + .is_empty() + ); + sqlx::query("INSERT INTO owned_notes(id,label) VALUES ('a','kept')") + .execute(db.pool()) + .await?; + sqlx::query("INSERT INTO host_journal(id,note_id) VALUES ('j','a')") + .execute(db.pool()) + .await?; + assert!( + sqlx::query("UPDATE host_journal SET note_id='a'") + .execute(db.pool()) + .await + .is_err() + ); + assert!( + sqlx::query("DELETE FROM owned_notes WHERE id='a'") + .execute(db.pool()) + .await + .is_err() + ); + // Reserved host infrastructure dependencies are also included, not filtered away. + sqlx::query("CREATE TABLE __graphql_orm_custom_link(id TEXT PRIMARY KEY NOT NULL,note_id TEXT REFERENCES owned_notes(id) ON DELETE CASCADE)").execute(db.pool()).await?; + let environment = manager + .runtime_mutation_environment(std::sync::Arc::new(schema()), &composed, &ownership) + .await?; + assert!( + environment + .incoming_dependencies("owned_notes") + .iter() + .any(|dep| dep.source_table == "__graphql_orm_custom_link" + && dep.on_delete == DeletePolicy::Cascade) + ); + Ok(()) +} + +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] +#[tokio::test] +async fn postgres_competing_baselines_destructive_guards_and_atomic_failure() +-> Result<(), Box> { + let mut owned = owned_postgres::OwnedPostgres::start("owned-migration-guards")?; + let db = Database::::connect_postgres(&owned.url).await?; + let manager = db.schema(); + let target = target::(); + let ownership = ownership(); + let first = manager + .plan_owned_migration( + "first", + "same baseline", + &target, + &ownership, + PlanOptions::strict(), + ) + .await?; + let competing = manager + .plan_owned_migration( + "second", + "same baseline", + &target, + &ownership, + PlanOptions::strict(), + ) + .await?; + manager + .apply_owned_migration(&first, Default::default()) + .await?; + rejected( + manager + .apply_owned_migration(&competing, Default::default()) + .await + .unwrap_err(), + RuntimeMigrationDiagnosticCode::BaselineMismatch, + ); + let drop = manager + .plan_owned_migration( + "drop", + "explicit removal", + &OwnedSchemaModel::default(), + &ownership, + PlanOptions::strict(), + ) + .await?; + assert!( + manager + .apply_owned_migration(&drop, Default::default()) + .await + .is_err() + ); + assert!( + manager + .apply_owned_migration( + &drop, + ApplyOptions { + allow_destructive: true, + additive_only: true, + ..Default::default() + } + ) + .await + .is_err() + ); + sqlx::query("INSERT INTO owned_notes(id,label) VALUES ('a','kept')") + .execute(db.pool()) + .await?; + let mut definition = schema().schema().clone(); + definition.collections[0].fields.push(RuntimeField { + id: FieldId::new("required")?, + api_name: "required".into(), + physical_column: "required".into(), + value_kind: RuntimeValueKind::String, + nullable: false, + unique: false, + filterable: false, + sortable: false, + generated: false, + default: None, + }); + let target = definition + .validate()? + .physical_schema::(Default::default())?; + let fail = manager + .plan_owned_migration( + "fail", + "required on existing row", + &target, + &ownership, + PlanOptions::strict(), + ) + .await?; + assert!( + manager + .apply_owned_migration(&fail, Default::default()) + .await + .is_err() + ); + let row: String = sqlx::query_scalar("SELECT label FROM owned_notes WHERE id='a'") + .fetch_one(db.pool()) + .await?; + assert_eq!(row, "kept"); + let history: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM __graphql_orm_migrations WHERE version='fail'") + .fetch_one(db.pool()) + .await?; + assert_eq!(history, 0); + let drop = manager + .plan_owned_migration( + "drop", + "guarded removal", + &OwnedSchemaModel::default(), + &ownership, + PlanOptions::strict(), + ) + .await?; + manager + .apply_owned_migration( + &drop, + ApplyOptions { + allow_destructive: true, + ..Default::default() + }, + ) + .await?; + db.pool().close().await; + owned.cleanup()?; + Ok(()) +} diff --git a/crates/graphql-orm/tests/runtime_migrations_unsupported.rs b/crates/graphql-orm/tests/runtime_migrations_unsupported.rs new file mode 100644 index 00000000..c13f7341 --- /dev/null +++ b/crates/graphql-orm/tests/runtime_migrations_unsupported.rs @@ -0,0 +1,57 @@ +#![cfg(feature = "mssql")] +use graphql_orm::db::{Database, mssql::MssqlPool}; +use graphql_orm::graphql::orm::*; + +fn rejected(error: RuntimeMigrationError) { + assert!( + matches!(error,RuntimeMigrationError::Diagnostics(ref diagnostics) + if diagnostics.0[0].code==RuntimeMigrationDiagnosticCode::UnsupportedBackend) + ); +} +#[tokio::test] +async fn mssql_owned_migrations_reject_before_pool_acquisition() { + // Construction is lazy; no database or network endpoint belongs to this test. + // A migration capability must reject without asking the pool for a connection. + let pool = MssqlPool::new(graphql_orm::tiberius::Config::new()); + let db = Database::::new(pool); + let target = OwnedSchemaModel::default(); + let ownership = ManagedTableSet::new(std::iter::empty()).unwrap(); + rejected( + db.schema() + .validate_owned_schema(&target, &ownership) + .await + .unwrap_err(), + ); + rejected( + db.schema() + .plan_owned_migration( + "unsupported", + "no connection", + &target, + &ownership, + PlanOptions::strict(), + ) + .await + .unwrap_err(), + ); + let schema = RuntimeSchema { + format_version: 1, + collections: vec![], + } + .validate() + .unwrap(); + let error = schema + .physical_schema::(Default::default()) + .unwrap_err(); + assert_eq!( + error.0[0].code, + RuntimeMigrationDiagnosticCode::UnsupportedBackend + ); + let error = schema + .physical_schema::(Default::default()) + .unwrap_err(); + assert_eq!( + error.0[0].code, + RuntimeMigrationDiagnosticCode::UnsupportedBackend + ); +} diff --git a/docs/plans/active/runtime-platform-capabilities/README.md b/docs/plans/active/runtime-platform-capabilities/README.md index 23cc59ec..f2567de1 100644 --- a/docs/plans/active/runtime-platform-capabilities/README.md +++ b/docs/plans/active/runtime-platform-capabilities/README.md @@ -14,9 +14,13 @@ supersedes: [] Provide reusable runtime migration, transactional single-record mutation, and opt-in dynamic GraphQL mechanisms for applications with catalog-loaded schemas. -This is a **proposed contract**, not an implemented API. All Rust signatures, -examples, SDL, feature additions, and future versions below are proposals. -Review this checkpoint before implementing unsettled public interfaces. +The revised contract at `c53a5de966ca5089a5e4aa2d727e144bae83e6bd` is approved for +implementation and merged by PR #86 at `9ed46db5d1c5c6214f83ef9084352030e14905ad`. +PR A implements the owned migration slice; its current public mechanics are in +[owned runtime migrations](../../../reference/graphql-orm/runtime-migrations.md). +B–D signatures, examples, SDL and feature additions below remain approved +proposals until their separate implementation/test PRs. A proposal is not +execution or downstream adoption evidence. The initiating [consumer contract](https://github.com/Dastari/digibase/blob/agent/vertical-slice-plan/docs/coordination/prompts/0013-graphql-orm-runnable-product-prerequisites.md) requires separate reviewable PRs. The host owns catalog persistence, activation, diff --git a/docs/reference/graphql-orm/README.md b/docs/reference/graphql-orm/README.md index 62f0cee6..5b699012 100644 --- a/docs/reference/graphql-orm/README.md +++ b/docs/reference/graphql-orm/README.md @@ -57,6 +57,7 @@ guidance for the core ORM package. ## Runtime schema APIs - [Runtime schema IR](runtime-schema-ir.md) +- [Owned runtime migration targets](runtime-migrations.md) - [Runtime records](runtime-records.md) - [Runtime queries](runtime-queries.md) - [Runtime relations](runtime-relations.md) diff --git a/docs/reference/graphql-orm/runtime-migrations.md b/docs/reference/graphql-orm/runtime-migrations.md new file mode 100644 index 00000000..640ac3c9 --- /dev/null +++ b/docs/reference/graphql-orm/runtime-migrations.md @@ -0,0 +1,176 @@ +--- +title: Owned runtime migration targets +kind: reference +status: active +owner: graphql-orm-maintainers +last_reviewed: 2026-10-01 +review_by: 2027-02-01 +supersedes: [] +--- + +# Owned runtime migration targets + +ORM/macros 0.34.0 adds migration targets from the existing validated runtime +schema on SQLite and PostgreSQL. These APIs are separate from schema activation, +catalog persistence, public revision selection, authorization policy and transport, +which remain host responsibilities. Dynamic GraphQL and runtime record mutations +are later capabilities. No feature beyond the selected database backend is required. + +## Public host flow + +```rust,ignore +use graphql_orm::graphql::orm::*; + +let schema = std::sync::Arc::new(catalog_definition.validate()?); +let target = schema.physical_schema::(RuntimeMigrationLimits::default())? + .with_static_entities(&[HostJournal::metadata()])?; +let ownership = ManagedTableSet::new([ + "notes".to_owned(), "host_journal".to_owned(), +])?; +let manager = database.schema(); +let plan = manager.plan_owned_migration( + "schema-42", "reviewed notes target", &target, &ownership, PlanOptions::strict(), +).await?; +for step in plan.steps() { + inspect(step.table_name(), step.risk(), step.reason()); +} +inspect_sql(plan.statements()); +// Separate explicit host decision; ordinary destructive/additive guards still apply. +let report = manager.apply_owned_migration(&plan, ApplyOptions { + additive_only: true, + expected_current_schema_hash: Some(plan.source_schema_hash().to_owned()), + ..Default::default() +}).await?; +assert!(!report.dry_run); +``` + +The compiled [disposable host example](../../../crates/graphql-orm/examples/runtime_owned_migration.rs) +uses an application-generated UUID key, previews by default and applies only when +invoked with `--apply`: + +```sh +cargo run -p graphql-orm --no-default-features --features sqlite \ + --example runtime_owned_migration +cargo run -p graphql-orm --no-default-features --features sqlite \ + --example runtime_owned_migration -- --apply +``` + +Both commands use an in-memory database owned by that process. PostgreSQL host +code selects `PostgresBackend` and a PostgreSQL `Database`; regression tests use +labelled disposable containers, never an ambient application connection. + +## One canonical physical contract + +`OwnedSchemaModel`, `OwnedTableModel`, and `IndexModel` own the same canonical +physical definitions as `SchemaModel`, `TableModel`, and `IndexDef`. Private +borrowed views adapt storage into one validation/hash/diff/classification/render +engine. Runtime conversion and owned live introspection never leak strings or +index arrays. Static compatibility adapters retain their existing static index +storage convention. + +The legacy public `MigrationStep` remains an actual enum. Variant and glob imports, +qualified constructors, exhaustive matches, inferred values, existing struct +literals and function pointers remain valid. The external +[migration compatibility fixture](../../../crates/graphql-orm/tests/fixtures/migration-api-compatibility/src/lib.rs) +compiles these call sites without depending on implementation internals. Legacy +physical and plan hash formats are retained. Owned plan integrity additionally +binds version, description, complete live physical source hash, target hash and +explicit ownership. Hashes are drift detectors, not signatures or authorization. + +Public collection/field names are diagnostic/query identities. Physical table and +column names determine DDL. Changing public names alone never plans physical +renames or record deletion. Equivalent static/owned targets use the same SQL/risk +semantics and replan to an empty step/statement list after application in either +origin direction. SQLite introspection respects primary-key member ordinals, +including a composite key whose order differs from column declaration order. + +## Ownership, composition and dependencies + +`ManagedTableSet` is trusted host state. Its portable table identifiers are unique, +case-sensitive and exclude ORM-reserved infrastructure. Every target table must +belong to it. Keep deliberately removed tables in the set until their drop is +reviewed; dropping them is destructive. Removing a name from the set relinquishes +ownership and cannot authorize a drop. `PlanOptions::strict` includes all owned +live tables. `managed_tables_only` retains the legacy option's narrower behavior: +it ignores owned live tables absent from the target. Neither option includes +unowned live tables as drop candidates. + +`with_static_entities` composes host system metadata, resolves relation target +labels against the complete physical target and rejects duplicate names or invalid +keys/FKs. Hosts can bind static relation metadata to stable physical table names +when the runtime's public type name may change. Ordinary static policy metadata +stays on the host entity declaration; this method does not create or relax policies. + +Live introspection reads the full database schema used by the canonical backend +contract. Unowned incoming FKs are checked before permitting changes to referenced +owned definitions. Initial preservation checks are conservative: changes to a +referenced definition with an unowned incoming FK are rejected; an unchanged +definition can gain indexes. Compose host-owned system tables explicitly to plan +changes to the owned dependency graph together. No `CASCADE` DDL bypass is added. + +After applying and replanning to no-op, hosts can obtain a +`RuntimeMutationEnvironment` through `runtime_mutation_environment(Arc, +&target, &ownership)`. It verifies that the schema matches the live composed +physical target and records incoming ordered FK bindings/actions from all physical +sources, including system and ORM infrastructure tables absent from the runtime +IR. This immutable environment is a physical dependency certificate for later +runtime writes, not another schema IR or an authorization grant. Hosts must fence +external DDL and revalidate after drift. The host's complete public/policy revision +remains distinct from structural ORM fingerprints; validate policy-only revision +changes before future mutations. + +## Read-only planning and guarded application + +`validate_owned_schema` and `plan_owned_migration` only read catalog state. They do +not create migration history, clean SQLite rewrite tables, apply SQL or reconcile +RLS. Capability, target bounds, ownership, policy and unsupported preservation +checks produce structured diagnostics where applicable. + +`apply_owned_migration` accepts an immutable plan under `SchemaPolicy::Managed`. +It enforces backend/integrity/ownership binding, `expected_current_schema_hash`, +`additive_only`, and explicit `allow_destructive`. `dry_run` performs no I/O. +Owned plans always require their pinned baseline: `require_clean_schema = false` +does not weaken this binding. A changed live physical source requires a new plan. + +Application rechecks the baseline on the pinned transaction before DDL. SQLite +uses `BEGIN IMMEDIATE`; controlled rebuilds check FKs before commit and restore +connection state. A connection whose FKs were suspended is closed on drop so +cancellation cannot return that state to the pool. PostgreSQL uses a cooperative +migration advisory lock plus locks on existing owned tables. Hosts must coordinate +DDL issued outside ORM migration execution. DDL and the successful history row +commit together. A recorded version is an already-applied success only after a +fresh no-op replan; nonempty reuse is rejected. Database commit errors remain +errors; do not blindly retry an ambiguous outcome. + +Owned apply never invokes RLS reconciliation with an empty target. PostgreSQL RLS +flags, policies, table ownership/grants, host helper functions and unrelated tables +remain intact. Removal/type changes on existing RLS-protected definitions are +rejected when preservation cannot be established. SQLite controlled rebuilds +reject host triggers, view preservation cases and reserved temporary-name collisions +instead of running the legacy global stale-table cleanup. + +## Scalar/default and capability limits + +Mapping preserves boolean, signed 64-bit integer, finite float, string, UUID, JSON, +bytes and UTC datetime logical storage, ordered keys, unique/index definitions, +relations/delete actions, append-only and retention semantics. An application- +generated UUID has no database identity/default expression. Generated non-UUID +fields need a supported default; otherwise `UnsupportedGeneration` identifies the +collection/member. Literal defaults must retain their validated type semantics. +Integer `CurrentTimestamp` uses backend epoch seconds. SQLite datetime defaults +emit UTC RFC 3339 with six fractional digits (`strftime` has millisecond resolution, +so the final three are zero); PostgreSQL uses its native timestamp default. SQL +NULL and JSON null semantics remain those of the runtime record API. + +`RuntimeMigrationLimits` bounds collections, per-table fields/indexes/relations, +key arity, default bytes, total target bytes and rendered plan steps/statements/bytes. +Custom limits remain attached to the owned target through composition and planning. +Default bounds are 512 tables, 256 fields, 128 indexes/relations, 32 key members, +64 KiB per default, 8 MiB per target/plan, 16,384 steps and 32,768 statements. + +MSSQL and unknown backend capabilities reject runtime migration conversion, +planning and apply before connection acquisition. Existing static APIs and MSSQL +read/write/schema-policy contracts remain unchanged. Unsupported runtime IR +semantics continue to be rejected by its existing validator/conversion; do not +substitute arbitrary SQL fragments. The dependency environment does not implement +record writes, cascade mutations, catalog activation or durable event replay. diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 4c32625b..a1b49ed8 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -18,11 +18,11 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | -| `graphql-orm` | `0.33.2` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.34.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.106.0` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.15.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.33.2` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.34.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.4.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | | `graphql-orm-router` | `0.5.3` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none | From 1581df980af962edd50cb34659ceea185cc0016d Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 1 Oct 2026 02:00:58 +0000 Subject: [PATCH 3/7] test: enforce owned migration preservation and fail closed on legacy datetime defaults --- CHANGELOG.md | 3 + MIGRATION.md | 6 + .../graphql-orm/src/graphql/orm/execution.rs | 83 +++++- .../graphql-orm/src/graphql/orm/migrations.rs | 2 +- .../src/graphql/orm/runtime_migration.rs | 29 ++- .../src/graphql/orm/runtime_schema.rs | 44 ++-- .../graphql-orm/tests/runtime_migrations.rs | 245 +++++++++++++++++- crates/graphql-orm/tests/runtime_schema_ir.rs | 31 ++- .../tests/support/owned_postgres.rs | 2 + .../runtime-platform-capabilities/README.md | 2 +- .../graphql-orm/runtime-migrations.md | 20 +- 11 files changed, 435 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95748468..11f93041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ Companion macros crate: `graphql-orm-macros` **0.34.0** (alignment only). preservation cases. Add a verified incoming-dependency environment for later runtime mutations; record mutation and dynamic GraphQL are still later slices. - Respect SQLite composite primary-key ordinal order during introspection. +- Reject legacy epoch-second DateTime default conversion with scoped structured + diagnostics while preserving static storage and supported Integer defaults; + preserve escaped quote semantics in converted literal defaults. No automatic application, host catalog, transport, durable delivery or release publication is introduced. See the owned runtime migration reference and example. diff --git a/MIGRATION.md b/MIGRATION.md index 425e2552..a8dd8a7b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -33,6 +33,12 @@ The physical dependency environment is not a record-write API or an authorizatio certificate. Hosts must fence external DDL and validate their complete public/policy revision before future mutations; ORM fingerprints exclude host policy revisions. +Static-to-runtime conversion now rejects an epoch-second default on a DateTime +field with a scoped `UnsupportedDefault` diagnostic. Existing static defaults and +storage remain unchanged; Integer epoch-second defaults remain supported. Canonical +runtime datetime defaults retain RFC3339/native timestamp semantics. No implicit +legacy datetime storage conversion or data migration is performed. + ## 0.33.2 to 0.33.3: repository aggregate enum compatibility Pin runtime and macros 0.33.3 to the same reviewed full workspace revision. diff --git a/crates/graphql-orm/src/graphql/orm/execution.rs b/crates/graphql-orm/src/graphql/orm/execution.rs index a97e0a82..a801403f 100644 --- a/crates/graphql-orm/src/graphql/orm/execution.rs +++ b/crates/graphql-orm/src/graphql/orm/execution.rs @@ -1167,6 +1167,61 @@ pub(super) async fn validate_owned_sqlite_plan( plan: &OwnedPlannedMigration, ) -> Result<(), RuntimeMigrationError> { use super::runtime_migration::{RuntimeMigrationDiagnosticCode, rejection}; + use sqlx::Row; + // The canonical index model has no expression or collation storage. Never + // silently replace a managed index after lossy catalog interpretation. + for table in plan.ownership().tables() { + let indexes = sqlx::query("SELECT name, partial FROM pragma_index_list(?)") + .bind(table) + .fetch_all(&mut *connection) + .await?; + for index in indexes { + let name: String = index.try_get("name")?; + let members = sqlx::query("SELECT cid, coll FROM pragma_index_xinfo(?) WHERE key = 1") + .bind(&name) + .fetch_all(&mut *connection) + .await?; + if members.iter().any(|member| { + member.try_get::("cid").is_ok_and(|cid| cid < 0) + || member + .try_get::("coll") + .is_ok_and(|coll| !coll.eq_ignore_ascii_case("BINARY")) + }) { + return Err( + rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract).into(), + ); + } + if index.try_get::("partial")? != 0 { + let sql: String = sqlx::query_scalar( + "SELECT sql FROM sqlite_master WHERE type='index' AND name=?", + ) + .bind(&name) + .fetch_one(&mut *connection) + .await?; + let recognized = sql.to_ascii_uppercase().find(" WHERE ").and_then(|offset| { + super::migrations::parse_closed_set_index_predicate(&sql[offset + 7..]) + }); + if recognized.is_none() { + return Err( + rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract).into(), + ); + } + } + } + } + if plan + .statements() + .iter() + .any(|sql| sql == "DROP TABLE IF EXISTS __graphql_orm_retention_context") + { + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='__graphql_orm_retention_context')") + .fetch_one(&mut *connection).await?; + if exists { + // This shared infrastructure may serve unowned append-only tables. + // The legacy repair/drop operation cannot prove bounded ownership. + return Err(rejection(RuntimeMigrationDiagnosticCode::OwnershipMismatch).into()); + } + } let rebuilt = plan.rebuilt_sqlite_tables().collect::>(); if !rebuilt.is_empty() { // Without a SQL view-dependency IR, preserving arbitrary view definitions @@ -1217,8 +1272,34 @@ pub(super) async fn validate_owned_postgres_plan( plan: &OwnedPlannedMigration, ) -> Result<(), RuntimeMigrationError> { use super::runtime_migration::{RuntimeMigrationDiagnosticCode, rejection}; + for table in plan.ownership().tables() { + let indexes: Vec<(bool, String, Option, bool)> = sqlx::query_as( + "SELECT ix.indexprs IS NOT NULL, am.amname, pg_get_expr(ix.indpred, ix.indrelid), + EXISTS(SELECT 1 FROM unnest(ix.indclass::oid[]) cls JOIN pg_opclass opc ON opc.oid=cls WHERE NOT opc.opcdefault) + OR EXISTS(SELECT 1 FROM unnest(ix.indcollation::oid[]) coll WHERE coll NOT IN (0, 100)) + OR ix.indnatts <> ix.indnkeyatts OR i.reloptions IS NOT NULL + OR EXISTS(SELECT 1 FROM unnest(ix.indoption::smallint[]) opt WHERE ((opt & 1) = 1) <> ((opt & 2) = 2)) + FROM pg_class t JOIN pg_namespace n ON n.oid=t.relnamespace + JOIN pg_index ix ON ix.indrelid=t.oid + JOIN pg_class i ON i.oid=ix.indexrelid JOIN pg_am am ON am.oid=i.relam + WHERE n.nspname=current_schema() AND t.relname=$1" + ).bind(table).fetch_all(&mut *connection).await?; + for (expression, method, predicate, unsupported_storage) in indexes { + if expression + || !matches!(method.as_str(), "btree" | "gist") + || unsupported_storage + || predicate.as_deref().is_some_and(|predicate| { + super::migrations::parse_closed_set_index_predicate(predicate).is_none() + }) + { + return Err( + rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract).into(), + ); + } + } + } let protected: Vec<(String,)> = sqlx::query_as("SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=current_schema() AND c.relkind='r' AND (c.relrowsecurity OR c.relforcerowsecurity OR EXISTS(SELECT 1 FROM pg_policy p WHERE p.polrelid=c.oid))") - .fetch_all(connection).await?; + .fetch_all(&mut *connection).await?; if protected .iter() .any(|(table,)| plan.changes_protected_rls_structure(table)) diff --git a/crates/graphql-orm/src/graphql/orm/migrations.rs b/crates/graphql-orm/src/graphql/orm/migrations.rs index 7b53788e..53243036 100644 --- a/crates/graphql-orm/src/graphql/orm/migrations.rs +++ b/crates/graphql-orm/src/graphql/orm/migrations.rs @@ -1186,7 +1186,7 @@ fn parse_structural_string_value( } #[cfg(any(feature = "sqlite", feature = "postgres"))] -fn parse_closed_set_index_predicate(expression: &str) -> Option { +pub(super) fn parse_closed_set_index_predicate(expression: &str) -> Option { let tokens = tokenize_structural_sql(expression)?; let tokens = strip_redundant_outer_parentheses(&tokens); let StructuralSqlToken::Ident(column) = tokens.first()? else { diff --git a/crates/graphql-orm/src/graphql/orm/runtime_migration.rs b/crates/graphql-orm/src/graphql/orm/runtime_migration.rs index cac431ab..3f7406f6 100644 --- a/crates/graphql-orm/src/graphql/orm/runtime_migration.rs +++ b/crates/graphql-orm/src/graphql/orm/runtime_migration.rs @@ -832,13 +832,27 @@ pub trait RuntimeMigrationBackend: OrmBackend { impl RuntimeMigrationBackend for super::MssqlBackend {} impl RuntimeMigrationBackend for NoDefaultBackend {} +#[cfg(any(feature = "sqlite", feature = "postgres"))] +fn owned_introspection_error(error: sqlx::Error) -> RuntimeMigrationError { + match error { + // Catalog semantics rejected by the canonical parser are capability + // diagnostics; transport/database errors retain their original source. + sqlx::Error::Protocol(_) | sqlx::Error::ColumnDecode { .. } => { + rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract).into() + } + other => RuntimeMigrationError::Database(other), + } +} + #[cfg(feature = "sqlite")] impl RuntimeMigrationBackend for super::SqliteBackend { const RUNTIME_MIGRATIONS_SUPPORTED: bool = true; async fn introspect_owned( pool: &Self::Pool, ) -> Result { - Ok(super::migrations::introspect_owned_sqlite_schema(pool).await?) + super::migrations::introspect_owned_sqlite_schema(pool) + .await + .map_err(owned_introspection_error) } async fn introspect_dependencies( pool: &Self::Pool, @@ -873,7 +887,9 @@ impl RuntimeMigrationBackend for super::PostgresBackend { async fn introspect_owned( pool: &Self::Pool, ) -> Result { - Ok(super::migrations::introspect_owned_postgres_schema(pool).await?) + super::migrations::introspect_owned_postgres_schema(pool) + .await + .map_err(owned_introspection_error) } async fn introspect_dependencies( pool: &Self::Pool, @@ -899,7 +915,7 @@ impl RuntimeMigrationBackend for super::PostgresBackend { plan: &OwnedPlannedMigration, options: &ApplyOptions, ) -> Result { - Ok(super::execution::apply_owned_postgres_migration(pool, plan, options).await?) + super::execution::apply_owned_postgres_migration(pool, plan, options).await } } @@ -911,6 +927,13 @@ impl<'db, B: RuntimeMigrationBackend> SchemaManager<'db, B> { ownership: &ManagedTableSet, ) -> Result { supported::()?; + if !self.policy().allows_validation() { + return Err(rejection(RuntimeMigrationDiagnosticCode::PolicyDenied).into()); + } + target.check_limits(target.limits)?; + target + .validate_physical_contract() + .map_err(|_| rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract))?; let current = B::introspect_owned(self.database.pool()).await?; validate_owned_effects(B::DIALECT, ¤t, target, ownership)?; let scoped = scoped_current(¤t, ownership, PlanOptions::strict(), target); diff --git a/crates/graphql-orm/src/graphql/orm/runtime_schema.rs b/crates/graphql-orm/src/graphql/orm/runtime_schema.rs index f5cdb73e..0e06287a 100644 --- a/crates/graphql-orm/src/graphql/orm/runtime_schema.rs +++ b/crates/graphql-orm/src/graphql/orm/runtime_schema.rs @@ -1463,24 +1463,29 @@ const EPOCH_DEFAULT_EXPRESSIONS: &[&str] = &[ "DATEDIFF_BIG(second, '1970-01-01', SYSUTCDATETIME())", ]; -fn convert_default(raw: &str) -> Option { +fn convert_default(raw: &str, kind: RuntimeValueKind) -> Option { if EPOCH_DEFAULT_EXPRESSIONS.contains(&raw) { - return Some(RuntimeDefault::CurrentTimestamp); + return (kind == RuntimeValueKind::Integer).then_some(RuntimeDefault::CurrentTimestamp); + } + if let Some(quoted) = raw + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + { + let mut value = String::with_capacity(quoted.len()); + let mut characters = quoted.chars(); + while let Some(character) = characters.next() { + if character == '\'' && characters.next() != Some('\'') { + return None; + } + value.push(character); + } + return Some(RuntimeDefault::Literal(value)); } let literal = raw.parse::().is_ok() || raw.parse::().is_ok() || raw.eq_ignore_ascii_case("true") - || raw.eq_ignore_ascii_case("false") - || (raw.len() >= 2 && raw.starts_with('\'') && raw.ends_with('\'')); - if literal { - let value = raw - .strip_prefix('\'') - .and_then(|v| v.strip_suffix('\'')) - .unwrap_or(raw); - Some(RuntimeDefault::Literal(value.to_string())) - } else { - None - } + || raw.eq_ignore_ascii_case("false"); + literal.then(|| RuntimeDefault::Literal(raw.to_owned())) } fn convert_value_kind(field: &FieldMetadata) -> Option { @@ -1514,6 +1519,8 @@ impl RuntimeSchema { /// /// Static declarations using capabilities the IR does not represent yet (spatial, search, /// partial/GiST indexes, check constraints) are reported as diagnostics rather than dropped. + /// Legacy epoch-second defaults on DateTime fields are rejected with `UnsupportedDefault`; + /// integer epoch-second defaults remain supported. Static declarations/storage are unchanged. pub fn from_static_entities( entities: &[&EntityMetadata], ) -> Result { @@ -1633,15 +1640,16 @@ impl RuntimeSchema { }; let default = match field.default { None => None, - Some(raw) => match convert_default(raw) { + Some(raw) => match convert_default(raw, value_kind) { Some(default) => Some(default), None => { diagnostics.push(RuntimeSchemaDiagnostic::scoped( RuntimeSchemaDiagnosticCode::UnsupportedDefault, - format!( - "column `{}` default `{raw}` is not a portable literal or known timestamp expression", - field.name - ), + if value_kind == RuntimeValueKind::DateTime && EPOCH_DEFAULT_EXPRESSIONS.contains(&raw) { + format!("column `{}` uses legacy epoch-second datetime default `{raw}`; canonical runtime DateTime requires RFC3339/native timestamp storage, not epoch seconds", field.name) + } else { + format!("column `{}` default `{raw}` is not a portable literal or supported timestamp expression for its value kind", field.name) + }, &cid, Some(field.name), )); diff --git a/crates/graphql-orm/tests/runtime_migrations.rs b/crates/graphql-orm/tests/runtime_migrations.rs index fe9de52e..c60025b7 100644 --- a/crates/graphql-orm/tests/runtime_migrations.rs +++ b/crates/graphql-orm/tests/runtime_migrations.rs @@ -8,13 +8,25 @@ use graphql_orm::graphql::orm::*; #[path = "support/owned_postgres.rs"] mod owned_postgres; -#[cfg_attr(feature = "sqlite", graphql_entity(backend = "sqlite"))] +#[derive(GraphQLSchemaEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[cfg_attr( + feature = "sqlite", + graphql_entity( + backend = "sqlite", + table = "owned_notes", + plural = "Notes", + default_sort = "id ASC" + ) +)] #[cfg_attr( all(feature = "postgres", not(feature = "sqlite")), - graphql_entity(backend = "postgres") + graphql_entity( + backend = "postgres", + table = "owned_notes", + plural = "Notes", + default_sort = "id ASC" + ) )] -#[derive(GraphQLSchemaEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] -#[graphql_entity(table = "owned_notes", plural = "Notes", default_sort = "id ASC")] struct Note { #[primary_key] #[graphql_orm(auto_generated = false)] @@ -708,9 +720,9 @@ async fn sqlite_composite_keys_all_runtime_types_and_timestamp_defaults() } #[cfg(feature = "sqlite")] -#[graphql_entity(backend = "sqlite")] #[derive(GraphQLSchemaEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] #[graphql_entity( + backend = "sqlite", table = "host_journal", plural = "HostJournal", default_sort = "id ASC", @@ -970,3 +982,226 @@ async fn postgres_competing_baselines_destructive_guards_and_atomic_failure() owned.cleanup()?; Ok(()) } + +#[cfg(feature = "sqlite")] +#[derive(GraphQLSchemaEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity(backend = "sqlite", table = "legacy_times", plural = "LegacyTimes")] +struct LegacyTime { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + #[date_field] + created_at: String, +} +#[cfg(feature = "sqlite")] +#[derive(GraphQLSchemaEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity( + backend = "sqlite", + table = "integer_times", + plural = "IntegerTimes", + default_sort = "id ASC" +)] +struct IntegerTime { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + created_at: i64, +} +#[cfg(feature = "sqlite")] +#[test] +fn legacy_datetime_default_conversion_fails_closed_without_changing_static_storage() { + let static_target = SchemaModel::from_entities(&[LegacyTime::metadata()]); + let legacy = static_target.tables[0] + .columns + .iter() + .find(|c| c.name == "created_at") + .unwrap(); + assert_eq!(legacy.default.as_deref(), Some("unixepoch()")); + let error = RuntimeSchema::from_static_entities(&[LegacyTime::metadata()]).unwrap_err(); + let diagnostic = error + .diagnostics() + .iter() + .find(|d| d.code == RuntimeSchemaDiagnosticCode::UnsupportedDefault) + .unwrap(); + assert_eq!( + diagnostic.collection.as_ref().map(CollectionId::as_str), + Some("legacy_times") + ); + assert_eq!(diagnostic.subject.as_deref(), Some("created_at")); + assert!(diagnostic.message.contains("epoch-second datetime")); + assert!(diagnostic.message.contains("RFC3339/native timestamp")); + + let runtime = RuntimeSchema::from_static_entities(&[IntegerTime::metadata()]) + .unwrap() + .validate() + .unwrap(); + let target = runtime + .physical_schema::(Default::default()) + .unwrap(); + let column = target.tables()[0] + .columns() + .iter() + .find(|c| c.name == "created_at") + .unwrap(); + assert_eq!(column.sql_type, "INTEGER"); + assert_eq!(column.default.as_deref(), Some("unixepoch()")); +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sqlite_owned_plans_reject_unrepresentable_indexes() +-> Result<(), Box> { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await?; + let db = Database::::new(pool); + let manager = db.schema(); + let target = target::(); + let initial = manager + .plan_owned_migration( + "initial", + "owned", + &target, + &ownership(), + PlanOptions::strict(), + ) + .await?; + manager + .apply_owned_migration(&initial, ApplyOptions::default()) + .await?; + for sql in [ + "CREATE INDEX unsupported ON owned_notes(label) WHERE score > 0", + "CREATE INDEX unsupported ON owned_notes(label COLLATE NOCASE)", + "CREATE INDEX unsupported ON owned_notes(lower(label))", + ] { + sqlx::query(sql).execute(db.pool()).await?; + rejected( + manager + .plan_owned_migration( + "unsafe", + "cannot preserve", + &target, + &ownership(), + PlanOptions::strict(), + ) + .await + .unwrap_err(), + RuntimeMigrationDiagnosticCode::InvalidPhysicalContract, + ); + let retained: i64 = + sqlx::query_scalar("SELECT count(*) FROM sqlite_master WHERE name='unsupported'") + .fetch_one(db.pool()) + .await?; + assert_eq!( + retained, 1, + "planning does not alter unsupported catalog state" + ); + sqlx::query("DROP INDEX unsupported") + .execute(db.pool()) + .await?; + } + Ok(()) +} + +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] +#[tokio::test] +async fn postgres_owned_plans_reject_unrepresentable_indexes() +-> Result<(), Box> { + let mut owned = owned_postgres::OwnedPostgres::start("owned-index-guards")?; + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&owned.url) + .await?; + let db = Database::::new(pool); + let manager = db.schema(); + let target = target::(); + let initial = manager + .plan_owned_migration( + "initial", + "owned", + &target, + &ownership(), + PlanOptions::strict(), + ) + .await?; + manager + .apply_owned_migration(&initial, ApplyOptions::default()) + .await?; + for sql in [ + "CREATE INDEX unsupported ON owned_notes(label) WHERE score > 0", + "CREATE INDEX unsupported ON owned_notes(lower(label))", + "CREATE INDEX unsupported ON owned_notes(label COLLATE \"C\")", + "CREATE INDEX unsupported ON owned_notes(label DESC NULLS LAST)", + "CREATE INDEX unsupported ON owned_notes(label) INCLUDE (score)", + "CREATE INDEX unsupported ON owned_notes(label text_pattern_ops)", + ] { + sqlx::query(sql).execute(db.pool()).await?; + rejected( + manager + .plan_owned_migration( + "unsafe", + "cannot preserve", + &target, + &ownership(), + PlanOptions::strict(), + ) + .await + .unwrap_err(), + RuntimeMigrationDiagnosticCode::InvalidPhysicalContract, + ); + let retained: i64 = sqlx::query_scalar("SELECT count(*) FROM pg_indexes WHERE schemaname=current_schema() AND indexname='unsupported'").fetch_one(db.pool()).await?; + assert_eq!(retained, 1); + sqlx::query("DROP INDEX unsupported") + .execute(db.pool()) + .await?; + } + db.pool().close().await; + owned.cleanup()?; + Ok(()) +} + +#[test] +fn static_quoted_literal_defaults_convert_losslessly() { + let mut metadata = Note::metadata().clone(); + metadata + .fields + .iter_mut() + .find(|f| f.name == "label") + .unwrap() + .default = Some("'a''b'"); + let static_target = SchemaModel::from_entities(&[&metadata]); + let runtime = RuntimeSchema::from_static_entities(&[&metadata]) + .unwrap() + .validate() + .unwrap(); + #[cfg(feature = "sqlite")] + let owned = runtime + .physical_schema::(Default::default()) + .unwrap(); + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + let owned = runtime + .physical_schema::(Default::default()) + .unwrap(); + assert_eq!(owned.stable_hash(), static_target.stable_hash()); + assert_eq!( + owned.tables()[0] + .columns() + .iter() + .find(|c| c.name == "label") + .unwrap() + .default + .as_deref(), + Some("'a''b'") + ); + metadata + .fields + .iter_mut() + .find(|f| f.name == "label") + .unwrap() + .default = Some("'a'b'"); + let diagnostics = RuntimeSchema::from_static_entities(&[&metadata]).unwrap_err(); + assert!(diagnostics.diagnostics().iter().any(|d| d.code + == RuntimeSchemaDiagnosticCode::UnsupportedDefault + && d.subject.as_deref() == Some("label"))); +} diff --git a/crates/graphql-orm/tests/runtime_schema_ir.rs b/crates/graphql-orm/tests/runtime_schema_ir.rs index 8d7e0327..b185713c 100644 --- a/crates/graphql-orm/tests/runtime_schema_ir.rs +++ b/crates/graphql-orm/tests/runtime_schema_ir.rs @@ -55,6 +55,8 @@ pub struct Customer { #[date_field] #[sortable] + // A representable datetime declaration has no legacy integer epoch default. + #[graphql_orm(default = false)] pub created_at: String, #[graphql(skip)] @@ -70,6 +72,7 @@ pub struct Customer { #[graphql_entity( table = "contact_details", plural = "ContactDetails", + default_sort = "id ASC", unique_composite = "customer_id,kind", index = "customer_id" )] @@ -135,6 +138,7 @@ pub struct CustomerNote { #[graphql_entity( table = "note_attachments", plural = "NoteAttachments", + default_sort = "id ASC", index = "customer_id,note_seq" )] pub struct NoteAttachment { @@ -318,7 +322,6 @@ fn fixture_runtime_schema() -> RuntimeSchema { }, RuntimeField { sortable: true, - default: Some(RuntimeDefault::CurrentTimestamp), ..field( "fld_customer_created_at", "createdAt", @@ -1094,3 +1097,29 @@ fn renamed_fields_convert_with_their_public_api_name() { .expect("field present"); assert_eq!(field.api_name, "customName"); } + +#[cfg(any(feature = "sqlite", feature = "postgres"))] +#[test] +fn representable_static_graph_converts_to_equivalent_physical_target() { + use graphql_orm::graphql::orm::{OwnedSchemaModel, SchemaModel}; + let metadata = [ + Customer::metadata(), + ContactDetail::metadata(), + CustomerNote::metadata(), + NoteAttachment::metadata(), + ]; + let schema = RuntimeSchema::from_static_entities(&metadata) + .unwrap() + .validate() + .unwrap(); + #[cfg(feature = "sqlite")] + let target: OwnedSchemaModel = schema + .physical_schema::(Default::default()) + .unwrap(); + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + let target: OwnedSchemaModel = schema + .physical_schema::(Default::default()) + .unwrap(); + let static_target = SchemaModel::from_entities(&metadata); + assert_eq!(target.stable_hash(), static_target.stable_hash()); +} diff --git a/crates/graphql-orm/tests/support/owned_postgres.rs b/crates/graphql-orm/tests/support/owned_postgres.rs index 19676ae8..cb36379e 100644 --- a/crates/graphql-orm/tests/support/owned_postgres.rs +++ b/crates/graphql-orm/tests/support/owned_postgres.rs @@ -58,6 +58,8 @@ impl OwnedPostgres { "exec", &owned.container_id, "pg_isready", + "-h", + "127.0.0.1", "-U", "graphql_orm_owner", "-d", diff --git a/docs/plans/active/runtime-platform-capabilities/README.md b/docs/plans/active/runtime-platform-capabilities/README.md index 6f6c0ad8..7b334100 100644 --- a/docs/plans/active/runtime-platform-capabilities/README.md +++ b/docs/plans/active/runtime-platform-capabilities/README.md @@ -296,7 +296,7 @@ let plan = database.schema().plan_owned_migration( ).await?; // Host reviews diagnostics/risks and retains the exact reviewed plan. let applied = database.schema().apply_owned_migration(&plan, ApplyOptions { - expected_current_schema_hash: plan.source_schema_hash().cloned(), + expected_current_schema_hash: Some(plan.source_schema_hash().to_owned()), ..ApplyOptions::default() }).await?; ``` diff --git a/docs/reference/graphql-orm/runtime-migrations.md b/docs/reference/graphql-orm/runtime-migrations.md index 640ac3c9..4eae284f 100644 --- a/docs/reference/graphql-orm/runtime-migrations.md +++ b/docs/reference/graphql-orm/runtime-migrations.md @@ -79,7 +79,7 @@ explicit ownership. Hashes are drift detectors, not signatures or authorization. Public collection/field names are diagnostic/query identities. Physical table and column names determine DDL. Changing public names alone never plans physical -renames or record deletion. Equivalent static/owned targets use the same SQL/risk +renames or record deletion. Equivalent representable static/owned targets use the same SQL/risk semantics and replan to an empty step/statement list after application in either origin direction. SQLite introspection respects primary-key member ordinals, including a composite key whose order differs from column declaration order. @@ -147,7 +147,12 @@ flags, policies, table ownership/grants, host helper functions and unrelated tab remain intact. Removal/type changes on existing RLS-protected definitions are rejected when preservation cannot be established. SQLite controlled rebuilds reject host triggers, view preservation cases and reserved temporary-name collisions -instead of running the legacy global stale-table cleanup. +instead of running the legacy global stale-table cleanup. Managed expression, +non-default-collation and unsupported partial indexes are rejected instead of +losing semantics through catalog interpretation. PostgreSQL index INCLUDE members, +non-default operator classes/null ordering/storage options and unsupported methods +are also rejected. SQLite plans that would drop an existing shared retention +context are rejected to preserve infrastructure serving other tables. ## Scalar/default and capability limits @@ -162,6 +167,17 @@ emit UTC RFC 3339 with six fractional digits (`strftime` has millisecond resolut so the final three are zero); PostgreSQL uses its native timestamp default. SQL NULL and JSON null semantics remain those of the runtime record API. +Static-to-runtime conversion rejects legacy epoch-second defaults on fields +marked DateTime with a scoped `RuntimeSchemaDiagnosticCode::UnsupportedDefault`. +The diagnostic identifies the collection and field and explains the incompatible +storage semantic. In particular, SQLite `#[date_field] created_at: String` keeps +its existing static `unixepoch()` default; it is never reinterpreted as RFC3339. +Supported Integer timestamp fields keep epoch-second defaults. For representable +static datetime declarations, use an explicitly compatible default or suppress +the legacy implicit default with `#[graphql_orm(default = false)]`. Suppression +changes that declaration's target and is not an automatic existing-data migration. +A legacy datetime adapter remains a separate follow-up. + `RuntimeMigrationLimits` bounds collections, per-table fields/indexes/relations, key arity, default bytes, total target bytes and rendered plan steps/statements/bytes. Custom limits remain attached to the owned target through composition and planning. From aeb07b1b631b3aa9901c4a06b70b31a7ace10ff3 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 1 Oct 2026 04:22:57 +0000 Subject: [PATCH 4/7] fix: reject unsupported runtime foreign keys and preserve restored SQLite leases --- CHANGELOG.md | 8 +- MIGRATION.md | 11 +- crates/graphql-orm/README.md | 5 +- .../graphql-orm/src/graphql/orm/execution.rs | 91 ++- crates/graphql-orm/src/graphql/orm/mod.rs | 1 + .../src/graphql/orm/owned_foreign_keys.rs | 174 ++++++ .../src/graphql/orm/runtime_migration.rs | 28 + .../tests/legacy_migration_history.rs | 12 +- crates/graphql-orm/tests/migration_apply.rs | 22 +- .../graphql-orm/tests/runtime_migrations.rs | 554 ++++++++++++++++++ .../runtime-platform-capabilities/README.md | 34 +- .../graphql-orm/runtime-migrations.md | 34 +- 12 files changed, 944 insertions(+), 30 deletions(-) create mode 100644 crates/graphql-orm/src/graphql/orm/owned_foreign_keys.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f93041..357c370a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ title: "Changelog" kind: reference status: active owner: workspace-maintainers -last_reviewed: 2026-09-30 +last_reviewed: 2026-10-01 review_by: 2027-02-01 supersedes: [] --- @@ -29,6 +29,12 @@ Companion macros crate: `graphql-orm-macros` **0.34.0** (alignment only). and additive guards, preserve host PostgreSQL RLS, and reject unsupported live preservation cases. Add a verified incoming-dependency environment for later runtime mutations; record mutation and dynamic GraphQL are still later slices. +- Reject unrepresentable live FK actions, deferral and dependency scopes with + `UnsupportedForeignKey`, including incoming system/unowned sources. Recheck on + the pinned apply transaction so SQLite rebuilds cannot drop omitted constraints + and runtime dependency certificates cannot mistake `SET DEFAULT` for `Restrict`. +- Preserve a single-connection SQLite in-memory database after a successful owned + rebuild; discard suspended connections on cancellation or failed restoration. - Respect SQLite composite primary-key ordinal order during introspection. - Reject legacy epoch-second DateTime default conversion with scoped structured diagnostics while preserving static storage and supported Integer defaults; diff --git a/MIGRATION.md b/MIGRATION.md index a8dd8a7b..ae034cad 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -3,7 +3,7 @@ title: "Migration Guide" kind: reference status: active owner: workspace-maintainers -last_reviewed: 2026-09-30 +last_reviewed: 2026-10-01 review_by: 2027-02-01 supersedes: [] --- @@ -29,6 +29,15 @@ Owned application does not reconcile PostgreSQL RLS. Unsupported backends fail before connection acquisition; unsupported live preservation cases return structured rejections. See [owned runtime migrations](docs/reference/graphql-orm/runtime-migrations.md). +Owned runtime operations reject unsupported live FK semantics with a scoped +`UnsupportedForeignKey` diagnostic. Use explicit delete `RESTRICT`, `CASCADE` or +`SET NULL`, default update `NO ACTION` and non-deferrable constraints in the initial +profile. Delete `SET DEFAULT`/`NO ACTION`, nondefault update actions and deferral +cannot be faithfully represented by the legacy model. Relevant system/unowned +incoming constraints are checked, including on the pinned apply transaction; +unrelated tables are left intact. Static migration APIs/storage behavior are +unchanged. See the reference for additional PostgreSQL scope/match limitations. + The physical dependency environment is not a record-write API or an authorization certificate. Hosts must fence external DDL and validate their complete public/policy revision before future mutations; ORM fingerprints exclude host policy revisions. diff --git a/crates/graphql-orm/README.md b/crates/graphql-orm/README.md index 5b2fd694..8f949cfd 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -3,7 +3,7 @@ title: "graphql-orm" kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-09-30 +last_reviewed: 2026-10-01 review_by: 2027-02-01 supersedes: [] --- @@ -190,3 +190,6 @@ Ordinary `GraphQLEntity` aggregate SDL remains unchanged. Owned runtime targets, explicit table ownership, read-only plans and guarded apply are documented in [runtime migrations](../../docs/reference/graphql-orm/runtime-migrations.md). +Live capability checks reject FK actions/deferral the physical model cannot retain, +including incoming system/unowned constraints, and recheck on the apply transaction. +Static migration APIs remain unchanged. diff --git a/crates/graphql-orm/src/graphql/orm/execution.rs b/crates/graphql-orm/src/graphql/orm/execution.rs index a801403f..80221e44 100644 --- a/crates/graphql-orm/src/graphql/orm/execution.rs +++ b/crates/graphql-orm/src/graphql/orm/execution.rs @@ -744,6 +744,35 @@ async fn cleanup_stale_sqlite_rewrite_tables(pool: &sqlx::SqlitePool) -> crate:: Ok(()) } +// Own the lease so cancellation can mark its connection for discard. Successful +// restoration disarms the guard synchronously, retaining single-connection memory DBs. +#[cfg(feature = "sqlite")] +struct SqliteMigrationConnection { + connection: sqlx::pool::PoolConnection, + close_if_unrestored: bool, +} +#[cfg(feature = "sqlite")] +impl std::ops::Deref for SqliteMigrationConnection { + type Target = sqlx::SqliteConnection; + fn deref(&self) -> &Self::Target { + &self.connection + } +} +#[cfg(feature = "sqlite")] +impl std::ops::DerefMut for SqliteMigrationConnection { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.connection + } +} +#[cfg(feature = "sqlite")] +impl Drop for SqliteMigrationConnection { + fn drop(&mut self) { + if self.close_if_unrestored { + self.connection.close_on_drop(); + } + } +} + #[cfg(feature = "sqlite")] async fn apply_sqlite_migration_statements_transactionally( pool: &sqlx::SqlitePool, @@ -757,9 +786,12 @@ async fn apply_sqlite_migration_statements_transactionally( where S: AsRef, { - use sqlx::{Acquire, Connection}; + use sqlx::Connection; - let mut conn = pool.acquire().await?; + let mut conn = SqliteMigrationConnection { + connection: pool.acquire().await?, + close_if_unrestored: false, + }; let suspend_foreign_keys = statements.iter().any(|statement| { statement .as_ref() @@ -769,7 +801,7 @@ where if suspend_foreign_keys { if owned.is_some() { - conn.close_on_drop(); + conn.close_if_unrestored = true; } sqlx::query("PRAGMA foreign_keys = OFF") .execute(&mut *conn) @@ -842,6 +874,17 @@ where sqlx::query("PRAGMA foreign_keys = ON") .execute(&mut *conn) .await?; + if owned.is_some() { + let enabled: i64 = sqlx::query_scalar("PRAGMA foreign_keys") + .fetch_one(&mut *conn) + .await?; + if enabled != 1 { + return Err( + sqlx::Error::Protocol("SQLite FK enforcement was not restored".into()).into(), + ); + } + } + conn.close_if_unrestored = false; } final_result @@ -1166,6 +1209,7 @@ pub(super) async fn validate_owned_sqlite_plan( connection: &mut sqlx::SqliteConnection, plan: &OwnedPlannedMigration, ) -> Result<(), RuntimeMigrationError> { + super::owned_foreign_keys::validate_sqlite(connection, plan.ownership()).await?; use super::runtime_migration::{RuntimeMigrationDiagnosticCode, rejection}; use sqlx::Row; // The canonical index model has no expression or collation storage. Never @@ -1271,6 +1315,7 @@ pub(super) async fn validate_owned_postgres_plan( connection: &mut sqlx::PgConnection, plan: &OwnedPlannedMigration, ) -> Result<(), RuntimeMigrationError> { + super::owned_foreign_keys::validate_postgres(connection, plan.ownership()).await?; use super::runtime_migration::{RuntimeMigrationDiagnosticCode, rejection}; for table in plan.ownership().tables() { let indexes: Vec<(bool, String, Option, bool)> = sqlx::query_as( @@ -1308,3 +1353,43 @@ pub(super) async fn validate_owned_postgres_plan( } Ok(()) } + +#[cfg(all(test, feature = "sqlite"))] +mod sqlite_migration_connection_tests { + use super::SqliteMigrationConnection; + + #[tokio::test] + async fn canceled_fk_suspension_does_not_return_disabled_enforcement_to_pool() { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + let (staged_tx, staged_rx) = tokio::sync::oneshot::channel(); + let task_pool = pool.clone(); + let task = tokio::spawn(async move { + let mut guard = SqliteMigrationConnection { + connection: task_pool.acquire().await.unwrap(), + close_if_unrestored: true, + }; + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&mut *guard) + .await + .unwrap(); + staged_tx.send(()).unwrap(); + std::future::pending::<()>().await; + }); + staged_rx.await.unwrap(); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + let enabled: i64 = sqlx::query_scalar("PRAGMA foreign_keys") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + enabled, 1, + "canceled suspended connections must be discarded" + ); + pool.close().await; + } +} diff --git a/crates/graphql-orm/src/graphql/orm/mod.rs b/crates/graphql-orm/src/graphql/orm/mod.rs index 52a46de6..d03887d2 100644 --- a/crates/graphql-orm/src/graphql/orm/mod.rs +++ b/crates/graphql-orm/src/graphql/orm/mod.rs @@ -13,6 +13,7 @@ mod lease; #[cfg(any(feature = "sqlite", feature = "postgres", feature = "mssql"))] mod migrations; mod operation_metadata; +mod owned_foreign_keys; mod owned_schema; mod query; mod rls; diff --git a/crates/graphql-orm/src/graphql/orm/owned_foreign_keys.rs b/crates/graphql-orm/src/graphql/orm/owned_foreign_keys.rs new file mode 100644 index 00000000..e9f73b57 --- /dev/null +++ b/crates/graphql-orm/src/graphql/orm/owned_foreign_keys.rs @@ -0,0 +1,174 @@ +//! Capability checks supplement the legacy physical model without changing its public layout. +//! Never let lossy legacy FK introspection authorize owned replacement or certify mutations. +#[cfg(any(feature = "sqlite", feature = "postgres"))] +use super::runtime_migration::{ + ManagedTableSet, RuntimeMigrationDiagnostic, RuntimeMigrationDiagnosticCode, + RuntimeMigrationDiagnostics, RuntimeMigrationError, +}; + +#[cfg(any(feature = "sqlite", feature = "postgres"))] +fn unsupported(table: String, subject: String) -> RuntimeMigrationError { + RuntimeMigrationDiagnostics(vec![RuntimeMigrationDiagnostic { + code: RuntimeMigrationDiagnosticCode::UnsupportedForeignKey, + // This is a physical catalog source, potentially absent from the runtime schema. + collection: None, + subject: Some(format!("{table}.{subject}")), + }]) + .into() +} + +#[cfg(feature = "sqlite")] +pub(super) async fn validate_sqlite( + connection: &mut sqlx::SqliteConnection, + ownership: &ManagedTableSet, +) -> Result<(), RuntimeMigrationError> { + use sqlx::Row; + // No reserved-name filter: system/ORM tables can carry incoming modifying actions. + let rows = sqlx::query( + "SELECT m.name, m.sql, f.id, f.\"from\" AS source_column, + f.\"table\" AS target_table, f.on_delete, f.on_update + FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) f + WHERE m.type = 'table' ORDER BY m.name, f.id, f.seq", + ) + .fetch_all(&mut *connection) + .await?; + for row in rows { + let source: String = row.try_get("name")?; + let target: String = row.try_get("target_table")?; + let touches_owned = ownership.tables().any(|table| { + table.eq_ignore_ascii_case(&source) || table.eq_ignore_ascii_case(&target) + }); + if !touches_owned { + continue; + } + let delete: String = row.try_get("on_delete")?; + let update: String = row.try_get("on_update")?; + let sql: String = row.try_get("sql")?; + // NO ACTION deletion is also distinct from the model's RESTRICT timing. + // Update NO ACTION is what canonical CREATE TABLE emits by omission. + if !matches!(delete.as_str(), "RESTRICT" | "CASCADE" | "SET NULL") + || update != "NO ACTION" + || ownership.tables().any(|table| { + (table.eq_ignore_ascii_case(&source) && table != source) + || (table.eq_ignore_ascii_case(&target) && table != target) + }) + || has_deferrable_clause(&sql) + { + return Err(unsupported(source, row.try_get("source_column")?)); + } + } + Ok(()) +} + +/// Extract only unquoted SQL words. Literals, quoted identifiers and comments cannot +/// masquerade as deferral clauses. SQLite doesn't expose deferral in its FK PRAGMA. +#[cfg(feature = "sqlite")] +fn has_deferrable_clause(sql: &str) -> bool { + let mut chars = sql.chars().peekable(); + let mut previous = String::new(); + while let Some(character) = chars.next() { + match character { + '\'' | '"' | '`' | '[' => { + let close = if character == '[' { ']' } else { character }; + while let Some(next) = chars.next() { + if next == close { + if close != ']' && chars.peek() == Some(&close) { + chars.next(); + } else { + break; + } + } + } + previous.clear(); + } + '-' if chars.peek() == Some(&'-') => { + chars.next(); + for next in chars.by_ref() { + if next == '\n' { + break; + } + } + } + '/' if chars.peek() == Some(&'*') => { + chars.next(); + while let Some(next) = chars.next() { + if next == '*' && chars.peek() == Some(&'/') { + chars.next(); + break; + } + } + } + first if first.is_ascii_alphabetic() || first == '_' => { + let mut word = first.to_string(); + while let Some(next) = chars.next_if(|c| c.is_ascii_alphanumeric() || *c == '_') { + word.push(next); + } + if word.eq_ignore_ascii_case("deferrable") && !previous.eq_ignore_ascii_case("not") + { + return true; + } + previous = word; + } + c if !c.is_whitespace() => previous.clear(), + _ => {} + } + } + false +} + +#[cfg(feature = "postgres")] +pub(super) async fn validate_postgres( + connection: &mut sqlx::PgConnection, + ownership: &ManagedTableSet, +) -> Result<(), RuntimeMigrationError> { + // pg_catalog includes incoming constraints from other schemas and reserved/system + // sources. information_schema may omit constraints and does not expose every action. + let tables: Vec<&str> = ownership.tables().collect(); + let unsupported: Option<(String, String)> = sqlx::query_as( + "SELECT sn.nspname || '.' || s.relname, con.conname + FROM pg_constraint con + JOIN pg_class s ON s.oid = con.conrelid + JOIN pg_namespace sn ON sn.oid = s.relnamespace + JOIN pg_class t ON t.oid = con.confrelid + JOIN pg_namespace tn ON tn.oid = t.relnamespace + WHERE con.contype = 'f' + AND ((sn.nspname = current_schema() AND s.relname = ANY($1)) + OR (tn.nspname = current_schema() AND t.relname = ANY($1))) + AND (con.confdeltype NOT IN ('r', 'c', 'n') OR con.confupdtype <> 'a' + OR con.condeferrable OR con.condeferred OR con.confmatchtype <> 's' + OR NOT con.convalidated + OR COALESCE(to_jsonb(con)->>'confdelsetcols', '') <> '' + OR sn.nspname <> current_schema() OR tn.nspname <> current_schema()) + ORDER BY sn.nspname, s.relname, con.conname LIMIT 1", + ) + .bind(tables) + .fetch_optional(&mut *connection) + .await?; + if let Some((table, constraint)) = unsupported { + return Err(self::unsupported(table, constraint)); + } + Ok(()) +} + +#[cfg(all(test, feature = "sqlite"))] +mod tests { + use super::has_deferrable_clause; + #[test] + fn sqlite_deferral_scan_ignores_literals_identifiers_and_comments() { + assert!(has_deferrable_clause( + "REFERENCES t(id) DEFERRABLE INITIALLY DEFERRED" + )); + assert!(has_deferrable_clause( + "REFERENCES t(id) deferrable initially immediate" + )); + assert!(!has_deferrable_clause( + "REFERENCES t(id) NOT /* comment */ DEFERRABLE" + )); + assert!(!has_deferrable_clause( + "'DEFERRABLE' \"DEFERRABLE\" [DEFERRABLE] `DEFERRABLE` -- DEFERRABLE\n /* DEFERRABLE */" + )); + assert!(!has_deferrable_clause( + "'can''t DEFERRABLE' \"a\"\"DEFERRABLE\"" + )); + } +} diff --git a/crates/graphql-orm/src/graphql/orm/runtime_migration.rs b/crates/graphql-orm/src/graphql/orm/runtime_migration.rs index 3f7406f6..857392cd 100644 --- a/crates/graphql-orm/src/graphql/orm/runtime_migration.rs +++ b/crates/graphql-orm/src/graphql/orm/runtime_migration.rs @@ -30,6 +30,8 @@ pub enum RuntimeMigrationDiagnosticCode { UnsupportedGeneration, /// Default semantics cannot be represented faithfully. UnsupportedDefault, + /// Live foreign-key semantics cannot be represented without changing behavior. + UnsupportedForeignKey, /// Invalid or unsupported canonical physical definition. InvalidPhysicalContract, /// Physical or diagnostic names collide across composed targets. @@ -808,6 +810,15 @@ pub trait RuntimeMigrationBackend: OrmBackend { let _ = pool; Err(rejection(RuntimeMigrationDiagnosticCode::UnsupportedBackend).into()) } + /// Validate complete live FK semantics, including external and internal incoming sources. + #[doc(hidden)] + async fn validate_owned_dependencies( + pool: &Self::Pool, + ownership: &ManagedTableSet, + ) -> Result<(), RuntimeMigrationError> { + let _ = (pool, ownership); + Err(rejection(RuntimeMigrationDiagnosticCode::UnsupportedBackend).into()) + } /// Reject live semantics that cannot be safely preserved by owned application. #[doc(hidden)] async fn validate_owned_plan( @@ -866,6 +877,13 @@ impl RuntimeMigrationBackend for super::SqliteBackend { .await?, ) } + async fn validate_owned_dependencies( + pool: &Self::Pool, + ownership: &ManagedTableSet, + ) -> Result<(), RuntimeMigrationError> { + let mut connection = pool.acquire().await?; + super::owned_foreign_keys::validate_sqlite(&mut connection, ownership).await + } async fn validate_owned_plan( pool: &Self::Pool, plan: &OwnedPlannedMigration, @@ -903,6 +921,13 @@ impl RuntimeMigrationBackend for super::PostgresBackend { .await?, ) } + async fn validate_owned_dependencies( + pool: &Self::Pool, + ownership: &ManagedTableSet, + ) -> Result<(), RuntimeMigrationError> { + let mut connection = pool.acquire().await?; + super::owned_foreign_keys::validate_postgres(&mut connection, ownership).await + } async fn validate_owned_plan( pool: &Self::Pool, plan: &OwnedPlannedMigration, @@ -934,6 +959,7 @@ impl<'db, B: RuntimeMigrationBackend> SchemaManager<'db, B> { target .validate_physical_contract() .map_err(|_| rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract))?; + B::validate_owned_dependencies(self.database.pool(), ownership).await?; let current = B::introspect_owned(self.database.pool()).await?; validate_owned_effects(B::DIALECT, ¤t, target, ownership)?; let scoped = scoped_current(¤t, ownership, PlanOptions::strict(), target); @@ -966,6 +992,7 @@ impl<'db, B: RuntimeMigrationBackend> SchemaManager<'db, B> { if version.trim().is_empty() || version.len() > 256 || description.len() > 4096 { return Err(rejection(RuntimeMigrationDiagnosticCode::LimitExceeded).into()); } + B::validate_owned_dependencies(self.database.pool(), ownership).await?; let current = B::introspect_owned(self.database.pool()).await?; validate_owned_effects(B::DIALECT, ¤t, target, ownership)?; let scoped = scoped_current(¤t, ownership, options, target); @@ -1039,6 +1066,7 @@ impl<'db, B: RuntimeMigrationBackend> SchemaManager<'db, B> { target .validate_physical_contract() .map_err(|_| rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract))?; + B::validate_owned_dependencies(self.database.pool(), ownership).await?; let current = B::introspect_owned(self.database.pool()).await?; validate_owned_effects(B::DIALECT, ¤t, target, ownership)?; let scoped = scoped_current(¤t, ownership, PlanOptions::strict(), target); diff --git a/crates/graphql-orm/tests/legacy_migration_history.rs b/crates/graphql-orm/tests/legacy_migration_history.rs index 00652430..8fb7720e 100644 --- a/crates/graphql-orm/tests/legacy_migration_history.rs +++ b/crates/graphql-orm/tests/legacy_migration_history.rs @@ -2,6 +2,10 @@ use graphql_orm::prelude::*; +#[cfg(feature = "postgres")] +#[path = "support/owned_postgres.rs"] +mod owned_postgres; + #[derive(GraphQLSchemaEntity, Clone, Debug)] #[graphql_entity(table = "legacy_history_items", plural = "LegacyHistoryItems")] #[allow(dead_code)] @@ -212,10 +216,8 @@ async fn sqlite_legacy_adoption_preserves_recorded_version_drift_protection() #[tokio::test] async fn postgres_legacy_history_adopts_idempotently_and_malformed_identity_fails_closed() -> Result<(), Box> { - let Ok(url) = std::env::var("TEST_DATABASE_URL") else { - return Ok(()); - }; - let database = Database::::connect_postgres(url).await?; + let mut owned = owned_postgres::OwnedPostgres::start("legacy-migration-history")?; + let database = Database::::connect_postgres(&owned.url).await?; graphql_orm::sqlx::query("DROP TABLE IF EXISTS legacy_history_items CASCADE") .execute(database.pool()) .await?; @@ -297,5 +299,7 @@ async fn postgres_legacy_history_adopts_idempotently_and_malformed_identity_fail graphql_orm::sqlx::query("DROP TABLE __graphql_orm_migrations CASCADE") .execute(database.pool()) .await?; + database.pool().close().await; + owned.cleanup()?; Ok(()) } diff --git a/crates/graphql-orm/tests/migration_apply.rs b/crates/graphql-orm/tests/migration_apply.rs index 13e54232..5fabe331 100644 --- a/crates/graphql-orm/tests/migration_apply.rs +++ b/crates/graphql-orm/tests/migration_apply.rs @@ -1,3 +1,7 @@ +#[cfg(feature = "postgres")] +#[path = "support/owned_postgres.rs"] +mod owned_postgres; + #[cfg(feature = "sqlite")] use graphql_orm::graphql::orm::DeletePolicy; #[cfg(feature = "sqlite")] @@ -278,10 +282,8 @@ async fn sqlite_migration_runner_rolls_back_failed_rewrite() #[cfg(feature = "postgres")] #[tokio::test] async fn postgres_migration_runner_applies_plan() -> Result<(), Box> { - let database_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| { - "postgres://graphql_orm:graphql_orm@127.0.0.1:55433/graphql_orm_test".to_string() - }); - let pool = sqlx::PgPool::connect(&database_url).await?; + let mut owned = owned_postgres::OwnedPostgres::start("legacy-migration-apply")?; + let pool = sqlx::PgPool::connect(&owned.url).await?; let suffix = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_nanos(); @@ -366,6 +368,9 @@ async fn postgres_migration_runner_applies_plan() -> Result<(), Box Result<(), Box Result<(), Box> { - let database_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| { - "postgres://graphql_orm:graphql_orm@127.0.0.1:55433/graphql_orm_test".to_string() - }); - let pool = sqlx::PgPool::connect(&database_url).await?; + let mut owned = owned_postgres::OwnedPostgres::start("legacy-migration-apply")?; + let pool = sqlx::PgPool::connect(&owned.url).await?; let table_name = format!( "rollback_users_{}", std::time::SystemTime::now() @@ -406,6 +409,9 @@ async fn postgres_migration_runner_rolls_back_failed_migration() assert!(row.is_none()); assert_eq!(postgres_history_count(&pool, &version_prefix).await?, 0); + pool.close().await; + owned.cleanup()?; + Ok(()) } diff --git a/crates/graphql-orm/tests/runtime_migrations.rs b/crates/graphql-orm/tests/runtime_migrations.rs index c60025b7..687da0c6 100644 --- a/crates/graphql-orm/tests/runtime_migrations.rs +++ b/crates/graphql-orm/tests/runtime_migrations.rs @@ -1205,3 +1205,557 @@ fn static_quoted_literal_defaults_convert_losslessly() { == RuntimeSchemaDiagnosticCode::UnsupportedDefault && d.subject.as_deref() == Some("label"))); } + +#[derive(GraphQLSchemaEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[cfg_attr( + feature = "sqlite", + graphql_entity(backend = "sqlite", table = "fk_sources", plural = "FkSources") +)] +#[cfg_attr( + all(feature = "postgres", not(feature = "sqlite")), + graphql_entity(backend = "postgres", table = "fk_sources", plural = "FkSources") +)] +struct FkSource { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + parent_id: String, + label: String, +} + +fn fk_target() -> OwnedSchemaModel { + let mut metadata = FkSource::metadata().clone(); + metadata.relations = vec![RelationMetadata { + field_name: "parent", + target_type: "owned_notes", + source_column: "parent_id", + target_column: "id", + source_columns: &["parent_id"], + target_columns: &["id"], + is_multiple: false, + emit_foreign_key: true, + on_delete: DeletePolicy::Restrict, + propagate_change: RelationChangePropagation::None, + search_fields: None, + }] + .into_boxed_slice(); + let mut model = SchemaModel::from_entities(&[Note::metadata(), &metadata]); + model + .tables + .iter_mut() + .find(|t| t.table_name == "fk_sources") + .unwrap() + .columns + .iter_mut() + .find(|c| c.name == "parent_id") + .unwrap() + .default = Some("'fallback'".into()); + // This unrelated column alteration requires a SQLite source-table rebuild. + model + .tables + .iter_mut() + .find(|t| t.table_name == "fk_sources") + .unwrap() + .columns + .iter_mut() + .find(|c| c.name == "label") + .unwrap() + .nullable = true; + OwnedSchemaModel::from(&model) +} + +fn rejected_fk(error: RuntimeMigrationError, source: &str) { + match error { + RuntimeMigrationError::Diagnostics(d) => { + assert_eq!( + d.0[0].code, + RuntimeMigrationDiagnosticCode::UnsupportedForeignKey + ); + assert!(d.0[0].subject.as_ref().unwrap().contains(source), "{d:?}"); + assert!(d.0[0].collection.is_none()); + } + other => panic!("expected structured FK rejection, got {other:?}"), + } +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sqlite_owned_fk_semantics_reject_rebuild_and_incoming_certification() +-> Result<(), Box> { + for clause in [ + "ON DELETE SET DEFAULT", + "ON DELETE RESTRICT ON UPDATE CASCADE", + "ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED", + "ON DELETE NO ACTION", + ] { + let db = sqlite().await; + let manager = db.schema(); + let parent = target::(); + let plan = manager + .plan_owned_migration( + "parent", + "fixture", + &parent, + &ownership(), + PlanOptions::strict(), + ) + .await?; + manager + .apply_owned_migration(&plan, Default::default()) + .await?; + let create = format!( + "CREATE TABLE fk_sources(id TEXT PRIMARY KEY NOT NULL, parent_id TEXT NOT NULL DEFAULT 'fallback', label TEXT NOT NULL, FOREIGN KEY(parent_id) REFERENCES owned_notes(id) {clause})" + ); + sqlx::query(&create).execute(db.pool()).await?; + sqlx::query("CREATE INDEX idx_fk_sources_parent_id ON fk_sources(parent_id)") + .execute(db.pool()) + .await?; + sqlx::query( + "INSERT INTO owned_notes(id,label) VALUES('parent','parent'),('fallback','fallback')", + ) + .execute(db.pool()) + .await?; + sqlx::query( + "INSERT INTO fk_sources(id,parent_id,label) VALUES('child','parent','history')", + ) + .execute(db.pool()) + .await?; + let before: String = + sqlx::query_scalar("SELECT sql FROM sqlite_master WHERE name='fk_sources'") + .fetch_one(db.pool()) + .await?; + let managed = ManagedTableSet::new(["owned_notes".into(), "fk_sources".into()])?; + rejected_fk( + manager + .plan_owned_migration( + "rebuild", + "change label only", + &fk_target(), + &managed, + PlanOptions::strict(), + ) + .await + .unwrap_err(), + "fk_sources", + ); + rejected_fk( + manager + .validate_owned_schema(&fk_target(), &managed) + .await + .unwrap_err(), + "fk_sources", + ); + // The source is now unowned, yet its incoming modifying/deferrable FK must + // still prevent an unsafe mutation certificate for the runtime parent. + rejected_fk( + manager + .runtime_mutation_environment(std::sync::Arc::new(schema()), &parent, &ownership()) + .await + .unwrap_err(), + "fk_sources", + ); + let after: String = + sqlx::query_scalar("SELECT sql FROM sqlite_master WHERE name='fk_sources'") + .fetch_one(db.pool()) + .await?; + assert_eq!(before, after); + let value: String = sqlx::query_scalar("SELECT label FROM fk_sources WHERE id='child'") + .fetch_one(db.pool()) + .await?; + assert_eq!(value, "history"); + // Demonstrate the constraint semantics the ORM refused to reinterpret. + if clause.contains("UPDATE CASCADE") { + sqlx::query("UPDATE owned_notes SET id='moved' WHERE id='parent'") + .execute(db.pool()) + .await?; + let value: String = sqlx::query_scalar("SELECT parent_id FROM fk_sources") + .fetch_one(db.pool()) + .await?; + assert_eq!(value, "moved"); + } else if clause.contains("SET DEFAULT") { + sqlx::query("DELETE FROM owned_notes WHERE id='parent'") + .execute(db.pool()) + .await?; + let value: String = sqlx::query_scalar("SELECT parent_id FROM fk_sources") + .fetch_one(db.pool()) + .await?; + assert_eq!(value, "fallback"); + } else if clause.contains("DEFERRED") { + let mut tx = db.pool().begin().await?; + sqlx::query("UPDATE fk_sources SET parent_id='later'") + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO owned_notes(id,label) VALUES('later','later')") + .execute(&mut *tx) + .await?; + tx.commit().await?; + } + } + Ok(()) +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sqlite_owned_apply_rechecks_reserved_incoming_fk_inside_transaction() +-> Result<(), Box> { + let db = sqlite().await; + let manager = db.schema(); + let parent = target::(); + let plan = manager + .plan_owned_migration( + "parent", + "fixture", + &parent, + &ownership(), + PlanOptions::strict(), + ) + .await?; + manager + .apply_owned_migration(&plan, Default::default()) + .await?; + let mut upgraded = schema().schema().clone(); + let mut extra = upgraded.collections[0].fields[3].clone(); + extra.id = FieldId::new("extra")?; + extra.api_name = "extra".into(); + extra.physical_column = "extra".into(); + upgraded.collections[0].fields.push(extra); + let upgraded = upgraded + .validate()? + .physical_schema::(RuntimeMigrationLimits::default())?; + sqlx::query("CREATE TABLE unrelated_parent(id TEXT PRIMARY KEY)") + .execute(db.pool()) + .await?; + sqlx::query("CREATE TABLE unrelated_child(id TEXT PRIMARY KEY, parent_id TEXT REFERENCES unrelated_parent(id) ON DELETE SET DEFAULT DEFERRABLE INITIALLY DEFERRED)").execute(db.pool()).await?; + // Unrelated unsupported FKs remain untouched, and cannot block an owned no-op. + assert!( + manager + .plan_owned_migration( + "unrelated", + "bounded scope", + &parent, + &ownership(), + PlanOptions::strict() + ) + .await? + .steps() + .is_empty() + ); + // Replan to bind the unrelated tables, which still belong to the full baseline. + let plan = manager + .plan_owned_migration( + "upgrade", + "add field", + &upgraded, + &ownership(), + PlanOptions::strict(), + ) + .await?; + // Reserved sources are omitted from the legacy baseline hash. Only the + // complete live capability check on the pinned apply connection can catch this. + for clause in [ + "ON DELETE SET DEFAULT", + "ON DELETE RESTRICT ON UPDATE CASCADE", + "ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED", + ] { + sqlx::query(&format!("CREATE TABLE __graphql_orm_external_link(id TEXT PRIMARY KEY NOT NULL, parent_id TEXT DEFAULT 'fallback' REFERENCES owned_notes(id) {clause})")).execute(db.pool()).await?; + rejected_fk( + manager + .apply_owned_migration(&plan, Default::default()) + .await + .unwrap_err(), + "__graphql_orm_external_link", + ); + let columns: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pragma_table_info('owned_notes') WHERE name='extra'", + ) + .fetch_one(db.pool()) + .await?; + assert_eq!(columns, 0); + let history: i64 = sqlx::query_scalar( + "SELECT count(*) FROM __graphql_orm_migrations WHERE version='upgrade'", + ) + .fetch_one(db.pool()) + .await?; + assert_eq!(history, 0); + sqlx::query("DROP TABLE __graphql_orm_external_link") + .execute(db.pool()) + .await?; + } + manager + .apply_owned_migration(&plan, Default::default()) + .await?; + assert!( + manager + .plan_owned_migration( + "noop", + "replan", + &upgraded, + &ownership(), + PlanOptions::strict() + ) + .await? + .steps() + .is_empty() + ); + Ok(()) +} + +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] +#[tokio::test] +async fn postgres_owned_fk_semantics_and_reserved_apply_recheck() +-> Result<(), Box> { + let mut owned = owned_postgres::OwnedPostgres::start("owned-fk-semantics")?; + let db = Database::::connect_postgres(&owned.url).await?; + let manager = db.schema(); + let parent = target::(); + let plan = manager + .plan_owned_migration( + "parent", + "fixture", + &parent, + &ownership(), + PlanOptions::strict(), + ) + .await?; + manager + .apply_owned_migration(&plan, Default::default()) + .await?; + let managed = ManagedTableSet::new(["owned_notes".into(), "fk_sources".into()])?; + for clause in [ + "ON DELETE SET DEFAULT", + "ON DELETE RESTRICT ON UPDATE CASCADE", + "ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED", + "ON DELETE NO ACTION", + ] { + sqlx::query(&format!("CREATE TABLE fk_sources(id TEXT PRIMARY KEY NOT NULL, parent_id TEXT NOT NULL DEFAULT 'fallback', label TEXT NOT NULL, FOREIGN KEY(parent_id) REFERENCES owned_notes(id) {clause})")).execute(db.pool()).await?; + sqlx::query( + "INSERT INTO owned_notes(id,label) VALUES('parent','parent'),('fallback','fallback')", + ) + .execute(db.pool()) + .await?; + sqlx::query( + "INSERT INTO fk_sources(id,parent_id,label) VALUES('child','parent','history')", + ) + .execute(db.pool()) + .await?; + let before: String = sqlx::query_scalar("SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid='fk_sources'::regclass AND contype='f'").fetch_one(db.pool()).await?; + rejected_fk( + manager + .plan_owned_migration( + "change", + "change label", + &fk_target(), + &managed, + PlanOptions::strict(), + ) + .await + .unwrap_err(), + "fk_sources", + ); + rejected_fk( + manager + .validate_owned_schema(&fk_target(), &managed) + .await + .unwrap_err(), + "fk_sources", + ); + rejected_fk( + manager + .runtime_mutation_environment(std::sync::Arc::new(schema()), &parent, &ownership()) + .await + .unwrap_err(), + "fk_sources", + ); + let after: String = sqlx::query_scalar("SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid='fk_sources'::regclass AND contype='f'").fetch_one(db.pool()).await?; + assert_eq!(before, after); + // Demonstrate the constraint semantics the ORM refused to reinterpret. + if clause.contains("UPDATE CASCADE") { + sqlx::query("UPDATE owned_notes SET id='moved' WHERE id='parent'") + .execute(db.pool()) + .await?; + let value: String = sqlx::query_scalar("SELECT parent_id FROM fk_sources") + .fetch_one(db.pool()) + .await?; + assert_eq!(value, "moved"); + } else if clause.contains("SET DEFAULT") { + sqlx::query("DELETE FROM owned_notes WHERE id='parent'") + .execute(db.pool()) + .await?; + let value: String = sqlx::query_scalar("SELECT parent_id FROM fk_sources") + .fetch_one(db.pool()) + .await?; + assert_eq!(value, "fallback"); + } else if clause.contains("DEFERRED") { + let mut tx = db.pool().begin().await?; + sqlx::query("UPDATE fk_sources SET parent_id='later'") + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO owned_notes(id,label) VALUES('later','later')") + .execute(&mut *tx) + .await?; + tx.commit().await?; + } + sqlx::query("DROP TABLE fk_sources") + .execute(db.pool()) + .await?; + sqlx::query("DELETE FROM owned_notes") + .execute(db.pool()) + .await?; + } + let mut upgraded = schema().schema().clone(); + let mut extra = upgraded.collections[0].fields[3].clone(); + extra.id = FieldId::new("extra")?; + extra.api_name = "extra".into(); + extra.physical_column = "extra".into(); + upgraded.collections[0].fields.push(extra); + let upgraded = upgraded + .validate()? + .physical_schema::(RuntimeMigrationLimits::default())?; + let plan = manager + .plan_owned_migration( + "upgrade", + "add field", + &upgraded, + &ownership(), + PlanOptions::strict(), + ) + .await?; + for clause in [ + "ON DELETE SET DEFAULT", + "ON DELETE RESTRICT ON UPDATE CASCADE", + "ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED", + ] { + sqlx::query(&format!("CREATE TABLE __graphql_orm_external_link(id TEXT PRIMARY KEY NOT NULL, parent_id TEXT DEFAULT 'fallback' REFERENCES owned_notes(id) {clause})")).execute(db.pool()).await?; + rejected_fk( + manager + .apply_owned_migration(&plan, Default::default()) + .await + .unwrap_err(), + "__graphql_orm_external_link", + ); + let columns: i64 = sqlx::query_scalar("SELECT count(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='owned_notes' AND column_name='extra'").fetch_one(db.pool()).await?; + assert_eq!(columns, 0); + let history: i64 = sqlx::query_scalar( + "SELECT count(*) FROM __graphql_orm_migrations WHERE version='upgrade'", + ) + .fetch_one(db.pool()) + .await?; + assert_eq!(history, 0); + sqlx::query("DROP TABLE __graphql_orm_external_link") + .execute(db.pool()) + .await?; + } + // Cross-schema incoming sources are visible in pg_catalog and cannot be + // certified by the schema-local canonical dependency model. + sqlx::query("CREATE SCHEMA external_owner") + .execute(db.pool()) + .await?; + sqlx::query("CREATE TABLE external_owner.links(id TEXT PRIMARY KEY, parent_id TEXT REFERENCES owned_notes(id) ON DELETE RESTRICT)").execute(db.pool()).await?; + rejected_fk( + manager + .runtime_mutation_environment(std::sync::Arc::new(schema()), &parent, &ownership()) + .await + .unwrap_err(), + "external_owner.links", + ); + sqlx::query("DROP SCHEMA external_owner CASCADE") + .execute(db.pool()) + .await?; + manager + .apply_owned_migration(&plan, Default::default()) + .await?; + db.pool().close().await; + owned.cleanup()?; + Ok(()) +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sqlite_successful_owned_rebuild_retains_memory_database_and_enforcement() +-> Result<(), Box> { + let db = sqlite().await; + let manager = db.schema(); + let initial = target::(); + let plan = manager + .plan_owned_migration( + "initial", + "fixture", + &initial, + &ownership(), + PlanOptions::strict(), + ) + .await?; + manager + .apply_owned_migration(&plan, Default::default()) + .await?; + sqlx::query("INSERT INTO owned_notes(id,label) VALUES('retained','history')") + .execute(db.pool()) + .await?; + let mut definition = schema().schema().clone(); + definition.collections[0].fields[1].nullable = true; + let updated = definition + .validate()? + .physical_schema::(RuntimeMigrationLimits::default())?; + let plan = manager + .plan_owned_migration( + "rebuild", + "nullable label only", + &updated, + &ownership(), + PlanOptions::strict(), + ) + .await?; + assert!( + plan.statements() + .iter() + .any(|sql| sql.starts_with("PRAGMA foreign_keys = OFF")) + ); + manager + .apply_owned_migration(&plan, Default::default()) + .await?; + let retained: (String, i64) = + sqlx::query_as("SELECT label,score FROM owned_notes WHERE id='retained'") + .fetch_one(db.pool()) + .await?; + assert_eq!(retained, ("history".into(), 7)); + let enabled: i64 = sqlx::query_scalar("PRAGMA foreign_keys") + .fetch_one(db.pool()) + .await?; + assert_eq!(enabled, 1); + assert!( + manager + .plan_owned_migration( + "noop", + "runtime", + &updated, + &ownership(), + PlanOptions::strict() + ) + .await? + .steps() + .is_empty() + ); + let mut static_target = SchemaModel::from_entities(&[Note::metadata()]); + static_target.tables[0] + .columns + .iter_mut() + .find(|field| field.name == "label") + .unwrap() + .nullable = true; + assert!( + manager + .plan_schema_target( + "noop-static", + "static", + &SchemaTarget { + schema: static_target, + rls: RlsSchemaModel { entities: vec![] }, + } + ) + .await? + .migration + .steps + .is_empty() + ); + Ok(()) +} diff --git a/docs/plans/active/runtime-platform-capabilities/README.md b/docs/plans/active/runtime-platform-capabilities/README.md index 7b334100..6f28030e 100644 --- a/docs/plans/active/runtime-platform-capabilities/README.md +++ b/docs/plans/active/runtime-platform-capabilities/README.md @@ -1116,13 +1116,27 @@ is advertised until its contract is committed and reviewed. ## Current checkpoint -Proposal prepared against the exact remote base; no functional APIs implemented. -The independent repository aggregate fix is approved for implementation in -its own PR. A–D remain pending review of this revised contract: preserved enum -namespaces with shared internal storage adapters, ownership/RLS/dependency -verification, guarded operation/hook cancellation, host-managed AEAD cursor -protection, symmetric lossless JSON-text scalars, required create keys, and -host policy-only revision validation. Other accepted boundaries, including -initial unsupported_cascade with RESTRICT supported, are retained. No A–D -interfaces are implemented by this proposal. Each approved functional PR must -provide its own compiled examples, isolated evidence and exact handoff. +The revised A–D contract at `c53a5de966ca5089a5e4aa2d727e144bae83e6bd` +is approved. The independent repository aggregate fix is merged and included +in the owner-published 0.33.3 baseline. PR A implements owned targets and +read-only planning, explicit ownership and separate guarded application in +ORM/macros 0.34.0; its compiled host example and isolated SQLite/PostgreSQL +tests accompany the implementation. It remains subject to owner review. + +A's live FK capability checks reject unsupported actions/deferral with scoped +`UnsupportedForeignKey` diagnostics, including relevant system/unowned incoming +sources, and recheck them on the pinned apply transaction. The legacy physical +model/static APIs are unchanged. Successful SQLite FK restoration retains its +connection; cancellation or failed restoration discards an unsafe lease. ORM API +reproductions at the reviewed head confirm constraint loss. Regressions cover +structured FK rejection, successful in-memory rebuild/no-op replanning, +cancellation, incoming constraints introduced after planning, and unchanged +PostgreSQL system RLS. The approved legacy datetime rejection and +representable static/runtime equivalence gates remain covered. + +B–D remain approved proposals with unimplemented interfaces and require reviewed +committed predecessors. Keep the operation/hook cancellation, host-managed AEAD +cursor protection, lossless JSON text, required create keys and host policy-only +revision guarantees during implementation. Independent compiled static +relationships/query/view work is coordinated separately and does not require +B–D or dynamic GraphQL. No releases or consumer repository changes are included. diff --git a/docs/reference/graphql-orm/runtime-migrations.md b/docs/reference/graphql-orm/runtime-migrations.md index 4eae284f..57e7092a 100644 --- a/docs/reference/graphql-orm/runtime-migrations.md +++ b/docs/reference/graphql-orm/runtime-migrations.md @@ -108,6 +108,34 @@ referenced definition with an unowned incoming FK are rejected; an unchanged definition can gain indexes. Compose host-owned system tables explicitly to plan changes to the owned dependency graph together. No `CASCADE` DDL bypass is added. +Owned runtime validation supplements the legacy FK model with live capability +checks. `UnsupportedForeignKey` identifies the physical source table and SQLite +source column or PostgreSQL constraint in `subject`; `collection` is absent for +catalog sources that need not belong to the runtime schema. A modifying +`ON DELETE SET DEFAULT` is never classified as `Restrict` for a runtime dependency +certificate. Initial owned support accepts explicit delete `RESTRICT`, `CASCADE` +and `SET NULL`, default update `NO ACTION`, and non-deferrable constraints. +Delete `NO ACTION` is rejected because its constraint-check timing differs from +`RESTRICT`; nondefault update actions and any deferrable FK are also rejected. +The static physical model and static migration behavior remain unchanged. + +Checks include relevant incoming sources from unowned/system/ORM tables. Unrelated +unsupported FKs outside the owned dependency graph do not block planning or change +those tables. SQLite reads action metadata from its FK PRAGMA and scans unquoted, +non-comment DDL words for deferral; if a relevant source table contains a deferrable +FK, it conservatively rejects that table. PostgreSQL reads `pg_constraint`, including +cross-schema incoming sources, and rejects cross-schema bindings, non-simple match +rules, unvalidated constraints and partial-column delete actions that the canonical +model cannot represent. SQLite physical names with case variants of owned names are +also rejected rather than losing dependency identity. + +These checks run during read-only validation/planning and mutation-environment +certification, and again on the pinned apply transaction before target DDL/history +success. The legacy source hash omits reserved infrastructure tables and omitted FK +attributes, so that hash alone is insufficient for these checks. Unsupported FK +adapters or extended canonical FK semantics remain separate follow-ups; hosts may +surface this diagnostic in capability/admin previews. + After applying and replanning to no-op, hosts can obtain a `RuntimeMutationEnvironment` through `runtime_mutation_environment(Arc, &target, &ownership)`. It verifies that the schema matches the live composed @@ -134,8 +162,10 @@ does not weaken this binding. A changed live physical source requires a new plan Application rechecks the baseline on the pinned transaction before DDL. SQLite uses `BEGIN IMMEDIATE`; controlled rebuilds check FKs before commit and restore -connection state. A connection whose FKs were suspended is closed on drop so -cancellation cannot return that state to the pool. PostgreSQL uses a cooperative +connection state. An operation guard closes a connection whose FK enforcement was not restored, +so cancellation cannot return that state to the pool. Successful restoration +disarms the guard and retains the connection, including a one-connection in-memory +database. PostgreSQL uses a cooperative migration advisory lock plus locks on existing owned tables. Hosts must coordinate DDL issued outside ORM migration execution. DDL and the successful history row commit together. A recorded version is an already-applied success only after a From e41866d582c36fd920044f6461cb15c6bd446f17 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Thu, 1 Oct 2026 07:34:25 +0000 Subject: [PATCH 5/7] docs: advance provisional runtime slice versions after static release coordination --- .../active/runtime-platform-capabilities/README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/plans/active/runtime-platform-capabilities/README.md b/docs/plans/active/runtime-platform-capabilities/README.md index 6f28030e..d0e59f2e 100644 --- a/docs/plans/active/runtime-platform-capabilities/README.md +++ b/docs/plans/active/runtime-platform-capabilities/README.md @@ -3,7 +3,7 @@ title: Bounded runtime platform capability contract kind: plan status: active owner: graphql-orm-maintainers -last_reviewed: 2026-09-30 +last_reviewed: 2026-10-01 review_by: 2026-12-31 supersedes: [] --- @@ -94,10 +94,10 @@ merged directly into a dynamic schema; hosts provide dynamic fields/types. | --- | --- | --- | --- | | Contract | Current main | This review checkpoint only | 0.33.1, unchanged | | Fix | Reviewed scope, independently of A–D | Plain repository aggregate enums | 0.33.3 (merged identity correction) | -| A | Contract review | Owned physical target conversion and scoped planning | 0.34.0 | -| B | Contract review and A's verified composed physical target | Transactional runtime mutation engine | 0.35.0 | -| C | Contract review; A for end-to-end fixtures | Read-only dynamic GraphQL | 0.36.0 | -| D | Committed, reviewed B and C | Dynamic mutation registration | 0.37.0 | +| A | Approved contract; implementation review #97 | Owned physical target conversion and scoped planning | 0.36.0 | +| B | Approved contract and A's reviewed verified physical target | Transactional runtime mutation engine | 0.37.0 | +| C | Approved contract; A for end-to-end fixtures | Read-only dynamic GraphQL | 0.38.0 | +| D | Committed, reviewed B and C | Dynamic mutation registration | 0.39.0 | These are proposed release increments, not reserved published versions. Rebase and choose the next appropriate version at each PR. ORM/macros remain @@ -1120,7 +1120,8 @@ The revised A–D contract at `c53a5de966ca5089a5e4aa2d727e144bae83e6bd` is approved. The independent repository aggregate fix is merged and included in the owner-published 0.33.3 baseline. PR A implements owned targets and read-only planning, explicit ownership and separate guarded application in -ORM/macros 0.34.0; its compiled host example and isolated SQLite/PostgreSQL +ORM/macros 0.36.0; its unpublished identity is reconciled after the independent +static 0.35.1 release set without changing the approved interfaces. Its compiled host example and isolated SQLite/PostgreSQL tests accompany the implementation. It remains subject to owner review. A's live FK capability checks reject unsupported actions/deferral with scoped From 75765e71610dbc9a4fb6de6cceeda5b6d7a590f1 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Tue, 6 Oct 2026 22:51:59 +0000 Subject: [PATCH 6/7] test(orm): retain owned migration gates in backend and release lanes [skip ci] --- .github/workflows/ci.yml | 7 +++++++ .github/workflows/release.yml | 5 +++++ docs/reference/graphql-orm/runtime-migrations.md | 13 +++++++++++++ scripts/run-owned-database-lanes.sh | 8 ++++++++ 4 files changed, 33 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15dff49c..751f548e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,13 @@ jobs: - run: cargo test -p graphql-orm --no-default-features --features sqlite,field-case-pascal,argument-case-snake --test federation_entity_keys_case_features - run: cargo check -p graphql-orm --no-default-features --features postgres - run: cargo test -p graphql-orm --no-default-features --features postgres --test federation_entity_keys_postgres + - name: Execute owned runtime migration preservation and compatibility + run: | + cargo test -p graphql-orm --locked --no-default-features --features postgres --test runtime_migrations --test runtime_schema_ir --test runtime_migration_memory --test migration_planner --test migration_apply --test legacy_migration_history -- --test-threads=1 + cargo test -p graphql-orm --locked --no-default-features --features mssql --test runtime_migrations_unsupported + for backend in sqlite postgres mssql; do + cargo test --manifest-path crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.toml --locked --no-default-features --features "$backend" + done - name: Execute host timestamps on owned PostgreSQL run: cargo test -p graphql-orm --locked --no-default-features --features postgres --test host_timestamps -- --ignored --test-threads=1 - name: Execute the private PostgreSQL host timestamp consumer diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10ddbdca..89612748 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -155,6 +155,11 @@ jobs: cargo test -p graphql-orm --no-default-features --features sqlite,field-case-pascal,argument-case-snake --test federation_entity_keys_case_features --locked cargo check -p graphql-orm --no-default-features --features postgres --locked cargo test -p graphql-orm --no-default-features --features postgres --test federation_entity_keys_postgres --locked + cargo test -p graphql-orm --locked --no-default-features --features postgres --test runtime_migrations --test runtime_schema_ir --test runtime_migration_memory --test migration_planner --test migration_apply --test legacy_migration_history -- --test-threads=1 + cargo test -p graphql-orm --locked --no-default-features --features mssql --test runtime_migrations_unsupported + for backend in sqlite postgres mssql; do + cargo test --manifest-path crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.toml --locked --no-default-features --features "$backend" + done cargo test -p graphql-orm --no-default-features --features postgres --test projection_visibility --test cross_crate_relations_fixture --test repository_counts --locked cargo test -p graphql-orm --no-default-features --features postgres --test grouped_aggregates_postgres --test portable_group_pages --test host_timestamps --locked -- --ignored --test-threads=1 cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example policy_projections diff --git a/docs/reference/graphql-orm/runtime-migrations.md b/docs/reference/graphql-orm/runtime-migrations.md index 4acdbbe4..19a51ce7 100644 --- a/docs/reference/graphql-orm/runtime-migrations.md +++ b/docs/reference/graphql-orm/runtime-migrations.md @@ -236,3 +236,16 @@ this migration slice does not implement runtime record writes or dynamic GraphQL The current static transaction cancellation limitation is unchanged. B remains gated on a committed, reviewed A predecessor and supplies its stronger runtime operation/hook poisoning guarantee separately. + +Local owned backend lanes include the runtime migration, incoming-FK, baseline, +DDL/history rollback and static compatibility suites. Run them sequentially with +bounded Cargo jobs; they refuse ambient application database URLs: + +```sh +CARGO_BUILD_JOBS=2 scripts/run-owned-database-lanes.sh sqlite +CARGO_BUILD_JOBS=2 scripts/run-owned-database-lanes.sh postgres +``` + +The existing CI/release workflow definitions also select PostgreSQL migration +execution, legacy external imports and MSSQL pre-I/O rejection for future owner +runs. Their configuration does not authorize a workflow dispatch or release. diff --git a/scripts/run-owned-database-lanes.sh b/scripts/run-owned-database-lanes.sh index 00ae8a74..a292a1df 100755 --- a/scripts/run-owned-database-lanes.sh +++ b/scripts/run-owned-database-lanes.sh @@ -26,6 +26,10 @@ repository_root=$(git rev-parse --show-toplevel) cd "${repository_root}" run_sqlite() { + cargo test -p graphql-orm --locked --no-default-features --features sqlite \ + --test runtime_migrations --test runtime_schema_ir --test runtime_migration_memory \ + --test migration_planner --test migration_apply --test legacy_migration_history \ + -- --test-threads=1 cargo test -p graphql-orm --no-default-features --features sqlite \ --test grouped_aggregates --test complete_group_pages --test portable_group_pages --test host_timestamps --locked cargo test --manifest-path \ @@ -34,6 +38,10 @@ run_sqlite() { } run_postgres() { + cargo test -p graphql-orm --locked --no-default-features --features postgres \ + --test runtime_migrations --test runtime_schema_ir --test runtime_migration_memory \ + --test migration_planner --test migration_apply --test legacy_migration_history \ + -- --test-threads=1 cargo test -p graphql-orm --no-default-features --features postgres \ --test grouped_aggregates_postgres --test portable_group_pages --test host_timestamps --locked -- --ignored --test-threads=1 cargo test --manifest-path \ From f2665ce68ed7fdbccb272cb8a783b6379ae5fd82 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Wed, 7 Oct 2026 01:36:52 +0000 Subject: [PATCH 7/7] fix: preserve PostgreSQL datetime migration fixed points [skip ci] --- CHANGELOG.md | 4 + MIGRATION.md | 7 ++ .../graphql-orm/src/graphql/orm/migrations.rs | 12 +++ crates/graphql-orm/tests/migration_planner.rs | 67 ++++++++++++ .../graphql-orm/tests/runtime_migrations.rs | 100 ++++++++++++++++++ .../runtime-platform-capabilities/README.md | 10 ++ .../graphql-orm/runtime-migrations.md | 10 +- 7 files changed, 209 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d83664..4355207c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,10 @@ Companion macros crate: `graphql-orm-macros` **0.40.0** (alignment only). - Preserve a single-connection SQLite in-memory database after a successful owned rebuild; discard suspended connections on cancellation or failed restoration. - Respect SQLite composite primary-key ordinal order during introspection. +- Compare PostgreSQL `TIMESTAMPTZ` and its introspected `timestamp with time zone` + spelling as equivalent in the shared static/owned planner. DateTime targets now + replan to no-op and certify after application; timezone-free timestamps and + precision changes remain distinct. Physical metadata and hash formats are unchanged. - Reject legacy epoch-second DateTime default conversion with scoped structured diagnostics while preserving static storage and supported Integer defaults; preserve escaped quote semantics in converted literal defaults. diff --git a/MIGRATION.md b/MIGRATION.md index ef73e298..072e538b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -39,6 +39,13 @@ incoming constraints are checked, including on the pinned apply transaction; unrelated tables are left intact. Static migration APIs/storage behavior are unchanged. See the reference for additional PostgreSQL scope/match limitations. +PostgreSQL planning recognizes the unmodified built-in `TIMESTAMPTZ` / +`timestamp with time zone` alias without rewriting metadata, defaults or hashes. +This prevents false alterations and certification failures after applying runtime +DateTime targets. It requires no data migration. Timestamp without time zone, +precision modifiers, arrays and qualified/quoted type names remain distinct; +this is not general SQL type-name normalization. SQLite comparison is unchanged. + The physical dependency environment is not a record-write API or an authorization certificate. Hosts must fence external DDL and validate their complete public/policy revision before future mutations; ORM fingerprints exclude host policy revisions. diff --git a/crates/graphql-orm/src/graphql/orm/migrations.rs b/crates/graphql-orm/src/graphql/orm/migrations.rs index 53243036..34e1029a 100644 --- a/crates/graphql-orm/src/graphql/orm/migrations.rs +++ b/crates/graphql-orm/src/graphql/orm/migrations.rs @@ -583,6 +583,15 @@ fn render_constraint_comments(backend: DatabaseBackend, table: &TableRef<'_>) -> .collect() } +// PostgreSQL format_type deparses the canonical runtime TIMESTAMPTZ spelling. +// Compare that exact built-in alias without rewriting physical metadata or hashes. +// Precision modifiers, arrays, domains and timestamp WITHOUT time zone remain +// distinct; this is not a parser or general SQL type-name normalization rule. +fn postgres_timestamptz_alias(sql_type: &str) -> bool { + sql_type.eq_ignore_ascii_case("TIMESTAMPTZ") + || sql_type.eq_ignore_ascii_case("timestamp with time zone") +} + pub(super) fn column_changed_for_backend( backend: DatabaseBackend, before: &ColumnModel, @@ -599,6 +608,9 @@ pub(super) fn column_changed_for_backend( let sql_type_changed = if matches!(backend, DatabaseBackend::Postgres | DatabaseBackend::Mssql) { !before.sql_type.eq_ignore_ascii_case(&after.sql_type) + && !(backend == DatabaseBackend::Postgres + && postgres_timestamptz_alias(&before.sql_type) + && postgres_timestamptz_alias(&after.sql_type)) } else { before.sql_type != after.sql_type }; diff --git a/crates/graphql-orm/tests/migration_planner.rs b/crates/graphql-orm/tests/migration_planner.rs index 84172213..d75a6562 100644 --- a/crates/graphql-orm/tests/migration_planner.rs +++ b/crates/graphql-orm/tests/migration_planner.rs @@ -573,3 +573,70 @@ fn known_postgres_epoch_defaults_compare_without_widening_sql_equivalence() { ); } } + +#[test] +fn postgres_timestamp_aliases_share_planning_without_rewriting_hashes_or_real_types() { + fn model(kind: &str) -> SchemaModel { + let mut table = users_v1(); + table.columns[1].sql_type = kind.into(); + SchemaModel { + extensions: vec![], + tables: vec![table], + } + } + let short = model("TIMESTAMPTZ"); + let deparsed = model("timestamp with time zone"); + // The public hash contract retains the caller's original physical spelling. + assert_ne!(short.stable_hash(), deparsed.stable_hash()); + for (before, after) in [(&short, &deparsed), (&deparsed, &short)] { + let plan = build_migration_plan(DatabaseBackend::Postgres, before, after); + assert!(plan.steps.is_empty()); + assert!(plan.statements.is_empty()); + assert!( + !build_migration_plan(DatabaseBackend::Sqlite, before, after) + .steps + .is_empty() + ); + assert!( + !build_migration_plan(DatabaseBackend::Mssql, before, after) + .steps + .is_empty() + ); + } + for distinct in [ + "timestamp without time zone", + "TIMESTAMP", + "DATE", + "TEXT", + "TIMESTAMPTZ(3)", + "timestamp(3) with time zone", + "TIMESTAMPTZ[]", + "public.timestamptz", + "\"TIMESTAMPTZ\"", + ] { + let plan = build_migration_plan(DatabaseBackend::Postgres, &short, &model(distinct)); + assert!( + plan.steps + .iter() + .any(|step| matches!(step, MigrationStep::AlterColumn { .. })) + ); + } + assert!( + build_migration_plan( + DatabaseBackend::Postgres, + &model("timestamptz"), + &model("TIMESTAMP WITH TIME ZONE") + ) + .steps + .is_empty() + ); + assert!( + !build_migration_plan( + DatabaseBackend::Postgres, + &model("TIMESTAMPTZ(3)"), + &model("TIMESTAMPTZ(6)") + ) + .steps + .is_empty() + ); +} diff --git a/crates/graphql-orm/tests/runtime_migrations.rs b/crates/graphql-orm/tests/runtime_migrations.rs index e75a6097..c4b7a5cf 100644 --- a/crates/graphql-orm/tests/runtime_migrations.rs +++ b/crates/graphql-orm/tests/runtime_migrations.rs @@ -1883,3 +1883,103 @@ async fn postgres_released_host_integer_timestamps_owned_noop() owned.cleanup()?; Ok(()) } + +async fn datetime_fixed_point( + database: &Database, +) -> Result<(), Box> { + for (table, matrix, mixed) in [ + ("minimal_dates", false, false), + ("matrix_dates", true, false), + ("composed_dates", true, true), + ] { + let mut definition = schema().schema().clone(); + let collection = &mut definition.collections[0]; + collection.physical_table = table.into(); + collection.fields.truncate(1); + collection.indexes.clear(); + for (name, nullable, default) in [ + ("required_date", false, None), + ("nullable_date", true, None), + ( + "required_now", + false, + Some(RuntimeDefault::CurrentTimestamp), + ), + ("nullable_now", true, Some(RuntimeDefault::CurrentTimestamp)), + ] + .into_iter() + .take(if matrix { 4 } else { 1 }) + { + collection.fields.push(RuntimeField { + id: FieldId::new(name)?, + api_name: name.into(), + physical_column: name.into(), + value_kind: RuntimeValueKind::DateTime, + nullable, + unique: false, + filterable: false, + sortable: false, + generated: false, + default, + }); + } + let schema = std::sync::Arc::new(definition.validate()?); + let mut target = schema.physical_schema::(Default::default())?; + let mut tables = vec![table.to_owned()]; + if mixed { + target = target.with_static_entities(&[OwnedClock::metadata()])?; + tables.push("owned_clocks".into()); + } + let ownership = ManagedTableSet::new(tables)?; + let manager = database.schema(); + let plan = manager + .plan_owned_migration( + table, + "datetime fixed point", + &target, + &ownership, + PlanOptions::strict(), + ) + .await?; + manager + .apply_owned_migration(&plan, ApplyOptions::default()) + .await?; + let noop = manager + .plan_owned_migration( + table, + "unchanged datetime target", + &target, + &ownership, + PlanOptions::strict(), + ) + .await?; + assert!(noop.steps().is_empty(), "{noop:?}"); + assert!(noop.statements().is_empty()); + manager + .runtime_mutation_environment(schema, &target, &ownership) + .await?; + } + Ok(()) +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sqlite_datetime_required_nullable_defaults_and_composition_fixed_point() +-> Result<(), Box> { + let db = sqlite().await; + datetime_fixed_point(&db).await?; + db.pool().close().await; + Ok(()) +} + +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] +#[tokio::test] +async fn postgres_datetime_required_nullable_defaults_and_composition_fixed_point() +-> Result<(), Box> { + let mut owned = owned_postgres::OwnedPostgres::start("datetime-fixed-point")?; + let db = Database::::connect_postgres(&owned.url).await?; + datetime_fixed_point(&db).await?; + db.pool().close().await; + owned.cleanup()?; + Ok(()) +} diff --git a/docs/plans/active/runtime-platform-capabilities/README.md b/docs/plans/active/runtime-platform-capabilities/README.md index f350e284..064271fe 100644 --- a/docs/plans/active/runtime-platform-capabilities/README.md +++ b/docs/plans/active/runtime-platform-capabilities/README.md @@ -1136,6 +1136,16 @@ cancellation, incoming constraints introduced after planning, and unchanged PostgreSQL system RLS. The approved legacy datetime rejection and representable static/runtime equivalence gates remain covered. +Review correction R3 reproduces PostgreSQL DateTime no-op/certification failure +through owned plan/apply APIs. The shared planner now compares the built-in +`TIMESTAMPTZ` / `timestamp with time zone` alias without rewriting hashes or +storage metadata. Required/nullable DateTime targets with no default and +`CurrentTimestamp`, including static/system composition, have executable +SQLite/PostgreSQL fixed-point gates. Timezone-free timestamps and precision +changes remain distinct. This correction awaits exact-head re-review. Prefer +owner merge/release of independent #112 first, then reconcile A with that exact +merged source; Actions remain paused. + B–D remain approved proposals with unimplemented interfaces and require reviewed committed predecessors. Keep the operation/hook cancellation, host-managed AEAD cursor protection, lossless JSON text, required create keys and host policy-only diff --git a/docs/reference/graphql-orm/runtime-migrations.md b/docs/reference/graphql-orm/runtime-migrations.md index 19a51ce7..ad4f35b6 100644 --- a/docs/reference/graphql-orm/runtime-migrations.md +++ b/docs/reference/graphql-orm/runtime-migrations.md @@ -10,7 +10,7 @@ supersedes: [] # Owned runtime migration targets -ORM/macros 0.36.0 adds migration targets from the existing validated runtime +ORM/macros 0.40.0 (unreleased) adds migration targets from the existing validated runtime schema on SQLite and PostgreSQL. These APIs are separate from schema activation, catalog persistence, public revision selection, authorization policy and transport, which remain host responsibilities. Dynamic GraphQL and runtime record mutations @@ -84,6 +84,14 @@ semantics and replan to an empty step/statement list after application in either origin direction. SQLite introspection respects primary-key member ordinals, including a composite key whose order differs from column declaration order. +PostgreSQL introspection reports `TIMESTAMPTZ` as `timestamp with time zone`. +The shared static/owned planner treats these unmodified built-in spellings as +equivalent, including required/nullable fields with no default or +`CurrentTimestamp`. The stored metadata and hash algorithm retain their original +spellings. Timezone-free timestamps and precision changes remain distinct. +This comparison does not reinterpret storage or relax legacy DateTime default +conversion checks. + ## Ownership, composition and dependencies `ManagedTableSet` is trusted host state. Its portable table identifiers are unique,