diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15dff49c..8b1a454a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,12 +61,22 @@ jobs: - run: cargo test -p graphql-orm --no-default-features --features sqlite,field-case-pascal,argument-case-snake --test federation_entity_keys_case_features - run: cargo check -p graphql-orm --no-default-features --features postgres - run: cargo test -p graphql-orm --no-default-features --features postgres --test federation_entity_keys_postgres + - name: Execute runtime strings and binding identity on owned PostgreSQL + env: + CARGO_BUILD_JOBS: "2" + run: cargo test -p graphql-orm --locked --no-default-features --features postgres --test runtime_strings -- --include-ignored --test-threads=1 + - name: Execute the private runtime string consumer on owned PostgreSQL + env: + CARGO_BUILD_JOBS: "2" + 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 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 run: cargo test --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features postgres --test host_timestamps -- --ignored --test-threads=1 - run: cargo check -p graphql-orm --no-default-features --features mssql - - run: cargo test -p graphql-orm --no-default-features --features mssql --test federation_entity_keys_mssql + - run: cargo test -p graphql-orm --no-default-features --features mssql --test federation_entity_keys_mssql --test runtime_records --test query_ir - run: cargo clippy -p graphql-orm --all-targets -- -D warnings - run: RUSTDOCFLAGS="-D warnings" cargo doc -p graphql-orm -p graphql-orm-macros --no-deps diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10ddbdca..bb700604 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -157,11 +157,14 @@ jobs: cargo test -p graphql-orm --no-default-features --features postgres --test federation_entity_keys_postgres --locked 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 + 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 + cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example runtime_strings cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example policy_projections cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example complete_events cargo test --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features postgres --test portable_groups --test host_timestamps -- --ignored --test-threads=1 cargo check -p graphql-orm --no-default-features --features mssql --locked - cargo test -p graphql-orm --no-default-features --features mssql --test federation_entity_keys_mssql --locked + cargo test -p graphql-orm --no-default-features --features mssql --test federation_entity_keys_mssql --test runtime_records --test query_ir --locked cargo clippy -p graphql-orm --all-targets --locked -- -D warnings RUSTDOCFLAGS="-D warnings" cargo doc -p graphql-orm -p graphql-orm-macros --no-deps --locked diff --git a/CHANGELOG.md b/CHANGELOG.md index cf7dd72b..e59d372a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ title: "Changelog" kind: reference status: active owner: workspace-maintainers -last_reviewed: 2026-10-02 +last_reviewed: 2026-10-06 review_by: 2027-02-01 supersedes: [] --- @@ -14,7 +14,21 @@ 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.39.0 - unreleased +## 0.39.1 - unreleased + +Companion macros crate: `graphql-orm-macros` **0.39.1** (alignment only). + +- Correct runtime SQLite `ends_with("")` to match every non-null string; + preserve case-sensitive literal, Unicode and SQL NULL semantics. +- Preserve repeated native PostgreSQL parameter identity at complete-statement + binding boundaries, including authenticated count/read pairs and transaction + reads/writes. Retain anonymous/mixed occurrence rebasing and static fragment + semantics; no public signature, cursor, schema or migration changes. +- Add owned SQLite/PostgreSQL execution, evaluator/page/count/RLS parity, + normalization and dependency-minimal external-consumer regressions. MSSQL + runtime capability gates remain unchanged; no live MSSQL claim is made. + +## 0.39.0 - 2026-10-03 Companion macros crate: `graphql-orm-macros` **0.39.0**. diff --git a/Cargo.lock b/Cargo.lock index 7e7f894f..787e8bc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3146,7 +3146,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.39.0" +version = "0.39.1" dependencies = [ "agql-auth", "async-graphql", @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.39.0" +version = "0.39.1" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index bc445f99..645f451e 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.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.39.1", 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 575f1b6c..c0d5363e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -3,7 +3,7 @@ title: "Migration Guide" kind: reference status: active owner: workspace-maintainers -last_reviewed: 2026-10-02 +last_reviewed: 2026-10-06 review_by: 2027-02-01 supersedes: [] --- @@ -13,6 +13,31 @@ 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. +## Runtime string predicates and PostgreSQL binding (0.39.1) + +Adopt aligned ORM/macros 0.39.1. No data migration, declaration change, GraphQL +SDL change, or cursor-format change is required. SQLite runtime empty suffixes +now match every non-null string, as empty prefixes already do. SQL NULL remains +unknown under comparisons and NOT; use an explicit null predicate to include it. + +`PostgresBackend::normalize_sql` is a complete-statement binding boundary: +SQL using only native `$n` parameters retains each index and repeated identity. +A positive rebasing start adds `start_index - 1` to each native index. Previously, +repeated references were incorrectly allocated separate slots; out-of-order +indices were also renumbered. Callers must supply values for the actual native +slots, rather than relying on occurrence order. Ordinary generated static queries +already compose contiguous native indices and require no call-site edits. + +Anonymous or mixed placeholder forms retain legacy occurrence-based rebasing; +mixed forms do not express shared native identity. `SqlDialect::normalize_sql` +retains static-fragment occurrence rebasing, including generated fragments with +arbitrary or restarted native labels. Do not use fragment rebasing on a complete +native statement whose repeated parameters share a bound value. + +See [runtime string semantics, executable consumer and backend checks](docs/reference/graphql-orm/runtime-queries.md). +Runtime MSSQL remains unsupported before I/O. This independent query fix does +not implement the proposed runtime migration/mutation/dynamic GraphQL contracts. + ## Repository host timestamps (0.39.0) Adopt aligned ORM/macros 0.39.0. Existing declarations require no edits and keep diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index a5a1fcaf..35184b59 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.0" +version = "0.39.1" 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 2f64e77c..59cffb42 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -3,7 +3,7 @@ title: "graphql-orm-macros" kind: reference status: active owner: graphql-orm-macros-maintainers -last_reviewed: 2026-10-02 +last_reviewed: 2026-10-06 review_by: 2027-02-01 supersedes: [] --- @@ -16,19 +16,22 @@ macro/runtime versions aligned: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.39.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.39.1", 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.0", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.39.1", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the consumer crate because generated code refers to `::graphql_orm`. The macros do not connect to a database, run migrations, host GraphQL, or authorize requests. +Version 0.39.1 aligns with the runtime string/binding correction; generated +static fragment rebasing, inputs, GraphQL SDL and timestamp behavior are unchanged. + ## Public macros | Item | Purpose | 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 648cdfeb..519bf451 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.0" +version = "0.39.1" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.39.0" +version = "0.39.1" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index 9c2359f3..afc09012 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.39.0" +version = "0.39.1" 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.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.39.1", 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 da91c71c..2cca0964 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -3,7 +3,7 @@ title: "graphql-orm" kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-10-02 +last_reviewed: 2026-10-06 review_by: 2027-02-01 supersedes: [] --- @@ -29,7 +29,7 @@ backend: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.39.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.39.1", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the @@ -163,6 +163,7 @@ enforce row/field/database limits appropriate to the application. - [Entities and relations](../../docs/reference/graphql-orm/entities-and-relations.md) - [Typed grouped aggregates](../../docs/reference/graphql-orm/typed-aggregates.md) - [Federation entities, keys, and operation roots](../../docs/reference/graphql-orm/federation.md) +- [Runtime predicates and keyset reads](../../docs/reference/graphql-orm/runtime-queries.md) - [Runtime writes and repository operations](../../docs/reference/graphql-orm/runtime-and-writes.md) - [Schema management](../../docs/reference/graphql-orm/schema-management.md) - [Strict authorization](../../docs/reference/graphql-orm/strict-authorization.md) diff --git a/crates/graphql-orm/src/graphql/orm/backend.rs b/crates/graphql-orm/src/graphql/orm/backend.rs index 9ba7a51d..8e83893c 100644 --- a/crates/graphql-orm/src/graphql/orm/backend.rs +++ b/crates/graphql-orm/src/graphql/orm/backend.rs @@ -53,8 +53,19 @@ pub trait OrmBackend: Copy + Clone + Send + Sync + 'static { Self::DIALECT.placeholder(index) } + /// Normalize a complete statement at the driver binding boundary. + /// + /// PostgreSQL statements using only native `$n` parameters preserve their + /// indices and repeated identity; rebasing adds `start_index - 1` to each + /// index. Anonymous or mixed placeholder forms retain legacy occurrence + /// rebasing. [`SqlDialect::normalize_sql`] separately rebases static query + /// fragments by occurrence, before their ordered values are composed. fn normalize_sql(sql: &str, start_index: usize) -> String { - Self::DIALECT.normalize_sql(sql, start_index) + if Self::DIALECT == DatabaseBackend::Postgres { + super::dialect::normalize_postgres_statement(sql, start_index) + } else { + Self::DIALECT.normalize_sql(sql, start_index) + } } } diff --git a/crates/graphql-orm/src/graphql/orm/dialect.rs b/crates/graphql-orm/src/graphql/orm/dialect.rs index 452b6ab4..4ae031ee 100644 --- a/crates/graphql-orm/src/graphql/orm/dialect.rs +++ b/crates/graphql-orm/src/graphql/orm/dialect.rs @@ -289,11 +289,206 @@ fn normalize_sql_placeholders(backend: DatabaseBackend, sql: &str, start_index: out } +// Complete PostgreSQL statements already carry binding-slot identity. Static +// fragments do not: generated field fragments can start at arbitrary indices, +// and nested filters can restart numbering. Keep their existing occurrence +// rebasing separate from this final driver-boundary normalization. +pub(crate) fn normalize_postgres_statement(sql: &str, start_index: usize) -> String { + let bytes = sql.as_bytes(); + let mut parameters = Vec::new(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' => { + let quote = bytes[i]; + let escaped = quote == b'\'' + && i > 0 + && matches!(bytes[i - 1], b'e' | b'E') + && (i == 1 || !postgres_identifier_byte(bytes[i - 2])); + i += 1; + while i < bytes.len() { + if escaped && bytes[i] == b'\\' { + i = (i + 2).min(bytes.len()); + } else if bytes[i] == quote { + i += 1; + if bytes.get(i) == Some("e) { + i += 1; + } else { + break; + } + } else { + i += 1; + } + } + } + b'-' if bytes.get(i + 1) == Some(&b'-') => { + i += 2; + while i < bytes.len() && !matches!(bytes[i], b'\n' | b'\r') { + i += 1; + } + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + i += 2; + let mut depth = 1usize; + while i < bytes.len() && depth > 0 { + if bytes.get(i..i + 2) == Some(b"/*") { + depth += 1; + i += 2; + } else if bytes.get(i..i + 2) == Some(b"*/") { + depth -= 1; + i += 2; + } else { + i += 1; + } + } + } + b'$' if i == 0 || !postgres_identifier_byte(bytes[i - 1]) => { + let start = i; + i += 1; + if bytes.get(i).is_some_and(u8::is_ascii_digit) { + while bytes.get(i).is_some_and(u8::is_ascii_digit) { + i += 1; + } + parameters.push((start, i)); + } else { + // PostgreSQL dollar-quoted strings ($$ or $tag$). + let tag_start = i; + if bytes + .get(i) + .is_some_and(|ch| ch.is_ascii_alphabetic() || *ch == b'_' || *ch >= 0x80) + { + i += 1; + while bytes.get(i).is_some_and(|ch| { + ch.is_ascii_alphanumeric() || *ch == b'_' || *ch >= 0x80 + }) { + i += 1; + } + } + if bytes.get(i) == Some(&b'$') { + i += 1; + let delimiter = &sql[start..i]; + i = sql[i..] + .find(delimiter) + .map_or(bytes.len(), |offset| i + offset + delimiter.len()); + } else { + // Not a parameter or quote delimiter; keep the original token. + i = tag_start; + } + } + } + b'?' => { + return normalize_sql_placeholders(DatabaseBackend::Postgres, sql, start_index); + } + b'@' if bytes + .get(i + 1) + .is_some_and(|ch| ch.eq_ignore_ascii_case(&b'p')) => + { + return normalize_sql_placeholders(DatabaseBackend::Postgres, sql, start_index); + } + _ => i += 1, + } + } + let offset = start_index.saturating_sub(1); + if offset == 0 { + return sql.to_owned(); + } + let mut out = String::with_capacity(sql.len()); + let mut previous = 0; + for (start, end) in parameters { + out.push_str(&sql[previous..start]); + // Leave malformed/overflowing indices for the driver's normal safe error + // path rather than panicking or guessing a different binding identity. + if let Some(index) = sql[start + 1..end] + .parse::() + .ok() + .filter(|n| *n > 0) + .and_then(|n| n.checked_add(offset)) + { + out.push_str(&DatabaseBackend::Postgres.placeholder(index)); + } else { + out.push_str(&sql[start..end]); + } + previous = end; + } + out.push_str(&sql[previous..]); + out +} + +fn postgres_identifier_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$') || byte >= 0x80 +} + +#[cfg(test)] +mod postgres_statement_tests { + use super::{DatabaseBackend, normalize_postgres_statement, normalize_sql_placeholders}; + + #[test] + fn native_identity_and_rebasing_are_idempotent_at_the_binding_boundary() { + for (sql, start, expected) in [ + ("SELECT $1, $1", 1, "SELECT $1, $1"), + ("SELECT $2, $1, $2", 3, "SELECT $4, $3, $4"), + ("SELECT $01, $1", 3, "SELECT $3, $3"), + ("SELECT $10, $10, $1", 1, "SELECT $10, $10, $1"), + ("SELECT ?, ?", 3, "SELECT $3, $4"), + ] { + let normalized = normalize_postgres_statement(sql, start); + assert_eq!(normalized, expected); + assert_eq!(normalize_postgres_statement(&normalized, 1), expected); + } + } + + #[test] + fn anonymous_mixed_and_static_fragment_rebasing_remain_compatible() { + for sql in ["SELECT ?, ?", "SELECT $8, ?, @P2, $8", "SELECT ?1, @p9, $4"] { + assert_eq!( + normalize_postgres_statement(sql, 4), + normalize_sql_placeholders(DatabaseBackend::Postgres, sql, 4) + ); + } + assert_eq!( + normalize_sql_placeholders(DatabaseBackend::Postgres, "value = $9", 2), + "value = $2" + ); + } + + #[test] + fn native_scanner_skips_quotes_identifiers_comments_and_dollar_strings() { + let sql = "SELECT $2, '$1?', 'it''s $9?', \"$1?\", \"a\"\"$2\", $$ $9 ? $$, $tag$ $4 ? $tag$, $π$ $8 ? $π$, E'\\\'$8?', e'\\\\$9', col$1, π$3, $2 -- $9 ?\n/* $7 /* $6 ? */ */"; + let expected = sql + .replacen("SELECT $2", "SELECT $4", 1) + .replace(", $2 --", ", $4 --"); + assert_eq!(normalize_postgres_statement(sql, 3), expected); + assert_eq!(normalize_postgres_statement(sql, 1), sql); + } + + #[test] + fn malformed_indices_and_unterminated_quotes_do_not_panic() { + assert_eq!(normalize_postgres_statement("SELECT $0", 3), "SELECT $0"); + for sql in [ + "SELECT $99999999999999999999999999999999999999", + "SELECT $", + "SELECT $tag$", + "SELECT '$1", + "SELECT E'\\", + "SELECT /* $1", + "SELECT $0", + ] { + let _ = normalize_postgres_statement(sql, usize::MAX); + } + } +} + pub trait SqlDialect { fn backend(&self) -> DatabaseBackend; fn quote_identifier(&self, identifier: &str) -> String; fn quote_identifier_path(&self, identifier: &str) -> String; fn placeholder(&self, index: usize) -> String; + /// Rebase fragment placeholders by occurrence, starting at `start_index`. + /// + /// Native labels in fragments are not binding identities: generated nested + /// fragments may reuse labels with separate values. Complete statements with + /// native PostgreSQL identities use [`super::backend::OrmBackend::normalize_sql`] + /// at the driver boundary instead. Anonymous/mixed rebasing remains unchanged. fn normalize_sql(&self, sql: &str, start_index: usize) -> String; fn count_projection(&self) -> &'static str; fn render_pagination(&self, limit: Option, offset: i64) -> String; diff --git a/crates/graphql-orm/src/graphql/orm/runtime_query.rs b/crates/graphql-orm/src/graphql/orm/runtime_query.rs index 0fc22e28..2da63c27 100644 --- a/crates/graphql-orm/src/graphql/orm/runtime_query.rs +++ b/crates/graphql-orm/src/graphql/orm/runtime_query.rs @@ -1079,7 +1079,12 @@ fn render_expr( _ => { let second = placeholder(backend, values.len() + 1, field.value_kind()); values.push(value_bind(value).expect("validated non-null literal")); - format!("substr({column}, -length({bind})) = {second}") + // Starting just after the value yields the empty suffix. + // A negative-length start alone instead selects the whole + // value when the operand is empty (SQLite treats -0 as 0). + format!( + "substr({column}, length({column}) - length({bind}) + 1) = {second}" + ) } }, } diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 5d8b00e2..0dd59962 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.0" +version = "0.39.1" dependencies = [ "agql-auth", "async-graphql", @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.39.0" +version = "0.39.1" 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 a80141d6..4be93ca4 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.0" +version = "0.39.1" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2531,7 +2531,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.39.0" +version = "0.39.1" 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 1c404321..26785a79 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.0" +version = "0.39.1" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.39.0" +version = "0.39.1" 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 d584b978..09295370 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.0" +version = "0.39.1" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.39.0" +version = "0.39.1" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/runtime_strings.rs b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/runtime_strings.rs new file mode 100644 index 00000000..c939fb32 --- /dev/null +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/runtime_strings.rs @@ -0,0 +1,201 @@ +//! Schema-bound literal string pages without handwritten query SQL or GraphQL derives. +#[cfg(any(feature = "sqlite", feature = "postgres"))] +mod supported { + use graphql_orm::prelude::*; + use repository_aggregate_consumer::Backend; + + #[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] + #[cfg_attr( + feature = "sqlite", + repository_entity( + backend = "sqlite", + table = "private_string_history", + plural = "PrivateStringHistory" + ) + )] + #[cfg_attr( + all(feature = "postgres", not(feature = "sqlite")), + repository_entity( + backend = "postgres", + table = "private_string_history", + plural = "PrivateStringHistory" + ) + )] + struct PrivateHistory { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + event: Option, + tenant: String, + } + fn field(name: &str, nullable: bool) -> RuntimeField { + RuntimeField { + id: FieldId::new(name).unwrap(), + api_name: name.into(), + physical_column: name.into(), + value_kind: RuntimeValueKind::String, + nullable, + unique: name == "id", + filterable: true, + sortable: true, + generated: false, + default: None, + } + } + pub async fn run(db: &Database) -> Result<(), Box> { + // Setup is for this example's owned database only. Read requests issue no DDL. + let plan = db + .schema() + .plan_migration_to_entities( + "example", + "owned string example", + &[PrivateHistory::metadata()], + ) + .await?; + db.schema() + .apply_migration(&plan, ApplyOptions::default()) + .await?; + for (index, event) in ["alpha", "ALPHA", "πfoo", "πfoo%", "suffix", "Ω", ""] + .iter() + .enumerate() + { + PrivateHistory::insert( + db, + CreatePrivateHistoryInput { + id: index.to_string(), + event: Some((*event).into()), + tenant: "partition-a".into(), + }, + ) + .await?; + } + PrivateHistory::insert( + db, + CreatePrivateHistoryInput { + id: "private".into(), + event: Some("πfoo".into()), + tenant: "partition-b".into(), + }, + ) + .await?; + PrivateHistory::insert( + db, + CreatePrivateHistoryInput { + id: "null".into(), + event: None, + tenant: "partition-a".into(), + }, + ) + .await?; + let collection_id = CollectionId::new("history")?; + let schema = RuntimeSchema { + format_version: 1, + collections: vec![RuntimeCollection { + id: collection_id.clone(), + api_type_name: "PrivateHistory".into(), + api_plural_name: "PrivateStringHistory".into(), + physical_table: "private_string_history".into(), + primary_key: vec![FieldId::new("id")?], + append_only: false, + retention_purge: false, + fields: vec![ + field("id", false), + field("event", true), + field("tenant", false), + ], + relations: vec![], + indexes: vec![], + composite_unique: vec![], + default_order: vec![], + }], + } + .validate()?; + let collection = schema.resolve_collection(&collection_id)?; + let event = schema.resolve_field(&collection, &FieldId::new("event")?)?; + let tenant = schema.resolve_field(&collection, &FieldId::new("tenant")?)?; + let id = schema.resolve_field(&collection, &FieldId::new("id")?)?; + let limits = RuntimeQueryLimits::default(); + let projection = schema.resolve_projection(&collection, std::slice::from_ref(&event))?; + let visibility = schema.runtime_compare( + &collection, + &tenant, + RuntimeScalarOperator::Eq, + RuntimeValue::String("partition-a".into()), + limits, + )?; + for (operator, operand, expected) in [ + (RuntimeScalarOperator::StartsWith, "π", 2), + (RuntimeScalarOperator::EndsWith, "foo", 1), + (RuntimeScalarOperator::EndsWith, "", 7), + ] { + let filter = schema.runtime_compare( + &collection, + &event, + operator, + RuntimeValue::String(operand.into()), + limits, + )?; + let filter = + schema.runtime_and(&collection, vec![filter, visibility.clone()], limits)?; + let order = schema.runtime_order( + &collection, + Some(vec![RuntimeOrderInput { + field: event.clone(), + direction: RuntimeOrderDirection::Asc, + nulls: RuntimeNullPlacement::Last, + }]), + limits, + )?; + let mut cursor = None; + let mut seen = 0; + loop { + let request = schema.runtime_read_request( + &collection, + &projection, + Some(filter.clone()), + order.clone(), + RuntimePageRequest::first(2, cursor), + true, + limits, + )?; + let page = db.execute_runtime_read(&request, None).await?; + assert_eq!(page.total_count, Some(expected)); + for edge in &page.edges { + assert!(edge.node.string(&event).is_ok()); + assert_eq!(edge.node.state(&id)?, RuntimeFieldState::Unloaded); + assert_eq!(edge.node.state(&tenant)?, RuntimeFieldState::Unloaded); + } + seen += page.edges.len() as i64; + if !page.page_info.has_next_page { + break; + } + cursor = page.page_info.end_cursor; + } + assert_eq!(seen, expected); + } + println!( + "Literal prefix/suffix pages, SQL NULL exclusion, private projection and visibility counts verified" + ); + Ok(()) + } +} +#[cfg(any(feature = "sqlite", feature = "postgres"))] +pub use supported::run; + +#[cfg(feature = "sqlite")] +#[tokio::main] +async fn main() -> Result<(), Box> { + let db = graphql_orm::db::Database::::connect_sqlite( + "sqlite::memory:", + ) + .await?; + run(&db).await?; + db.pool().close().await; + Ok(()) +} +#[cfg(not(feature = "sqlite"))] +fn main() { + println!( + "Use the test-owned PostgreSQL runtime_strings test. MSSQL runtime reads remain unsupported." + ); +} diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/tests/runtime_strings.rs b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/tests/runtime_strings.rs new file mode 100644 index 00000000..ed3ee8f1 --- /dev/null +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/tests/runtime_strings.rs @@ -0,0 +1,39 @@ +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] +#[path = "../examples/runtime_strings.rs"] +#[allow(dead_code)] +mod example; +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] +#[path = "../../../support/owned_postgres.rs"] +mod owned_postgres; + +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] +#[tokio::test] +#[ignore = "owns a disposable PostgreSQL Docker container; run explicitly"] +async fn standalone_runtime_string_consumer_executes_on_owned_postgres() +-> Result<(), Box> { + for variable in [ + "DATABASE_URL", + "TEST_DATABASE_URL", + "MSSQL_TEST_DATABASE_URL", + ] { + assert!( + std::env::var_os(variable).is_none(), + "refusing ambient database configuration" + ); + } + let mut owned = owned_postgres::OwnedPostgres::start("runtime-string-consumer")?; + let db = graphql_orm::db::Database::::connect_postgres( + &owned.url, + ) + .await?; + example::run(&db).await?; + let (database, user): (String, String) = + graphql_orm::sqlx::query_as("SELECT current_database(), current_user") + .fetch_one(db.pool()) + .await?; + assert_eq!(Some(database.as_str()), owned.url.rsplit('/').next()); + assert_eq!(user, "graphql_orm_owner"); + db.pool().close().await; + owned.cleanup()?; + Ok(()) +} diff --git a/crates/graphql-orm/tests/runtime_strings.rs b/crates/graphql-orm/tests/runtime_strings.rs new file mode 100644 index 00000000..19a7b45d --- /dev/null +++ b/crates/graphql-orm/tests/runtime_strings.rs @@ -0,0 +1,695 @@ +#![cfg(all(any(feature = "sqlite", feature = "postgres"), not(feature = "mssql")))] +//! Executable runtime string contract on databases owned exclusively by this test. + +use graphql_orm::prelude::*; +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] +#[path = "support/owned_postgres.rs"] +mod owned_postgres; +#[cfg(feature = "sqlite")] +type Backend = SqliteBackend; +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] +type Backend = PostgresBackend; + +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[cfg_attr( + feature = "sqlite", + repository_entity( + backend = "sqlite", + table = "runtime_string_samples", + plural = "StringSamples" + ) +)] +#[cfg_attr( + all(feature = "postgres", not(feature = "sqlite")), + repository_entity( + backend = "postgres", + table = "runtime_string_samples", + plural = "StringSamples" + ) +)] +struct StringSample { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + #[filterable(type = "string")] + text: Option, + #[filterable(type = "string")] + tenant: String, +} + +fn field(name: &str, kind: RuntimeValueKind, nullable: bool) -> RuntimeField { + RuntimeField { + id: FieldId::new(name).unwrap(), + api_name: name.into(), + physical_column: name.into(), + value_kind: kind, + nullable, + unique: name == "id", + filterable: true, + sortable: true, + generated: false, + default: None, + } +} +fn schema() -> ValidatedRuntimeSchema { + RuntimeSchema { + format_version: 1, + collections: vec![RuntimeCollection { + id: CollectionId::new("samples").unwrap(), + api_type_name: "StringSample".into(), + api_plural_name: "StringSamples".into(), + physical_table: "runtime_string_samples".into(), + primary_key: vec![FieldId::new("id").unwrap()], + append_only: false, + retention_purge: false, + fields: vec![ + field("id", RuntimeValueKind::String, false), + field("text", RuntimeValueKind::String, true), + field("tenant", RuntimeValueKind::String, false), + ], + relations: vec![], + indexes: vec![], + composite_unique: vec![], + default_order: vec![], + }], + } + .validate() + .unwrap() +} +struct Fixture { + db: Database, + schema: ValidatedRuntimeSchema, + rows: Vec>, + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + postgres: owned_postgres::OwnedPostgres, +} +impl Fixture { + async fn start() -> Result> { + for variable in [ + "DATABASE_URL", + "TEST_DATABASE_URL", + "MSSQL_TEST_DATABASE_URL", + ] { + assert!( + std::env::var_os(variable).is_none(), + "refusing ambient database configuration" + ); + } + #[cfg(feature = "sqlite")] + let db = Database::new( + sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await?, + ); + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + let postgres = owned_postgres::OwnedPostgres::start("runtime-strings")?; + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + let db = Database::new( + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&postgres.url) + .await?, + ); + let plan = db + .schema() + .plan_migration_to_entities( + "fixture", + "test-owned strings", + &[StringSample::metadata()], + ) + .await?; + db.schema() + .apply_migration(&plan, Default::default()) + .await?; + let rows = vec![ + Some("alpha"), + Some("ALPHA"), + Some("πfoo"), + Some("πfoo%"), + Some("suffix"), + Some("Ω"), + Some(""), + ]; + for (index, text) in rows.iter().enumerate() { + StringSample::insert( + &db, + CreateStringSampleInput { + id: format!("{:03}", index + 1), + text: text.map(str::to_owned), + tenant: "visible".into(), + }, + ) + .await?; + } + Ok(Self { + db, + schema: schema(), + rows, + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + postgres, + }) + } + fn collection(&self) -> RuntimeCollectionHandle { + self.schema + .resolve_collection(&CollectionId::new("samples").unwrap()) + .unwrap() + } + fn field(&self, name: &str) -> RuntimeFieldHandle { + self.schema + .resolve_field(&self.collection(), &FieldId::new(name).unwrap()) + .unwrap() + } + fn compare( + &self, + name: &str, + operator: RuntimeScalarOperator, + value: &str, + ) -> RuntimePredicate { + self.schema + .runtime_compare( + &self.collection(), + &self.field(name), + operator, + RuntimeValue::String(value.into()), + RuntimeQueryLimits::default(), + ) + .unwrap() + } + async fn read( + &self, + predicate: RuntimePredicate, + page: RuntimePageRequest, + auth: Option<&DbAuthContext>, + ) -> RuntimeConnection { + let collection = self.collection(); + let projection = self + .schema + .resolve_projection(&collection, &[self.field("id"), self.field("text")]) + .unwrap(); + let limits = RuntimeQueryLimits::default(); + let order = self + .schema + .runtime_order( + &collection, + Some(vec![RuntimeOrderInput { + field: self.field("text"), + direction: RuntimeOrderDirection::Asc, + nulls: RuntimeNullPlacement::Last, + }]), + limits, + ) + .unwrap(); + let request = self + .schema + .runtime_read_request( + &collection, + &projection, + Some(predicate), + order, + page, + true, + limits, + ) + .unwrap(); + self.db.execute_runtime_read(&request, auth).await.unwrap() + } + fn ids(&self, connection: &RuntimeConnection) -> Vec { + connection + .edges + .iter() + .map(|edge| { + edge.node + .string(&self.field("id")) + .unwrap() + .parse() + .unwrap() + }) + .collect() + } + async fn finish(self) -> Result<(), Box> { + // Verify the connection's identity before closing it; no external file is deleted. + #[cfg(feature = "sqlite")] + { + let (_, _, path): (i64, String, String) = sqlx::query_as("PRAGMA database_list") + .fetch_one(self.db.pool()) + .await?; + assert!( + path.is_empty(), + "expected an exclusively owned in-memory database" + ); + } + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + { + let (database, user): (String, String) = + sqlx::query_as("SELECT current_database(), current_user") + .fetch_one(self.db.pool()) + .await?; + assert_eq!( + Some(database.as_str()), + self.postgres.url.rsplit('/').next() + ); + assert_eq!(user, "graphql_orm_owner"); + } + self.db.pool().close().await; + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + { + let mut postgres = self.postgres; + postgres.cleanup()?; + } + Ok(()) + } +} + +#[tokio::test] +#[cfg_attr( + all(feature = "postgres", not(feature = "sqlite")), + ignore = "owns a disposable PostgreSQL Docker container; run explicitly" +)] +async fn runtime_string_predicates_execute_and_paginate() -> Result<(), Box> +{ + let mut fixture = Fixture::start().await?; + use RuntimeScalarOperator as O; + // The seven-row consumer reproduction, without policy lowering. + for (operator, operand, count) in [ + (O::StartsWith, "π", 2), + (O::EndsWith, "foo", 1), + (O::EndsWith, "", 7), + (O::StartsWith, "", 7), + ] { + let result = fixture + .read( + fixture.compare("text", operator, operand), + RuntimePageRequest::first(100, None), + None, + ) + .await; + assert_eq!(result.total_count, Some(count)); + assert_eq!(result.edges.len() as i64, count); + } + for text in [ + None, + Some("%_\\' $1 ? @P2"), + Some("πfoo"), + Some("\\end"), + Some("a_b"), + Some("ab%"), + Some("line\nπ"), + Some("🦀π"), + ] { + StringSample::insert( + &fixture.db, + CreateStringSampleInput { + id: format!("{:03}", fixture.rows.len() + 1), + text: text.map(str::to_owned), + tenant: "visible".into(), + }, + ) + .await?; + fixture.rows.push(text); + } + // Literal wildcards, Unicode, case, control characters, longer-than-value and null behavior. + for operator in [O::StartsWith, O::EndsWith, O::Contains] { + for operand in [ + "", + "a", + "A", + "π", + "foo", + "%", + "_", + "\\", + "' $1 ? @P2", + "🦀", + "\nπ", + "longer-than-any-fixture-string-without-a-match", + ] { + let result = fixture + .read( + fixture.compare("text", operator, operand), + RuntimePageRequest::first(100, None), + None, + ) + .await; + let mut actual = fixture.ids(&result); + actual.sort(); + let expected: Vec = fixture + .rows + .iter() + .enumerate() + .filter_map(|(index, value)| { + value + .filter(|text| match operator { + O::StartsWith => text.starts_with(operand), + O::EndsWith => text.ends_with(operand), + O::Contains => text.contains(operand), + _ => unreachable!(), + }) + .map(|_| index as i64 + 1) + }) + .collect(); + assert_eq!( + actual, expected, + "literal string predicate differs from its evaluator" + ); + assert_eq!(result.total_count, Some(expected.len() as i64)); + } + } + let limits = RuntimeQueryLimits::default(); + let collection = fixture.collection(); + let starts = fixture.compare("text", O::StartsWith, "π"); + let ends = fixture.compare("text", O::EndsWith, "foo"); + let either = fixture.schema.runtime_or( + &collection, + vec![starts.clone(), fixture.compare("text", O::StartsWith, "a")], + limits, + )?; + let not_ends = fixture + .schema + .runtime_not(&collection, ends.clone(), limits)?; + let composed = fixture + .schema + .runtime_and(&collection, vec![either, not_ends], limits)?; + let result = fixture + .read(composed, RuntimePageRequest::first(100, None), None) + .await; + let mut ids = fixture.ids(&result); + ids.sort(); + assert_eq!(ids, vec![1, 4, 12, 13]); + let not_empty_suffix = fixture.schema.runtime_not( + &collection, + fixture.compare("text", O::EndsWith, ""), + limits, + )?; + assert!( + fixture + .read(not_empty_suffix, RuntimePageRequest::first(100, None), None) + .await + .edges + .is_empty(), + "NOT must preserve SQL null's unknown state" + ); + let nullable = fixture.schema.runtime_or( + &collection, + vec![ + ends, + fixture + .schema + .runtime_is_null(&collection, &fixture.field("text"), true, limits)?, + ], + limits, + )?; + assert_eq!( + fixture + .read(nullable, RuntimePageRequest::first(100, None), None) + .await + .total_count, + Some(3) + ); + + // Independently bound authorization predicate applies before count/continuation. + StringSample::insert( + &fixture.db, + CreateStringSampleInput { + id: "100".into(), + text: Some("πfoo".into()), + tenant: "denied".into(), + }, + ) + .await?; + let authorized = fixture.schema.runtime_and( + &collection, + vec![starts, fixture.compare("tenant", O::Eq, "visible")], + limits, + )?; + let whole = fixture + .read( + authorized.clone(), + RuntimePageRequest::first(100, None), + None, + ) + .await; + let mut forward = vec![]; + let mut cursor = None; + loop { + let page = fixture + .read( + authorized.clone(), + RuntimePageRequest::first(1, cursor), + None, + ) + .await; + assert_eq!(page.total_count, whole.total_count); + assert!( + page.edges + .iter() + .all(|edge| edge.node.state(&fixture.field("tenant")).unwrap() + == RuntimeFieldState::Unloaded) + ); + forward.extend(fixture.ids(&page)); + if !page.page_info.has_next_page { + break; + } + cursor = page.page_info.end_cursor; + } + assert_eq!(forward, fixture.ids(&whole)); + let mut backward = vec![]; + let mut cursor = None; + loop { + let page = fixture + .read( + authorized.clone(), + RuntimePageRequest::last(1, cursor), + None, + ) + .await; + assert_eq!(page.total_count, whole.total_count); + backward.extend(fixture.ids(&page)); + if !page.page_info.has_previous_page { + break; + } + cursor = page.page_info.start_cursor; + } + backward.reverse(); + assert_eq!(backward, forward); + // Malformed cursors fail before any pool access, with safe value-free errors. + #[cfg(feature = "sqlite")] + let closed_pool = sqlx::sqlite::SqlitePoolOptions::new().connect_lazy("sqlite::memory:")?; + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + let closed_pool = sqlx::postgres::PgPoolOptions::new().connect_lazy(&fixture.postgres.url)?; + closed_pool.close().await; + let closed_db = Database::::new(closed_pool); + let projection = fixture + .schema + .resolve_projection(&collection, &[fixture.field("text")])?; + let request = fixture.schema.runtime_read_request( + &collection, + &projection, + Some(authorized), + fixture.schema.runtime_order(&collection, None, limits)?, + RuntimePageRequest::first(1, Some("caller-value-not-for-diagnostics".into())), + true, + limits, + )?; + let error = closed_db + .execute_runtime_read(&request, None) + .await + .unwrap_err(); + assert_eq!(error.code(), RuntimeQueryErrorCode::CursorInvalid); + assert!(!format!("{error:?} {error}").contains("caller-value-not-for-diagnostics")); + // Multiple static generated clauses and nested native fragments retain their binding semantics. + let static_rows = StringSample::query(&fixture.db) + .filter(StringSampleWhereInput { + tenant: Some(StringFilter { + eq: Some("visible".into()), + ..Default::default() + }), + or: Some(vec![ + StringSampleWhereInput { + text: Some(StringFilter { + eq: Some("alpha".into()), + ..Default::default() + }), + ..Default::default() + }, + StringSampleWhereInput { + text: Some(StringFilter { + eq: Some("πfoo".into()), + ..Default::default() + }), + ..Default::default() + }, + ]), + ..Default::default() + }) + .fetch_all() + .await?; + let mut ids: Vec<_> = static_rows + .into_iter() + .map(|row| row.id.parse::().unwrap()) + .collect(); + ids.sort(); + assert_eq!(ids, vec![1, 3, 10]); + let unchanged = fixture + .db + .schema() + .plan_migration_to_entities( + "read-replan", + "queries preserve physical schema", + &[StringSample::metadata()], + ) + .await?; + assert!( + unchanged.steps.is_empty(), + "runtime reads must leave the physical target unchanged" + ); + #[cfg(all(feature = "postgres", not(feature = "sqlite")))] + postgres_binding_and_rls(&fixture).await?; + fixture.finish().await +} + +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] +async fn postgres_binding_and_rls(fixture: &Fixture) -> Result<(), Box> { + // Audit every backend normalization entry point, including transaction DML. + let statement = "SELECT $2::bigint AS later, $1::bigint AS first, $2::bigint AS again"; + let values = [SqlValue::Int(17), SqlValue::Int(29)]; + let rows = PostgresBackend::fetch_rows(fixture.db.pool(), statement, &values).await?; + assert_eq!(PostgresBackend::try_get_i64(&rows[0], "later")?, 29); + assert_eq!(PostgresBackend::try_get_i64(&rows[0], "first")?, 17); + assert_eq!(PostgresBackend::try_get_i64(&rows[0], "again")?, 29); + let mut transaction = fixture.db.pool().begin().await?; + let rows = ::fetch_rows_on( + transaction.as_mut(), + statement.into(), + values.to_vec(), + ) + .await?; + assert_eq!(PostgresBackend::try_get_i64(&rows[0], "again")?, 29); + ::execute_with_binds_on( + transaction.as_mut(), + "UPDATE runtime_string_samples SET tenant = $1 WHERE tenant = $1".into(), + vec![SqlValue::String("visible".into())], + ) + .await?; + transaction.rollback().await?; + ::execute_with_binds( + fixture.db.pool(), + "UPDATE runtime_string_samples SET tenant = $1 WHERE tenant = $1", + &[SqlValue::String("visible".into())], + ) + .await?; + + for statement in [ + "CREATE ROLE string_reader NOLOGIN NOSUPERUSER NOBYPASSRLS", + "GRANT SELECT ON runtime_string_samples TO string_reader", + "ALTER TABLE runtime_string_samples ENABLE ROW LEVEL SECURITY", + "CREATE POLICY string_partition ON runtime_string_samples FOR SELECT TO string_reader USING (tenant = current_setting('app.tenant_id', true))", + ] { + sqlx::query(statement).execute(fixture.db.pool()).await?; + } + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query("SET ROLE string_reader") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect(&fixture.postgres.url) + .await?; + let db = Database::::new(pool); + let user: String = sqlx::query_scalar("SELECT current_user") + .fetch_one(db.pool()) + .await?; + assert_eq!(user, "string_reader"); + let visible = DbAuthContext::from_parts("reader-a", vec![], vec![], Some("visible".into())); + let denied = DbAuthContext::from_parts("reader-b", vec![], vec![], Some("denied".into())); + let absent = DbAuthContext::from_parts("reader-c", vec![], vec![], Some("absent".into())); + let collection = fixture.collection(); + let projection = fixture + .schema + .resolve_projection(&collection, &[fixture.field("id")])?; + let limits = RuntimeQueryLimits::default(); + let order = fixture.schema.runtime_order(&collection, None, limits)?; + for count in [false, true] { + for operator in [ + RuntimeScalarOperator::StartsWith, + RuntimeScalarOperator::EndsWith, + ] { + let filter = fixture.compare("text", operator, ""); + let request = fixture.schema.runtime_read_request( + &collection, + &projection, + Some(filter.clone()), + order.clone(), + RuntimePageRequest::first(2, None), + count, + limits, + )?; + let first = db.execute_runtime_read(&request, Some(&visible)).await?; + assert_eq!(first.total_count, count.then_some(14)); + assert_eq!(fixture.ids(&first), vec![1, 2]); + let next = fixture.schema.runtime_read_request( + &collection, + &projection, + Some(filter.clone()), + order.clone(), + RuntimePageRequest::first(2, first.page_info.end_cursor), + count, + limits, + )?; + let next = db.execute_runtime_read(&next, Some(&visible)).await?; + assert_eq!(fixture.ids(&next), vec![3, 4]); + assert_eq!(next.total_count, count.then_some(14)); + let other = db.execute_runtime_read(&request, Some(&denied)).await?; + assert_eq!(fixture.ids(&other), vec![100]); + assert_eq!(other.total_count, count.then_some(1)); + assert!( + db.execute_runtime_read(&request, Some(&absent)) + .await? + .edges + .is_empty() + ); + // The previous transaction's SET LOCAL auth context must not leak. + assert!( + db.execute_runtime_read(&request, None) + .await? + .edges + .is_empty() + ); + } + } + db.pool().close().await; + Ok(()) +} + +#[cfg(all(feature = "postgres", not(feature = "sqlite")))] +#[test] +fn postgres_complete_statement_preserves_native_parameter_identity() { + assert_eq!( + PostgresBackend::normalize_sql("SELECT $1, $1", 1), + "SELECT $1, $1" + ); + assert_eq!( + PostgresBackend::normalize_sql("SELECT $2, $1, $2", 3), + "SELECT $4, $3, $4" + ); + assert_eq!( + PostgresBackend::normalize_sql("SELECT ?, ?", 4), + "SELECT $4, $5" + ); + // Mixed forms retain occurrence-based compatibility; they do not promise shared identity. + assert_eq!( + PostgresBackend::normalize_sql("SELECT $8, ?, @P2, $8", 3), + "SELECT $3, $4, $5, $6" + ); + assert_eq!( + DatabaseBackend::Postgres.normalize_sql("value = $9", 2), + "value = $2", + "static fragments still rebase by occurrence" + ); + let quoted = "SELECT $2, '$1?', \"$1?\", $$ $9 ? $$, $tag$ $4 ? $tag$, E'\\\'$8?', $2 -- $9 ?\n/* $7 /* $6 ? */ */"; + assert_eq!(PostgresBackend::normalize_sql(quoted, 1), quoted); +} diff --git a/docs/reference/graphql-orm/runtime-queries.md b/docs/reference/graphql-orm/runtime-queries.md index 51834ffe..85deecb2 100644 --- a/docs/reference/graphql-orm/runtime-queries.md +++ b/docs/reference/graphql-orm/runtime-queries.md @@ -3,7 +3,7 @@ title: "Runtime select, filter, order, and keyset reads" kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-08-01 +last_reviewed: 2026-10-06 review_by: 2027-02-01 supersedes: [] --- @@ -105,7 +105,11 @@ String equality, range comparison, matching, and ordering are case-sensitive. Order and range comparisons use SQLite `BINARY` and PostgreSQL `C` collation. Pattern operators use backend functions and bind the whole literal, so `%`, `_`, quotes, comments, placeholder-shaped text, control characters, and -Unicode remain data. Locale case folding and Unicode normalization are not +Unicode remain data. Empty prefixes, suffixes and containment operands match +all non-null strings, including the empty string. A longer operand cannot match +a shorter value. SQL NULL remains unknown under NOT as well as direct comparison. +Embedded U+0000 is outside the portable text contract: PostgreSQL TEXT rejects +it and SQLite string-function length behavior differs. Locale case folding and Unicode normalization are not claimed. ## Ordering and cursors @@ -155,3 +159,42 @@ Relation predicates on parent collections, dynamic-schema aggregates beyond exact count, dynamic GraphQL, runtime writes, and runtime migrations remain deferred. Static generated entities use the separate [typed grouped aggregate](typed-aggregates.md) API. + +## PostgreSQL parameter identity and verification + +At the complete-statement binding boundary, `PostgresBackend::normalize_sql` +preserves native `$n` identity (including repeats and out-of-order references). +Starting at 1 is idempotent; a positive start N shifts each native index by N−1. +Native scanning skips ordinary/escaped strings, quoted identifiers, PostgreSQL +dollar strings, line comments and nested block comments. Caller operands always +remain bound data; neither normalization nor runtime rendering inserts them into +SQL or safe diagnostics. + +Anonymous placeholders rebase by occurrence. Mixed anonymous/native/foreign +forms retain their legacy occurrence rebasing and do not promise shared identity. +`SqlDialect::normalize_sql` intentionally retains occurrence rebasing for static +fragments, whose generated native labels can be arbitrary or restart inside +nested filters. Those fragments are composed before driver binding. No public +signature or static generated SDL/cursor format changes in 0.39.1. + +The standalone [runtime string consumer](../../../crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/runtime_strings.rs) +uses schema-bound predicates, an independently bound visibility predicate, +private projection and bounded pages with total counts. It has no direct +async-graphql dependency or handwritten query SQL. Run it on owned SQLite: + +```sh +CARGO_BUILD_JOBS=2 cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example runtime_strings +CARGO_BUILD_JOBS=2 cargo test -p graphql-orm --locked --no-default-features --features sqlite --lib --test runtime_strings --test runtime_queries --test query_ir +CARGO_BUILD_JOBS=2 cargo test -p graphql-orm --locked --no-default-features --features postgres --lib --test runtime_strings --test query_ir -- --include-ignored --test-threads=1 +CARGO_BUILD_JOBS=2 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 +``` + +PostgreSQL execution tests own a loopback Docker PostgreSQL 17 container and +verify its immutable container identity/ownership label before cleanup. SQLite +uses an exclusively owned in-memory pool and verifies it has no on-disk path +before closing it. Ambient application database URLs are refused. PostgreSQL +coverage includes a verified nonowner/NOBYPASSRLS role, current `DbAuthContext`, +authenticated pages/counts and absence of transaction-local identity leakage. +MSSQL evidence is compilation/rendering/capability rejection, not live execution. +Separate page requests still do not pin a snapshot under concurrent writes; +this correction changes no existing pagination or authorization guarantee. diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 64322a2d..c383c0f7 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.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.39.1` | `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.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.39.1` | `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 00ae8a74..521136f4 100755 --- a/scripts/run-owned-database-lanes.sh +++ b/scripts/run-owned-database-lanes.sh @@ -27,13 +27,19 @@ cd "${repository_root}" run_sqlite() { cargo test -p graphql-orm --no-default-features --features sqlite \ - --test grouped_aggregates --test complete_group_pages --test portable_group_pages --test host_timestamps --locked + --test runtime_strings --test grouped_aggregates --test complete_group_pages --test portable_group_pages --test host_timestamps --locked cargo test --manifest-path \ crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml \ --no-default-features --features sqlite --locked } run_postgres() { + cargo test -p graphql-orm --no-default-features --features postgres \ + --test runtime_strings --locked -- --include-ignored --test-threads=1 + cargo test --manifest-path \ + crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml \ + --no-default-features --features postgres --locked --test runtime_strings \ + -- --ignored --test-threads=1 cargo test -p graphql-orm --no-default-features --features postgres \ --test grouped_aggregates_postgres --test portable_group_pages --test host_timestamps --locked -- --ignored --test-threads=1 cargo test --manifest-path \