diff --git a/query-compiler/query-builders/sql-query-builder/src/filter/visitor.rs b/query-compiler/query-builders/sql-query-builder/src/filter/visitor.rs index 366f89e36d3..05f110d4968 100644 --- a/query-compiler/query-builders/sql-query-builder/src/filter/visitor.rs +++ b/query-compiler/query-builders/sql-query-builder/src/filter/visitor.rs @@ -721,6 +721,7 @@ fn convert_json_filter( condition, target_type, } = json_condition; + let path_returns_array = path_returns_array(path.as_ref()); let (expr_json, expr_string): (Expression, Expression) = match path { Some(JsonFilterPath::String(path)) => ( json_extract(comparable.clone(), JsonPath::string(path.clone()), false).into(), @@ -734,21 +735,36 @@ fn convert_json_filter( }; let condition: Expression = match *condition { - ScalarCondition::Contains(value) => { - (expr_json, expr_string).json_contains(field, value, target_type.unwrap(), query_mode, reverse, alias, ctx) - } + ScalarCondition::Contains(value) => (expr_json, expr_string).json_contains( + field, + value, + target_type.unwrap(), + path_returns_array, + query_mode, + reverse, + alias, + ctx, + ), ScalarCondition::StartsWith(value) => (expr_json, expr_string).json_starts_with( field, value, target_type.unwrap(), + path_returns_array, + query_mode, + reverse, + alias, + ctx, + ), + ScalarCondition::EndsWith(value) => (expr_json, expr_string).json_ends_with( + field, + value, + target_type.unwrap(), + path_returns_array, query_mode, reverse, alias, ctx, ), - ScalarCondition::EndsWith(value) => { - (expr_json, expr_string).json_ends_with(field, value, target_type.unwrap(), query_mode, reverse, alias, ctx) - } ScalarCondition::GreaterThan(value) => { let gt = expr_json .clone() @@ -801,6 +817,31 @@ fn convert_json_filter( ConditionTree::single(condition) } +/// True when `JSON_EXTRACT` against `path` can return a JSON array of matched +/// values rather than a single scalar. MySQL JSON path expressions support +/// three wildcard constructs that turn a per-row extract into an array even +/// when each matched value is itself a string: `[*]` (array element wildcard), +/// `.*` (object member wildcard), and `**` (recursive descent). When any of +/// these is present the `JSON_TYPE = 'STRING'` gate around `string_contains`, +/// `string_starts_with`, and `string_ends_with` would otherwise always fail +/// because the extract result is `ARRAY`. Postgres' array-form path +/// (`JsonFilterPath::Array`) carries literal segments only and cannot express +/// a wildcard, so it always returns scalars. +fn path_returns_array(path: Option<&JsonFilterPath>) -> bool { + match path { + Some(JsonFilterPath::String(p)) => p.contains("[*]") || p.contains(".*") || p.contains("**"), + _ => false, + } +} + +fn string_target_expected_type(path_returns_array: bool) -> JsonType<'static> { + if path_returns_array { + JsonType::Array + } else { + JsonType::String + } +} + fn with_json_type_filter( comparable: Compare<'static>, expr_json: Expression<'static>, @@ -1288,6 +1329,7 @@ trait JsonFilterExt { field: &ScalarFieldRef, value: ConditionValue, target_type: JsonTargetType, + path_returns_array: bool, query_mode: QueryMode, reverse: bool, alias: Option, @@ -1300,6 +1342,7 @@ trait JsonFilterExt { field: &ScalarFieldRef, value: ConditionValue, target_type: JsonTargetType, + path_returns_array: bool, query_mode: QueryMode, reverse: bool, alias: Option, @@ -1312,6 +1355,7 @@ trait JsonFilterExt { field: &ScalarFieldRef, value: ConditionValue, target_type: JsonTargetType, + path_returns_array: bool, query_mode: QueryMode, reverse: bool, alias: Option, @@ -1325,12 +1369,14 @@ impl JsonFilterExt for (Expression<'static>, Expression<'static>) { field: &ScalarFieldRef, value: ConditionValue, target_type: JsonTargetType, + path_returns_array: bool, query_mode: QueryMode, reverse: bool, alias: Option, ctx: &Context<'_>, ) -> Expression<'static> { let (expr_json, expr_string) = self; + let string_expected_type = string_target_expected_type(path_returns_array); match (value, target_type) { // string_contains (value) @@ -1343,9 +1389,9 @@ impl JsonFilterExt for (Expression<'static>, Expression<'static>) { }; if reverse { - contains.or(expr_json.json_type_not_equals(JsonType::String)).into() + contains.or(expr_json.json_type_not_equals(string_expected_type)).into() } else { - contains.and(expr_json.json_type_equals(JsonType::String)).into() + contains.and(expr_json.json_type_equals(string_expected_type)).into() } } // array_contains (value) @@ -1379,9 +1425,9 @@ impl JsonFilterExt for (Expression<'static>, Expression<'static>) { }; if reverse { - contains.or(expr_json.json_type_not_equals(JsonType::String)).into() + contains.or(expr_json.json_type_not_equals(string_expected_type)).into() } else { - contains.and(expr_json.json_type_equals(JsonType::String)).into() + contains.and(expr_json.json_type_equals(string_expected_type)).into() } } // array_contains (ref) @@ -1404,12 +1450,15 @@ impl JsonFilterExt for (Expression<'static>, Expression<'static>) { field: &ScalarFieldRef, value: ConditionValue, target_type: JsonTargetType, + path_returns_array: bool, query_mode: QueryMode, reverse: bool, alias: Option, ctx: &Context<'_>, ) -> Expression<'static> { let (expr_json, expr_string) = self; + let string_expected_type = string_target_expected_type(path_returns_array); + match (value, target_type) { // string_starts_with (value) (ConditionValue::Value(value), JsonTargetType::String) => { @@ -1421,9 +1470,11 @@ impl JsonFilterExt for (Expression<'static>, Expression<'static>) { }; if reverse { - starts_with.or(expr_json.json_type_not_equals(JsonType::String)).into() + starts_with + .or(expr_json.json_type_not_equals(string_expected_type)) + .into() } else { - starts_with.and(expr_json.json_type_equals(JsonType::String)).into() + starts_with.and(expr_json.json_type_equals(string_expected_type)).into() } } // array_starts_with (value) @@ -1451,9 +1502,11 @@ impl JsonFilterExt for (Expression<'static>, Expression<'static>) { }; if reverse { - starts_with.or(expr_json.json_type_not_equals(JsonType::String)).into() + starts_with + .or(expr_json.json_type_not_equals(string_expected_type)) + .into() } else { - starts_with.and(expr_json.json_type_equals(JsonType::String)).into() + starts_with.and(expr_json.json_type_equals(string_expected_type)).into() } } // array_starts_with (ref) @@ -1476,12 +1529,14 @@ impl JsonFilterExt for (Expression<'static>, Expression<'static>) { field: &ScalarFieldRef, value: ConditionValue, target_type: JsonTargetType, + path_returns_array: bool, query_mode: QueryMode, reverse: bool, alias: Option, ctx: &Context<'_>, ) -> Expression<'static> { let (expr_json, expr_string) = self; + let string_expected_type = string_target_expected_type(path_returns_array); match (value, target_type) { // string_ends_with (value) @@ -1494,9 +1549,11 @@ impl JsonFilterExt for (Expression<'static>, Expression<'static>) { }; if reverse { - ends_with.or(expr_json.json_type_not_equals(JsonType::String)).into() + ends_with + .or(expr_json.json_type_not_equals(string_expected_type)) + .into() } else { - ends_with.and(expr_json.json_type_equals(JsonType::String)).into() + ends_with.and(expr_json.json_type_equals(string_expected_type)).into() } } // array_ends_with (value) @@ -1524,9 +1581,11 @@ impl JsonFilterExt for (Expression<'static>, Expression<'static>) { }; if reverse { - ends_with.or(expr_json.json_type_not_equals(JsonType::String)).into() + ends_with + .or(expr_json.json_type_not_equals(string_expected_type)) + .into() } else { - ends_with.and(expr_json.json_type_equals(JsonType::String)).into() + ends_with.and(expr_json.json_type_equals(string_expected_type)).into() } } // array_ends_with (ref) diff --git a/query-engine/connector-test-kit-rs/query-engine-tests/tests/queries/filters/json_filters.rs b/query-engine/connector-test-kit-rs/query-engine-tests/tests/queries/filters/json_filters.rs index c8462461ed1..761a929b267 100644 --- a/query-engine/connector-test-kit-rs/query-engine-tests/tests/queries/filters/json_filters.rs +++ b/query-engine/connector-test-kit-rs/query-engine-tests/tests/queries/filters/json_filters.rs @@ -554,6 +554,48 @@ mod json_filters { Ok(()) } + // Regression for https://github.com/prisma/prisma/issues/29571. + // + // MySQL JSON path wildcards (`[*]`, `.*`, `**`) make JSON_EXTRACT return an + // array even when each matched value is a scalar string, so the + // `JSON_TYPE = 'STRING'` gate around `string_contains` always evaluated to + // false and the filter returned 0 rows. With the fix, wildcard-path filters + // expect `JSON_TYPE = 'ARRAY'` and `LIKE` matches against the JSON-serialized + // array text. + #[connector_test(capabilities(JsonFilteringJsonPath), only(MySql(5.7), MySql(8)))] + async fn string_contains_wildcard_path(runner: Runner) -> TestResult<()> { + create_row( + &runner, + 1, + r#"{ \"items\": [{ \"name\": \"Widget A\" }, { \"name\": \"Gadget B\" }] }"#, + false, + ) + .await?; + create_row( + &runner, + 2, + r#"{ \"items\": [{ \"name\": \"Gadget X\" }, { \"name\": \"Gadget Y\" }] }"#, + false, + ) + .await?; + create_row(&runner, 3, r#"{ \"items\": [] }"#, false).await?; + + let res = run_query!( + runner, + jsonq( + &runner, + r#"path: "$.items[*].name", string_contains: "Widget" "#, + Some("") + ) + ); + insta::assert_snapshot!( + res, + @r###"{"data":{"findManyTestModel":[{"id":1}]}}"### + ); + + Ok(()) + } + async fn string_starts_with_runner(runner: Runner) -> TestResult<()> { create_row(&runner, 1, r#"\"foo\""#, true).await?; create_row(&runner, 2, r#"\"fool\""#, true).await?;