diff --git a/.github/workflows/integration-postgres.yaml b/.github/workflows/integration-postgres.yaml index 1543702c..465eac06 100644 --- a/.github/workflows/integration-postgres.yaml +++ b/.github/workflows/integration-postgres.yaml @@ -44,6 +44,7 @@ jobs: cargo test --test postgres_transport --no-default-features --features postgres --verbose cargo test --test distributed_read_model --no-default-features --features postgres --verbose cargo test --test sql_lock_manager --no-default-features --features postgres --verbose + cargo test --test graphql_literal_text --no-default-features --features graphql,postgres --verbose - name: Verify source fencing and snapshot rebuild against PostgreSQL env: DISTRIBUTED_SNAPSHOT_TEST_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/distributed_snapshots diff --git a/README.md b/README.md index c555924c..bbb49214 100644 --- a/README.md +++ b/README.md @@ -1921,6 +1921,31 @@ distributed = { version = "0.1", features = ["graphql", "postgres"] } `distributed::graphql::{naming,sdl}` compile without the feature so `distributed schema --format graphql` works in tooling crates. +### Literal text search + +Use `_icontains` for a case-insensitive substring search on a String column: + +```graphql +query SearchTodos($q: String! = "") @load @live { + todos(where: { title: { _icontains: $q } }, limit: 20) { + todo_id + title + } +} +``` + +A colocated binding can pass `q: searchParam('q')` directly. Callers do not +construct `%patterns%`: `%`, `_`, backslash, quotes and the escape character +are literal text. The query engine binds an escaped pattern on both PostgreSQL +and SQLite. An empty string matches every non-null string; use a bounded query +and show results only after a submitted search when building a search form. + +Case matching follows the database: PostgreSQL uses its configured ILIKE +behavior; SQLite's default LIKE folds ASCII, not arbitrary Unicode. The browser +replica therefore leaves local match evaluation uncertain and relies on the +authorized server result. `_ilike` still accepts deliberate wildcard patterns; +`_contains` remains the PostgreSQL JSON containment operator. + ### Scope | In | Out | diff --git a/distributed_cli/src/client_compiler/manifest/model_validation.rs b/distributed_cli/src/client_compiler/manifest/model_validation.rs index dcf9db69..7c5a58f9 100644 --- a/distributed_cli/src/client_compiler/manifest/model_validation.rs +++ b/distributed_cli/src/client_compiler/manifest/model_validation.rs @@ -1134,6 +1134,7 @@ pub(crate) fn validate_filter_fields( | "_is_null" | "_like" | "_ilike" + | "_icontains" | "_contains" | "_contained_in" | "_has_key" diff --git a/distributed_cli/tests/cli_client.rs b/distributed_cli/tests/cli_client.rs index 1d814a2a..53d29d90 100644 --- a/distributed_cli/tests/cli_client.rs +++ b/distributed_cli/tests/cli_client.rs @@ -382,6 +382,53 @@ fn snapshot_tree(root: &Path) -> BTreeMap> { snapshot } +#[test] +fn literal_text_search_generates_a_typed_load_island() { + let project = project_dir("client-literal-text"); + fs::write( + project.join("client-manifest.json"), + ROLE_MANIFEST + .replace("\"_ilike\"", "\"_ilike\", \"_icontains\"") + // Adding a filter changes the selected schema contract. + .replace( + "sha256:758a97e4f7e1e538e8be86d24abd3d50a8da2d5813d29abd7a04bfa092d05189", + "sha256:9143345e62737d38ed28e997fd76f434b18ae6b9c794b2c06477d1879fcfc136", + ), + ) + .unwrap(); + write_document( + &project, + "queries/search.graphql", + r#" + query SearchTodos($q: String! = "") @load { + todos(where: {title: {_icontains: $q}}, limit: 20) { id title } + } + "#, + ); + assert_success( + &generate(&project, "queries/*.graphql", &[]), + "literal search generation", + ); + assert_success( + &generate(&project, "queries/*.graphql", &["--check"]), + "literal search drift check", + ); + let inventory = fs::read_to_string(project.join("generated/islands.json")).unwrap(); + assert!(inventory.contains("SearchTodos")); + assert!(inventory.contains("\"load\": true")); + assert!(inventory.contains("String!")); + write_document( + &project, + "queries/search.graphql", + r#" + query SearchTodos { todos(where: {title: {_icontains: 1}}) { id } } + "#, + ); + assert!(!generate(&project, "queries/*.graphql", &[]) + .status + .success()); +} + #[test] fn generate_then_check_accepts_the_exact_artifact_tree() { let project = project_dir("client-generate-check"); diff --git a/distributed_cli/tests/cli_manifest.rs b/distributed_cli/tests/cli_manifest.rs index 9e53f197..86106da2 100644 --- a/distributed_cli/tests/cli_manifest.rs +++ b/distributed_cli/tests/cli_manifest.rs @@ -74,7 +74,7 @@ fn client_manifest_uses_service_surface_export() { assert_eq!(manifest["surface"]["name"], "user"); assert_eq!( manifest["schema_fingerprint"], - "sha256:8f91d3fc7b6d916241b959f6bacd6228eeb06586e9681739ba3e986b4092e134" + "sha256:64007d9696cdd73fdbd3b12133155c53b709970ef2411631d9f66189c3c8fc90" ); assert_eq!( manifest["protocol_fingerprint"], diff --git a/js/src/replica/identity/codec.ts b/js/src/replica/identity/codec.ts index 24f81b25..b8649be2 100644 --- a/js/src/replica/identity/codec.ts +++ b/js/src/replica/identity/codec.ts @@ -756,7 +756,7 @@ export function canonicalizeFilterComparison( canonical.push([operator, operand]); continue; } - if (operator === '_like' || operator === '_ilike' || operator === '_has_key') { + if (operator === '_like' || operator === '_ilike' || operator === '_icontains' || operator === '_has_key') { if (typeof operand !== 'string') { variableValueInvalid(operatorPath, 'expected string or null'); } @@ -972,7 +972,7 @@ export function validateFilterOperatorContract( path: string ): void { if ( - (operator === '_like' || operator === '_ilike') && + (operator === '_like' || operator === '_ilike' || operator === '_icontains') && scalar !== 'String' ) { variableCodecInvalid(path); diff --git a/js/src/replica/identity/constants.ts b/js/src/replica/identity/constants.ts index e4e26ef4..a2356384 100644 --- a/js/src/replica/identity/constants.ts +++ b/js/src/replica/identity/constants.ts @@ -12,8 +12,8 @@ export const FILTER_OPERATORS = new Set([ '_is_null', '_like', '_ilike', + '_icontains', '_contains', '_contained_in', '_has_key' ]); - diff --git a/js/src/replica/query-plan/filter.ts b/js/src/replica/query-plan/filter.ts index d040f3a5..5307da7c 100644 --- a/js/src/replica/query-plan/filter.ts +++ b/js/src/replica/query-plan/filter.ts @@ -484,6 +484,7 @@ export function evaluateComparison( if ( operator === '_like' || operator === '_ilike' || + operator === '_icontains' || operator === '_contains' || operator === '_contained_in' || operator === '_has_key' @@ -961,6 +962,7 @@ export function isFilterOperator(value: string): value is ReplicaFilterOperator value === '_is_null' || value === '_like' || value === '_ilike' || + value === '_icontains' || value === '_contains' || value === '_contained_in' || value === '_has_key' @@ -971,7 +973,7 @@ export function isOperatorScalarCompatible( scalar: string, operator: ReplicaFilterOperator ): boolean { - if (operator === '_like' || operator === '_ilike') { + if (operator === '_like' || operator === '_ilike' || operator === '_icontains') { return scalar === 'String'; } if ( diff --git a/js/src/replica/types.ts b/js/src/replica/types.ts index b39705c4..384cb2f8 100644 --- a/js/src/replica/types.ts +++ b/js/src/replica/types.ts @@ -217,6 +217,7 @@ export type ReplicaFilterOperator = | '_is_null' | '_like' | '_ilike' + | '_icontains' | '_contains' | '_contained_in' | '_has_key'; diff --git a/js/tests/replica-query-plan.test.mjs b/js/tests/replica-query-plan.test.mjs index 043b3247..c4c4a5cc 100644 --- a/js/tests/replica-query-plan.test.mjs +++ b/js/tests/replica-query-plan.test.mjs @@ -52,7 +52,8 @@ const FILTER_FIELDS = Object.freeze([ '_in', '_nin', '_like', - '_ilike' + '_ilike', + '_icontains' ]) }), Object.freeze({ @@ -387,6 +388,10 @@ test('query-plan artifacts require exact scalar-codec pairs', () => { test('unsafe operators, codecs, and absent canonical fields explain why evaluation is unknown', () => { const record = { id: 'todo-1', priority: 3, active: true, title: 'one', payload: null }; + assert.equal( + evaluateReplicaFilter(filterArtifact(literal({ title: { _icontains: 'ONE' } })), record).reason.code, + 'unsupported_operator' + ); assert.equal( evaluateReplicaFilter( filterArtifact(literal({ title: { _like: 'o%' } })), diff --git a/js/tests/replica-variable-codec.test.mjs b/js/tests/replica-variable-codec.test.mjs index ea02fce6..f180ef95 100644 --- a/js/tests/replica-variable-codec.test.mjs +++ b/js/tests/replica-variable-codec.test.mjs @@ -104,7 +104,7 @@ const variableCodec = Object.freeze({ scalar: 'String', codec: 'string', nullable: false, - operators: Object.freeze(['_eq', '_like', '_ilike']) + operators: Object.freeze(['_eq', '_like', '_ilike', '_icontains']) }) ]), relationships: Object.freeze([ @@ -319,6 +319,21 @@ test('compiler variable codec canonicalizes ID, lists, filters, order, and key o assert.equal(JSON.stringify(singleton), JSON.stringify(expanded)); }); +test('literal text filters preserve operands and reject non-string values', () => { + for (const q of ['', '%_!\\\'OR 1=1', 'Älice']) { + const variables = { id: '1', where: { title: { _icontains: q } } }; + assert.deepEqual(canonicalizeOperationVariables(CodecArtifact, variables), variables); + } + for (const q of [12, true, {}, []]) { + assert.throws(() => canonicalizeOperationVariables(CodecArtifact, { + id: '1', where: { title: { _icontains: q } } + }), /expected string/); + } + assert.throws(() => canonicalizeOperationVariables(CodecArtifact, { + id: '1', where: { priority: { _icontains: '1' } } + })); +}); + test('scalar canonicalization is deterministic and preserves omission versus null', () => { const canonical = canonicalizeOperationVariables(CodecArtifact, { id: -0, diff --git a/src/graphql/client_manifest/tests.rs b/src/graphql/client_manifest/tests.rs index 08a3efe6..c0cb568b 100644 --- a/src/graphql/client_manifest/tests.rs +++ b/src/graphql/client_manifest/tests.rs @@ -763,7 +763,7 @@ fn role_manifest_is_deterministic_and_hides_denied_identity_and_commands() { assert_eq!(first.schema_fingerprint, second.schema_fingerprint); assert_eq!( first.schema_fingerprint, - "sha256:d170cb2de47ed71c0127206a5a42970abff278cec2bcb494551da554053f3a83" + "sha256:d0e8509749ca7a9a4a1b48a785e448bb5e798e6376f920bc15ec9c07ac314d7c" ); assert_eq!( first.protocol_fingerprint, diff --git a/src/graphql/compile/filter.rs b/src/graphql/compile/filter.rs index 07774070..c0534281 100644 --- a/src/graphql/compile/filter.rs +++ b/src/graphql/compile/filter.rs @@ -481,6 +481,25 @@ fn compile_client_op( ) -> Result { let col_ref = format!("{alias}.\"{column}\""); match op { + "_icontains" => { + let Value::String(text) = rhs else { + return Err("_icontains requires a string".into()); + }; + // Use an explicit escape character on both dialects. The operand + // stays a bind parameter; user text never becomes SQL or a wildcard. + let mut pattern = String::from("%"); + for ch in text.chars() { + if matches!(ch, '!' | '%' | '_') { + pattern.push('!'); + } + pattern.push(ch); + } + pattern.push('%'); + binds.push(value_to_bind(&Value::String(pattern), column_type)?); + let ph = placeholder(inner.dialect, binds.len()); + let sql_op = inner.dialect.ops().ilike_op; + Ok(format!("{col_ref} {sql_op} {ph} ESCAPE '!'")) + } "_is_null" => { let yes = matches!(rhs, Value::Boolean(true)); Ok(if yes { diff --git a/src/graphql/engine/tests.rs b/src/graphql/engine/tests.rs index 6dcf0636..ea2b2d2d 100644 --- a/src/graphql/engine/tests.rs +++ b/src/graphql/engine/tests.rs @@ -1304,7 +1304,7 @@ mod client_surface_parity_tests { } assert_eq!( manifest.schema_fingerprint, - "sha256:7a456dac4fce3e4ccba7255baccad3e70e891f71ea476c1560631c9b2e5cf1da" + "sha256:cce38712ab7d84a934fa10bf1272d292d20f746aea5bf2e485fe15b2527517c0" ); } @@ -1439,7 +1439,7 @@ mod client_surface_parity_tests { assert_eq!(manifest.service_id, "orders-service"); assert_eq!( manifest.schema_fingerprint, - "sha256:7a599939c85d2f444428431675655d371929234965bdcfb6c81eba244694e6d0" + "sha256:9c0150e725493dc1e5ddbb3fdfc8b651cad399f099908cbcac326e514389c48a" ); } @@ -1845,10 +1845,13 @@ mod client_surface_parity_tests { let actual_manifest = sha256(&manifest_json); let actual_static_sdl = sha256(static_sdl.as_bytes()); let actual_runtime_sdl = sha256(runtime_sdl.as_bytes()); - assert_eq!(actual_manifest, expected.manifest, "{dialect:?}/{role}"); - assert_eq!(actual_static_sdl, expected.static_sdl, "{dialect:?}/{role}"); assert_eq!( - actual_runtime_sdl, expected.runtime_sdl, + ( + actual_manifest.as_str(), + actual_static_sdl.as_str(), + actual_runtime_sdl.as_str() + ), + (expected.manifest, expected.static_sdl, expected.runtime_sdl), "{dialect:?}/{role}" ); } @@ -1882,30 +1885,30 @@ mod client_surface_parity_tests { #[cfg(feature = "sqlite")] const SQLITE_RESTRICTED_GOLDENS: ArtifactGoldens = ArtifactGoldens { - manifest: "sha256:a2b97c4156fd9e6c99c3ad516af5cf2c57781fa4f13757902685989a691b2515", - static_sdl: "sha256:6ac07aaa60a726bdde7c1632125a3ab933766931187654dacc2dd4ab19ffece1", - runtime_sdl: "sha256:fb41d43fa1b58fec7224d768124abc8bb0b30407e1ee56b44f620ddc8d8c0007", + manifest: "sha256:c1c3dd3f242f82225b486542b1737976f791e019f50e015f8755f48d70685f9a", + static_sdl: "sha256:03252ba251b1ddac611fe567d816f780f0876f9f6ce263be95a3480f88fc2283", + runtime_sdl: "sha256:3d099b8c0b27f0dcbdd677199f767fcc5993071e06b2c0a7d4aa02caf4e5f4ac", }; #[cfg(feature = "sqlite")] const SQLITE_ADMIN_GOLDENS: ArtifactGoldens = ArtifactGoldens { - manifest: "sha256:4619fb257bd0b3b0155ebdff5f34a8d15f6c23bc0fc8a99459e7d56aad444932", - static_sdl: "sha256:4d7ba7651ff632d32e538a083165ff718e094858c6c5bdb2705d38d9f0665e2f", - runtime_sdl: "sha256:be0f13249ec0cb394457572097a1d201649deeec1eba9c980f48b1751a13062b", + manifest: "sha256:94345b9e29bccaa77aa5083eb73014a1a102db7ce1f03f022a2f0e242b5c84d2", + static_sdl: "sha256:128b85bcd6485d14de62b0976e9f12e8b35ad9e7d96a5627d1edcfcabdb591b3", + runtime_sdl: "sha256:c0b6d600d353357ab4f393897fb6b7ee51f69546f7a4cc4b6786e42abe621f5c", }; #[cfg(feature = "postgres")] const POSTGRES_RESTRICTED_GOLDENS: ArtifactGoldens = ArtifactGoldens { - manifest: "sha256:a2b97c4156fd9e6c99c3ad516af5cf2c57781fa4f13757902685989a691b2515", - static_sdl: "sha256:6ac07aaa60a726bdde7c1632125a3ab933766931187654dacc2dd4ab19ffece1", - runtime_sdl: "sha256:fb41d43fa1b58fec7224d768124abc8bb0b30407e1ee56b44f620ddc8d8c0007", + manifest: "sha256:c1c3dd3f242f82225b486542b1737976f791e019f50e015f8755f48d70685f9a", + static_sdl: "sha256:03252ba251b1ddac611fe567d816f780f0876f9f6ce263be95a3480f88fc2283", + runtime_sdl: "sha256:3d099b8c0b27f0dcbdd677199f767fcc5993071e06b2c0a7d4aa02caf4e5f4ac", }; #[cfg(feature = "postgres")] const POSTGRES_ADMIN_GOLDENS: ArtifactGoldens = ArtifactGoldens { - manifest: "sha256:66cddbcf76eac385f94de011497fe4752f7fb6102de28c14c24d83c67367788b", - static_sdl: "sha256:d128621aea3ffa6c38abc44a9b7f3b2716aada4af4b579b10e7c415040281751", - runtime_sdl: "sha256:ae58e2ed718955a6d400197a4cfa2f363d3057c80c6f4e528d10615a3df804cb", + manifest: "sha256:8ff2691f33789c8267b1338603b2ee3544841f8b17cc5b90ea2e851381dd36de", + static_sdl: "sha256:afe92660c1700845ed5f3e0ddaacc4b481799c39f86b8eacecb46d1b8f99d421", + runtime_sdl: "sha256:de4885736fdf22a57ccc55160d63b6d1a7fdb33728dec399e8a81d54ce7e8c09", }; #[cfg(feature = "sqlite")] diff --git a/src/graphql/naming.rs b/src/graphql/naming.rs index 834f7f1a..9742bedf 100644 --- a/src/graphql/naming.rs +++ b/src/graphql/naming.rs @@ -104,7 +104,7 @@ pub const PORTABLE_COMPARISON_OPS: &[&str] = &[ ]; /// String-only comparison operators (portable; SQLite maps `_ilike` → `LIKE`). -pub const STRING_COMPARISON_OPS: &[&str] = &["_like", "_ilike"]; +pub const STRING_COMPARISON_OPS: &[&str] = &["_like", "_ilike", "_icontains"]; /// Postgres `jsonb` operators — only on `JSON_comparison_exp` when the engine /// dialect is Postgres. **Must not** appear on SQLite schema or SDL. @@ -254,6 +254,8 @@ mod tests { let string_ops = comparison_op_fields("String", false); assert!(string_ops.contains(&"_like")); assert!(string_ops.contains(&"_ilike")); + assert!(string_ops.contains(&"_icontains")); + assert!(!comparison_op_fields("Int", false).contains(&"_icontains")); assert!(!string_ops.contains(&"_contains")); } diff --git a/src/graphql/sdl.rs b/src/graphql/sdl.rs index 8af2e41f..9c7dbda2 100644 --- a/src/graphql/sdl.rs +++ b/src/graphql/sdl.rs @@ -456,7 +456,7 @@ fn emit_comparison_exp(out: &mut String, scalar: &str, operators: &[String]) { let operand = match operator.as_str() { "_in" | "_nin" => format!("[{scalar}!]"), "_is_null" => "Boolean".into(), - "_like" | "_ilike" | "_has_key" => "String".into(), + "_like" | "_ilike" | "_icontains" | "_has_key" => "String".into(), _ => scalar.to_string(), }; out.push_str(&format!(" {operator}: {operand}\n")); diff --git a/tests/fixtures/generated-draining-command-v2.json b/tests/fixtures/generated-draining-command-v2.json index 6cb96029..17ccf0ae 100644 --- a/tests/fixtures/generated-draining-command-v2.json +++ b/tests/fixtures/generated-draining-command-v2.json @@ -39,7 +39,7 @@ "protocol": { "operation": "sha256:bb3a777dd32f7d40cccc173fafdbc7464f5dc50a3d17587909689a880fb0205a", "protocolHash": "sha256:00fb342f3acb4dc1c1716a43cc3001c748d5f6c500ff831690d820e9e43e2782", - "schemaHash": "sha256:2a4fd759519caaa13088228accd4fad4df3a0bc1bd19d1a9be323ad58cab29c1", + "schemaHash": "sha256:7e4d542ce141b2a9f350433b03603ae0ebfecd38c15b2e9d436fe8d0bca2662b", "surface": { "kind": "role", "name": "admin" diff --git a/tests/graphql_literal_text/main.rs b/tests/graphql_literal_text/main.rs new file mode 100644 index 00000000..9ee9f4a0 --- /dev/null +++ b/tests/graphql_literal_text/main.rs @@ -0,0 +1,162 @@ +//! Real-database coverage for literal search through the generated GraphQL API. +#![cfg(all(feature = "graphql", any(feature = "sqlite", feature = "postgres")))] + +use async_graphql::{Request, Variables}; +use distributed::{ + graphql::{claim, col, read, GraphqlEngine, ModelPermissions}, + microsvc::Session, + ReadModel, ROLE_KEY, USER_ID_KEY, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[table("gql_literal_text")] +struct SearchRow { + #[id("id")] + id: String, + label: Option, + owner: String, + number: i64, +} + +const DDL: &str = "CREATE TEMP TABLE gql_literal_text (id TEXT PRIMARY KEY, label TEXT, owner TEXT NOT NULL, number BIGINT NOT NULL)"; +const ROWS: &[(&str, Option<&str>, &str)] = &[ + ("01", Some("A%_!\\'BC"), "alice"), + ("02", Some("aZQ!\\'bc"), "alice"), + ("03", None, "alice"), + ("04", Some(""), "alice"), + ("05", Some("A%_!\\'BC"), "bob"), +]; + +fn permissions() -> ModelPermissions { + ModelPermissions::new().grant( + "user", + read() + .all_columns() + .rows(col("owner").eq(claim("x-user-id"))), + ) +} + +async fn verify(engine: GraphqlEngine) { + let mut session = Session::new(); + session.set(ROLE_KEY, "user"); + session.set(USER_ID_KEY, "alice"); + for (text, expected) in [ + ("%", vec!["01"]), + ("_", vec!["01"]), + ("%_", vec!["01"]), + ("!", vec!["01", "02"]), + ("\\", vec!["01", "02"]), + ("'", vec!["01", "02"]), + ("bc", vec!["01", "02"]), + ("a%_!\\'bc", vec!["01"]), + ("' OR 1=1 --", vec![]), + ("", vec!["01", "02", "04"]), + ] { + let response = engine.execute(&session, Request::new( + "query($q: String!) { gql_literal_text(where: {label: {_icontains: $q}}, order_by: [{id: asc}], limit: 20) {id} }" + ).variables(Variables::from_json(json!({"q": text})))).await; + assert!( + response.errors.is_empty(), + "{text:?}: {:?}", + response.errors + ); + let data = response.data.into_json().unwrap(); + let ids: Vec<_> = data["gql_literal_text"] + .as_array() + .unwrap() + .iter() + .map(|row| row["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, expected, "operand {text:?}"); + } + let page = engine.execute(&session, Request::new( + "{ gql_literal_text(where: {label: {_icontains: \"bc\"}}, order_by: [{id: asc}], limit: 1, offset: 1) {id} }" + )).await; + assert!(page.errors.is_empty(), "{:?}", page.errors); + assert_eq!( + page.data.into_json().unwrap(), + json!({"gql_literal_text":[{"id":"02"}]}) + ); + for query in [ + "{ gql_literal_text(where: {number: {_icontains: \"1\"}}) {id} }", + "{ gql_literal_text(where: {label: {_icontains: 1}}) {id} }", + ] { + assert!(!engine + .execute(&session, Request::new(query)) + .await + .errors + .is_empty()); + } + // Existing wildcard semantics remain intentionally different. + let pattern = engine.execute(&session, Request::new( + "{ gql_literal_text(where: {label: {_ilike: \"a%bc\"}}, order_by: [{id: asc}]) {id} }" + )).await; + assert!(pattern.errors.is_empty(), "{:?}", pattern.errors); + assert_eq!( + pattern.data.into_json().unwrap(), + json!({"gql_literal_text":[{"id":"01"},{"id":"02"}]}) + ); +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sqlite_literal_text() { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::query(DDL).execute(&pool).await.unwrap(); + for (id, label, owner) in ROWS { + sqlx::query("INSERT INTO gql_literal_text VALUES (?, ?, ?, 1)") + .bind(id) + .bind(label) + .bind(owner) + .execute(&pool) + .await + .unwrap(); + } + verify( + GraphqlEngine::builder(pool) + .roles(&["user"]) + .model::(permissions()) + .build() + .unwrap(), + ) + .await; +} + +#[cfg(feature = "postgres")] +#[tokio::test] +async fn postgres_literal_text() { + let Ok(url) = std::env::var("DATABASE_URL") else { + eprintln!("skip: DATABASE_URL unset"); + return; + }; + // Session-local table and a single connection: no retained tables changed. + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .unwrap(); + sqlx::query(DDL).execute(&pool).await.unwrap(); + for (id, label, owner) in ROWS { + sqlx::query("INSERT INTO gql_literal_text VALUES ($1, $2, $3, 1)") + .bind(id) + .bind(label) + .bind(owner) + .execute(&pool) + .await + .unwrap(); + } + verify( + GraphqlEngine::builder(pool) + .roles(&["user"]) + .model::(permissions()) + .build() + .unwrap(), + ) + .await; +}