diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b1a454a..9be0c4d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,13 @@ jobs: run: cargo test --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features postgres --test runtime_strings -- --ignored --test-threads=1 - name: Execute the private runtime string consumer on owned SQLite run: cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example runtime_strings + - 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 bb700604..941aefb4 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 test -p graphql-orm --locked --no-default-features --features postgres --test runtime_strings -- --include-ignored --test-threads=1 diff --git a/CHANGELOG.md b/CHANGELOG.md index e59d372a..88ceb756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,39 @@ 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.40.0 - unreleased + +Companion macros crate: `graphql-orm-macros` **0.40.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. +- 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. +- 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. + +No automatic application, host catalog, transport, durable delivery or release +publication is introduced. See the owned runtime migration reference and example. + ## 0.39.1 - unreleased Companion macros crate: `graphql-orm-macros` **0.39.1** (alignment only). diff --git a/Cargo.lock b/Cargo.lock index 787e8bc4..84523071 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3146,7 +3146,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.39.1" +version = "0.40.0" dependencies = [ "agql-auth", "async-graphql", @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.39.1" +version = "0.40.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 645f451e..e629435a 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.39.1", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.40.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 c0d5363e..857c4b09 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,6 +13,49 @@ 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.39.0 to 0.40.0: owned runtime migration targets + +Adopt runtime and macros 0.40.0 together. This unpublished identity retains the released +0.39.0 static capabilities without changing the approved migration contract. 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). + +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. + +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. + +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. + ## Runtime string predicates and PostgreSQL binding (0.39.1) Adopt aligned ORM/macros 0.39.1. No data migration, declaration change, GraphQL diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 35184b59..aae982e7 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.39.1" +version = "0.40.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 59cffb42..4c115919 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.39.1", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.40.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.39.1", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.40.0", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the @@ -182,3 +182,7 @@ Repository-only Integer timestamps can use per-field `#[graphql_orm(timestamp = See [host timestamp inputs, migration compatibility and cancellation limits](../../docs/reference/graphql-orm/repository-only-entities.md#host-managed-integer-timestamps). Complete SQLite/PostgreSQL text-group pages and SQL-visible ordinary aggregates: see [typed aggregates](../../docs/reference/graphql-orm/typed-aggregates.md). + +Version 0.40.0 aligns with the owned runtime migration addition. Released host-managed +timestamps, framework-neutral repository aggregate helpers and static GraphQL SDL +remain unchanged. diff --git a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock index 519bf451..b47ce13c 100644 --- a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock @@ -1028,7 +1028,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.39.1" +version = "0.40.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.39.1" +version = "0.40.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index afc09012..9603b9d7 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.39.1" +version = "0.40.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.39.1", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.40.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 2cca0964..289c5cde 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.39.1", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.40.0", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the @@ -198,3 +198,9 @@ Repository-only Integer timestamps can use per-field `#[graphql_orm(timestamp = See [host timestamp inputs, migration compatibility and cancellation limits](../../docs/reference/graphql-orm/repository-only-entities.md#host-managed-integer-timestamps). Complete SQLite/PostgreSQL text-group pages and SQL-visible ordinary aggregates: see [typed aggregates](../../docs/reference/graphql-orm/typed-aggregates.md). + +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/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/core.rs b/crates/graphql-orm/src/graphql/orm/core.rs index 0c0130ec..d0d49673 100644 --- a/crates/graphql-orm/src/graphql/orm/core.rs +++ b/crates/graphql-orm/src/graphql/orm/core.rs @@ -3260,21 +3260,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) } } @@ -3660,7 +3646,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)) @@ -3680,77 +3666,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, @@ -3768,144 +3683,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() } } @@ -4304,212 +4082,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() } } @@ -4704,237 +4283,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..80221e44 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) } } @@ -736,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, @@ -744,13 +781,17 @@ 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::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() @@ -759,13 +800,38 @@ where }); if suspend_foreign_keys { + if owned.is_some() { + conn.close_if_unrestored = true; + } 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 +850,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 +858,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) @@ -808,11 +874,92 @@ 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 } +#[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 +995,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 +1047,7 @@ where } tx.commit().await?; - Ok(()) + Ok(None) } #[cfg(feature = "postgres")] @@ -1022,3 +1203,193 @@ 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> { + 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 + // 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 + // 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> { + 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( + "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(&mut *connection).await?; + if protected + .iter() + .any(|(table,)| plan.changes_protected_rls_structure(table)) + { + return Err(rejection(RuntimeMigrationDiagnosticCode::InvalidPhysicalContract).into()); + } + 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/migrations.rs b/crates/graphql-orm/src/graphql/orm/migrations.rs index 7da8d949..34e1029a 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 { - render_create_table_statement_for_name(backend, table, &table.table_name) +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,16 @@ fn render_constraint_comments(backend: DatabaseBackend, table: &TableModel) -> V .collect() } -fn column_changed_for_backend( +// 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, after: &ColumnModel, @@ -593,6 +608,9 @@ 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 }; @@ -610,14 +628,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 +645,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 +685,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 +715,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 +734,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 +762,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 +771,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 +780,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 +802,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 +842,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 +905,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,15 +920,14 @@ 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, }); } - if !check_constraints_equivalent(&table.check_constraints, &target_table.check_constraints) - { - steps.push(MigrationStep::SetCheckConstraints { + 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(), after: target_table.check_constraints.clone(), @@ -906,7 +935,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 +959,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 +1198,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 { @@ -1231,15 +1260,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 +1723,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 +1735,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( @@ -1746,12 +1770,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, )); @@ -1760,8 +1784,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,13 +1801,13 @@ 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 .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 @@ -1788,7 +1818,7 @@ pub fn render_migration_step(backend: DatabaseBackend, step: &MigrationStep) -> if table.append_only { statements.extend(render_append_only_statements( backend, - &table.table_name, + table.table_name, true, table.retention_purge, )); @@ -1796,15 +1826,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 +1851,7 @@ pub fn render_migration_step(backend: DatabaseBackend, step: &MigrationStep) -> table_name, column_name )], }, - MigrationStep::AlterColumn { + StepRef::AlterColumn { table_name, before: _, after, @@ -1852,10 +1882,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 +1912,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 +1930,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 +1963,7 @@ pub fn render_migration_step(backend: DatabaseBackend, step: &MigrationStep) -> )], } } - MigrationStep::DropForeignKey { + StepRef::DropForeignKey { table_name, foreign_key, } => { @@ -1958,12 +1988,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 +2037,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 +2085,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 +2135,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 +2170,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 +2182,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 +2200,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 +2209,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 +2276,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 +2301,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 +2310,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 +2388,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 +2409,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 +2473,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 +2483,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 +2495,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 +2567,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 +2600,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 +2620,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 +2640,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 +2723,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 +2765,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 +2793,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 +2808,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 +2834,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 +2901,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 +2934,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 +2989,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 +3060,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 +3081,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 +3106,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 +3134,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 +3154,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..d03887d2 100644 --- a/crates/graphql-orm/src/graphql/orm/mod.rs +++ b/crates/graphql-orm/src/graphql/orm/mod.rs @@ -13,8 +13,11 @@ 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; +mod runtime_migration; mod runtime_query; mod runtime_record; mod runtime_relation; @@ -38,8 +41,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_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/owned_schema.rs b/crates/graphql-orm/src/graphql/orm/owned_schema.rs new file mode 100644 index 00000000..61e6c16e --- /dev/null +++ b/crates/graphql-orm/src/graphql/orm/owned_schema.rs @@ -0,0 +1,1535 @@ +//! 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 }, + MigrationStep::CreateTable(value) => Self::CreateTable(TableRef::from(value)), + 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, + column_name, + }, + MigrationStep::AlterColumn { + table_name, + before, + after, + } => Self::AlterColumn { + table_name, + before, + after, + }, + MigrationStep::CreateIndex { table_name, index } => Self::CreateIndex { + table_name, + index: IndexRef::from(index), + }, + MigrationStep::DropIndex { + table_name, + index_name, + } => Self::DropIndex { + table_name, + index_name, + }, + MigrationStep::CreateSearchIndex { table_name, index } => { + Self::CreateSearchIndex { table_name, index } + } + MigrationStep::DropSearchIndex { + table_name, + index_name, + } => Self::DropSearchIndex { + table_name, + index_name, + }, + MigrationStep::AlterSearchIndex { + table_name, + before, + after, + } => Self::AlterSearchIndex { + table_name, + before, + after, + }, + MigrationStep::AddForeignKey { + table_name, + foreign_key, + } => Self::AddForeignKey { + table_name, + foreign_key, + }, + MigrationStep::DropForeignKey { + table_name, + foreign_key, + } => Self::DropForeignKey { + table_name, + foreign_key, + }, + MigrationStep::SetAppendOnly { + table_name, + enabled, + retention_purge, + } => Self::SetAppendOnly { + table_name, + enabled: *enabled, + retention_purge: *retention_purge, + }, + MigrationStep::SetCheckConstraints { + table_name, + before, + after, + } => Self::SetCheckConstraints { + table_name, + before, + after, + }, + } + } +} +impl<'a> From<&'a OwnedMigrationStep> for StepRef<'a> { + fn from(step: &'a OwnedMigrationStep) -> Self { + match step { + OwnedMigrationStep::EnableExtension { name } => Self::EnableExtension { name }, + OwnedMigrationStep::CreateTable(value) => Self::CreateTable(value.borrowed()), + 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, + column_name, + }, + OwnedMigrationStep::AlterColumn { + table_name, + before, + after, + } => Self::AlterColumn { + table_name, + before, + after, + }, + OwnedMigrationStep::CreateIndex { table_name, index } => Self::CreateIndex { + table_name, + index: index.borrowed(), + }, + OwnedMigrationStep::DropIndex { + table_name, + index_name, + } => Self::DropIndex { + table_name, + index_name, + }, + OwnedMigrationStep::CreateSearchIndex { table_name, index } => { + Self::CreateSearchIndex { table_name, index } + } + OwnedMigrationStep::DropSearchIndex { + table_name, + index_name, + } => Self::DropSearchIndex { + table_name, + index_name, + }, + OwnedMigrationStep::AlterSearchIndex { + table_name, + before, + after, + } => Self::AlterSearchIndex { + table_name, + before, + after, + }, + OwnedMigrationStep::AddForeignKey { + table_name, + foreign_key, + } => Self::AddForeignKey { + table_name, + foreign_key, + }, + OwnedMigrationStep::DropForeignKey { + table_name, + foreign_key, + } => Self::DropForeignKey { + table_name, + foreign_key, + }, + OwnedMigrationStep::SetAppendOnly { + table_name, + enabled, + retention_purge, + } => Self::SetAppendOnly { + table_name, + enabled: *enabled, + retention_purge: *retention_purge, + }, + OwnedMigrationStep::SetCheckConstraints { + table_name, + before, + after, + } => Self::SetCheckConstraints { + table_name, + before, + 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..857392cd --- /dev/null +++ b/crates/graphql-orm/src/graphql/orm/runtime_migration.rs @@ -0,0 +1,1303 @@ +//! 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, + /// 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. + 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()) + } + /// 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( + 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(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 { + super::migrations::introspect_owned_sqlite_schema(pool) + .await + .map_err(owned_introspection_error) + } + 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_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, + ) -> 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 { + 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 { + super::migrations::introspect_owned_postgres_schema(pool) + .await + .map_err(owned_introspection_error) + } + 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_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, + ) -> 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 { + 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::()?; + 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))?; + 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); + 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()); + } + 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); + 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))?; + 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); + 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/runtime_schema.rs b/crates/graphql-orm/src/graphql/orm/runtime_schema.rs index a7e10e9d..0e06287a 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 @@ -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/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/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 0dd59962..a051d5ca 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.39.1" +version = "0.40.0" dependencies = [ "agql-auth", "async-graphql", @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.39.1" +version = "0.40.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 4be93ca4..604781a8 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.39.1" +version = "0.40.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2531,7 +2531,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.39.1" +version = "0.40.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 26785a79..a549e290 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.39.1" +version = "0.40.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.39.1" +version = "0.40.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 09295370..d288e123 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.39.1" +version = "0.40.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.39.1" +version = "0.40.0" dependencies = [ "convert_case", "proc-macro2", 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/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_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 new file mode 100644 index 00000000..c4b7a5cf --- /dev/null +++ b/crates/graphql-orm/tests/runtime_migrations.rs @@ -0,0 +1,1985 @@ +#![cfg(any(feature = "sqlite", feature = "postgres"))] + +use graphql_orm::GraphQLSchemaEntity; +use graphql_orm::db::Database; +use graphql_orm::graphql::orm::*; + +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] +#[path = "support/owned_postgres.rs"] +mod owned_postgres; + +#[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", + 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!( + 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(all(feature = "postgres", not(feature = "sqlite")))] + 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!( + 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::>() + ); + 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(all(feature = "postgres", not(feature = "sqlite")))] +#[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(()) +} + +#[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")] +#[derive(GraphQLSchemaEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity( + backend = "sqlite", + 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(()) +} + +#[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"))); +} + +#[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(()) +} + +// Released repository-only timestamp modes must retain their Integer/default DDL +// when converted through A's canonical owned physical target. +#[derive(graphql_orm::RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[cfg_attr( + feature = "sqlite", + repository_entity( + backend = "sqlite", + table = "owned_clocks", + plural = "OwnedClocks", + default_sort = "id ASC" + ) +)] +#[cfg_attr( + all(feature = "postgres", not(feature = "sqlite")), + repository_entity( + backend = "postgres", + table = "owned_clocks", + plural = "OwnedClocks", + default_sort = "id ASC" + ) +)] +struct OwnedClock { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + #[graphql_orm(timestamp = "host")] + created_at: i64, + #[graphql_orm(timestamp = "host", db_column = "updated_at", default = "0")] + modified_ms: i64, +} + +async fn owned_integer_timestamps_noop( + database: &Database, +) -> Result<(), Box> { + let static_target = SchemaModel::from_entities(&[OwnedClock::metadata()]); + let schema = RuntimeSchema::from_static_entities(&[OwnedClock::metadata()])?.validate()?; + let target = schema.physical_schema::(RuntimeMigrationLimits::default())?; + assert_eq!(target.stable_hash(), static_target.stable_hash()); + for column in target.tables()[0] + .columns() + .iter() + .filter(|c| c.name != "id") + { + assert!(matches!(column.sql_type.as_str(), "INTEGER" | "BIGINT")); + let original = static_target.tables[0] + .columns + .iter() + .find(|c| c.name == column.name) + .unwrap(); + assert_eq!(column.default, original.default); + } + assert!( + target.tables()[0] + .columns() + .iter() + .find(|c| c.name == "created_at") + .unwrap() + .default + .is_some() + ); + assert_eq!( + target.tables()[0] + .columns() + .iter() + .find(|c| c.name == "updated_at") + .unwrap() + .default + .as_deref(), + Some("0") + ); + let ownership = ManagedTableSet::new(["owned_clocks".to_owned()])?; + let manager = database.schema(); + let plan = manager.plan_migration( + "owned-clocks-v1", + "static Integer defaults", + &SchemaModel::from_entities(&[]), + &static_target, + )?; + manager + .apply_migration(&plan, ApplyOptions::default()) + .await?; + let owned = manager + .plan_owned_migration( + "owned-clocks-v2", + "same owned Integer defaults", + &target, + &ownership, + PlanOptions::strict(), + ) + .await?; + assert!(owned.steps().is_empty()); + assert!(owned.statements().is_empty()); + let environment = manager + .runtime_mutation_environment(std::sync::Arc::new(schema), &target, &ownership) + .await?; + assert_eq!( + environment.schema().schema().collections[0].id.as_str(), + "owned_clocks" + ); + Ok(()) +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sqlite_released_host_integer_timestamps_owned_noop() +-> Result<(), Box> { + let database = sqlite().await; + owned_integer_timestamps_noop(&database).await?; + database.pool().close().await; + Ok(()) +} + +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] +#[tokio::test] +async fn postgres_released_host_integer_timestamps_owned_noop() +-> Result<(), Box> { + let mut owned = owned_postgres::OwnedPostgres::start("owned-integer-timestamps")?; + let database = Database::::connect_postgres(&owned.url).await?; + owned_integer_timestamps_noop(&database).await?; + database.pool().close().await; + 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/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/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/docs/plans/active/runtime-platform-capabilities/README.md b/docs/plans/active/runtime-platform-capabilities/README.md index 23cc59ec..064271fe 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: [] --- @@ -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, @@ -89,11 +93,11 @@ merged directly into a dynamic schema; hosts provide dynamic fields/types. | PR | Depends on | Change | Suggested aligned ORM/macros version | | --- | --- | --- | --- | | Contract | Current main | This review checkpoint only | 0.33.1, unchanged | -| Fix | Reviewed scope, independently of A–D | Plain repository aggregate enums | 0.33.2 | -| 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 | +| Fix | Reviewed scope, independently of A–D | Plain repository aggregate enums | 0.33.3 (merged identity correction) | +| A | Approved contract; implementation review #97 | Owned physical target conversion and scoped planning | 0.40.0 | +| B | Approved contract and A's reviewed verified physical target | Transactional runtime mutation engine | next additive version after reviewed A | +| C | Approved contract; A for end-to-end fixtures | Read-only dynamic GraphQL | next additive version after reviewed B | +| D | Committed, reviewed B and C | Dynamic mutation registration | next additive version after reviewed C | These are proposed release increments, not reserved published versions. Rebase and choose the next appropriate version at each PR. ORM/macros remain @@ -292,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?; ``` @@ -1112,13 +1116,39 @@ 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.40.0; its unpublished identity is reconciled onto released +0.39.0 main without changing the approved interfaces. Host-managed timestamps and +portable static aggregate capabilities remain intact. 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. + +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 +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/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..ad4f35b6 --- /dev/null +++ b/docs/reference/graphql-orm/runtime-migrations.md @@ -0,0 +1,259 @@ +--- +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.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 +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 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. + +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, +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. + +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 +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. 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 +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. 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 + +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. + +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. +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. + +## Released repository capability compatibility + +Owned migration targets retain the released repository host-managed timestamp +annotation as an ordinary Integer field. Per-field host management changes no +physical default, column name or unit. Static-to-runtime conversion retains +explicit Integer defaults and supported epoch-second Integer defaults; it does +not infer millisecond DateTime storage. No-op replanning on SQLite/PostgreSQL +covers conversion of these released declarations. Legacy epoch-second DateTime +default rejection remains unchanged. + +The independent runtime string predicate/binding fix is reviewed separately; +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/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index c383c0f7..40dfd994 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.39.1` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.40.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.106.1` | `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.39.1` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.40.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.4` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none | diff --git a/scripts/run-owned-database-lanes.sh b/scripts/run-owned-database-lanes.sh index 521136f4..13dc0ea0 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 runtime_strings --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 runtime_strings --locked -- --include-ignored --test-threads=1 cargo test --manifest-path \