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
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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()
Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -1288,6 +1329,7 @@ trait JsonFilterExt {
field: &ScalarFieldRef,
value: ConditionValue,
target_type: JsonTargetType,
path_returns_array: bool,
query_mode: QueryMode,
reverse: bool,
alias: Option<Alias>,
Expand All @@ -1300,6 +1342,7 @@ trait JsonFilterExt {
field: &ScalarFieldRef,
value: ConditionValue,
target_type: JsonTargetType,
path_returns_array: bool,
query_mode: QueryMode,
reverse: bool,
alias: Option<Alias>,
Expand All @@ -1312,6 +1355,7 @@ trait JsonFilterExt {
field: &ScalarFieldRef,
value: ConditionValue,
target_type: JsonTargetType,
path_returns_array: bool,
query_mode: QueryMode,
reverse: bool,
alias: Option<Alias>,
Expand All @@ -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<Alias>,
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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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<Alias>,
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) => {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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<Alias>,
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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Comment on lines +557 to +597
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider adding tests for other wildcard patterns and string operations.

The test validates the [*] wildcard with string_contains, which covers the reported issue. However, the implementation also handles .* (object member wildcard) and ** (recursive descent), and applies the fix to string_starts_with and string_ends_with as well. Adding tests for these variations would strengthen confidence in the fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@query-engine/connector-test-kit-rs/query-engine-tests/tests/queries/filters/json_filters.rs`
around lines 557 - 597, Add additional tests mirroring
string_contains_wildcard_path to cover the other wildcard patterns and string
operations: create new async test functions (e.g.,
string_contains_member_wildcard, string_contains_recursive_descent,
string_starts_with_wildcard_path, string_ends_with_wildcard_path) that use
create_row to seed rows and run_query!/jsonq with path values "$.items.*.name"
and "$.items**.name" (and with string_starts_with / string_ends_with payloads)
and assert the expected snapshots similarly to string_contains_wildcard_path;
ensure tests are annotated with the same connector_test capabilities
(JsonFilteringJsonPath) and MySql versions so they exercise the same fix.


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?;
Expand Down