Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/integration-postgres.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,7 @@ pub(crate) fn validate_filter_fields(
| "_is_null"
| "_like"
| "_ilike"
| "_icontains"
| "_contains"
| "_contained_in"
| "_has_key"
Expand Down
47 changes: 47 additions & 0 deletions distributed_cli/tests/cli_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,53 @@ fn snapshot_tree(root: &Path) -> BTreeMap<String, Vec<u8>> {
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");
Expand Down
2 changes: 1 addition & 1 deletion distributed_cli/tests/cli_manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
4 changes: 2 additions & 2 deletions js/src/replica/identity/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion js/src/replica/identity/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ export const FILTER_OPERATORS = new Set([
'_is_null',
'_like',
'_ilike',
'_icontains',
'_contains',
'_contained_in',
'_has_key'
]);

4 changes: 3 additions & 1 deletion js/src/replica/query-plan/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,7 @@ export function evaluateComparison(
if (
operator === '_like' ||
operator === '_ilike' ||
operator === '_icontains' ||
operator === '_contains' ||
operator === '_contained_in' ||
operator === '_has_key'
Expand Down Expand Up @@ -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'
Expand All @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions js/src/replica/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export type ReplicaFilterOperator =
| '_is_null'
| '_like'
| '_ilike'
| '_icontains'
| '_contains'
| '_contained_in'
| '_has_key';
Expand Down
7 changes: 6 additions & 1 deletion js/tests/replica-query-plan.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ const FILTER_FIELDS = Object.freeze([
'_in',
'_nin',
'_like',
'_ilike'
'_ilike',
'_icontains'
])
}),
Object.freeze({
Expand Down Expand Up @@ -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%' } })),
Expand Down
17 changes: 16 additions & 1 deletion js/tests/replica-variable-codec.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/graphql/client_manifest/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions src/graphql/compile/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,25 @@ fn compile_client_op(
) -> Result<String, String> {
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 {
Expand Down
37 changes: 20 additions & 17 deletions src/graphql/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1304,7 +1304,7 @@ mod client_surface_parity_tests {
}
assert_eq!(
manifest.schema_fingerprint,
"sha256:7a456dac4fce3e4ccba7255baccad3e70e891f71ea476c1560631c9b2e5cf1da"
"sha256:cce38712ab7d84a934fa10bf1272d292d20f746aea5bf2e485fe15b2527517c0"
);
}

Expand Down Expand Up @@ -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"
);
}

Expand Down Expand Up @@ -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}"
);
}
Expand Down Expand Up @@ -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")]
Expand Down
4 changes: 3 additions & 1 deletion src/graphql/naming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"));
}

Expand Down
2 changes: 1 addition & 1 deletion src/graphql/sdl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/generated-draining-command-v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"protocol": {
"operation": "sha256:bb3a777dd32f7d40cccc173fafdbc7464f5dc50a3d17587909689a880fb0205a",
"protocolHash": "sha256:00fb342f3acb4dc1c1716a43cc3001c748d5f6c500ff831690d820e9e43e2782",
"schemaHash": "sha256:2a4fd759519caaa13088228accd4fad4df3a0bc1bd19d1a9be323ad58cab29c1",
"schemaHash": "sha256:7e4d542ce141b2a9f350433b03603ae0ebfecd38c15b2e9d436fe8d0bca2662b",
"surface": {
"kind": "role",
"name": "admin"
Expand Down
Loading
Loading