Extend relation filters: JSON paths and single-row matching ($elemMatch) - #192
Extend relation filters: JSON paths and single-row matching ($elemMatch)#192rob-ghost wants to merge 3 commits into
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #192 +/- ##
==========================================
+ Coverage 84.18% 85.26% +1.07%
==========================================
Files 9 9
Lines 2074 2239 +165
Branches 428 470 +42
==========================================
+ Hits 1746 1909 +163
- Misses 322 324 +2
Partials 6 6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ed703c1 to
f6bdcc0
Compare
A publisher can now filter and segment the members list by what a member holds for a custom field. Values are reached through a new `custom_fields` relation on the Member model, and a filter names the field by its key and matches on its value — the key rides in the value position of the NQL, so a hyphenated slug key needs no escaping. Text values, presence (set / not set), and address sub-fields are all supported; a sub-field is read out of the address JSON blob by path, which needs the JSON-path support added to mongo-knex (pinned here to a pre-release of TryGhost/NQL#192 until it ships). Values reference their field by its immutable key rather than its internal id, so the value's foreign key speaks the same identity the API, CSV, and now filters all use. Behind the membersCustomFields flag.
f6bdcc0 to
6f921cf
Compare
A publisher can now filter and segment the members list by what a member holds for a custom field. Values are reached through a new `custom_fields` relation on the Member model, and a filter names the field by its key and matches on its value — the key rides in the value position of the NQL, so a hyphenated slug key needs no escaping. Text values, presence (set / not set), and address sub-fields are all supported; a sub-field is read out of the address JSON blob by path, which needs the JSON-path support added to mongo-knex (pinned here to a pre-release of TryGhost/NQL#192 until it ships). Values reference their field by its immutable key rather than its internal id, so the value's foreign key speaks the same identity the API, CSV, and now filters all use. Behind the membersCustomFields flag.
A publisher can now filter and segment the members list by what a member holds for a custom field. Values are reached through a new `custom_fields` relation on the Member model, and a filter names the field by its key and matches on its value — the key rides in the value position of the NQL, so a hyphenated slug key needs no escaping. Text values, presence (set / not set), and address sub-fields are all supported; a sub-field is read out of the address JSON blob by path, which needs the JSON-path support added to mongo-knex (pinned here to a pre-release of TryGhost/NQL#192 until it ships). Values reference their field by its immutable key rather than its internal id, so the value's foreign key speaks the same identity the API, CSV, and now filters all use. Behind the membersCustomFields flag.
A dotted key with segments beyond `relation.column` (e.g. `custom_fields.value_json.country`) is now read as a JSON path into that column, so a consumer can filter on a value nested inside a JSON column of a related row rather than only on the column as a whole. Extraction uses the `->>` operator, which returns the bare scalar on both databases Ghost runs on (MySQL 8+, SQLite 3.38+); plain `json_extract` returns a MySQL string scalar with its surrounding quotes, so `LIKE` and other string comparisons would match the quote rather than the value. A regex (contains/startsWith/ endsWith) applied to a path is converted to the same LIKE form the top-level path already uses, lowering the extracted value for a case-insensitive match. The `in`/`not in` a negated equality produces is spelled out too, since knex's whereJsonPath does not cover it. Statements without extra segments are unaffected. The extraction is exercised against a real database (sqlite and mysql in CI), because a `.toQuery()` string is identical on both dialects and cannot catch MySQL's quoting.
A condition on a multi-row relation is an independent existence check by
default, and a negation in particular becomes its own `NOT IN` subquery -
correct for "has a tag that is not X", but wrong for a discriminator+value
pair such as a members custom-field filter's `key = 'company' AND value !=
'Ghost'`, where both conditions describe one value row. Splitting them there
excludes a member whose company is Acme but who happens to have a Ghost value
on some other field. `$elemMatch` is the explicit way to say a group of
conditions must all match the same related row: `{relation: {$elemMatch:
{...}}}` emits one correlated subquery with the conditions ANDed. It is
relation-agnostic (works on one-to-one and many-to-many alike) and additive -
anything not wrapped in `$elemMatch` keeps the existing per-condition grouping
untouched, so no existing filter changes.
6f921cf to
9f1d71e
Compare
A publisher can now filter and segment the members list by what a member holds for a custom field. Values are reached through a new `custom_fields` relation on the Member model, and a filter names the field by its key and matches on its value — the key rides in the value position of the NQL, so a hyphenated slug key needs no escaping. Text values, presence (set / not set), and address sub-fields are all supported; a sub-field is read out of the address JSON blob by path, which needs the JSON-path support added to mongo-knex (pinned here to a pre-release of TryGhost/NQL#192 until it ships). Values reference their field by its immutable key rather than its internal id, so the value's foreign key speaks the same identity the API, CSV, and now filters all use. Behind the membersCustomFields flag.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
packages/mongo-knex/test/integration/relations.test.js (1)
1103-1127: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winConsider adding cross-dialect coverage for numeric JSON-path comparisons.
These tests only exercise equality and regex (string) matching on extracted JSON scalars. SQLite's
->>returns a nativeINTEGER/REALfor a numeric JSON value, while MySQL's->>always returnsTEXT— the two dialects genuinely differ in the SQL type produced by the extraction, even though the comment at Line 1098-1102 emphasizes that only real query execution proves cross-dialect correctness. A numeric comparison ($gt/$lt) test, like the unit test atpackages/mongo-knex/test/unit/convertor.test.jsLine 843-846, run against both databases here would confirm type coercion behaves correctly on both engines and not just on the one currently exercised by string comparisons.🤖 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 `@packages/mongo-knex/test/integration/relations.test.js` around lines 1103 - 1127, Add integration coverage in the JSON path extraction describe block for numeric comparisons against a JSON subfield, using a numeric $gt or $lt query and expected matching IDs. Ensure the test executes through the existing makeQuery(...).select() flow against both supported database dialects, following the numeric comparison pattern from the convertor tests.
🤖 Prompt for all review comments with 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.
Inline comments:
In `@packages/mongo-knex/lib/convertor.js`:
- Around line 195-238: The JSON-path null handling in applyComparison must use
the null-aware whereType (for example, matching the Null suffix) rather than
value === null or the operator, so relation-level array-ification still emits IS
NULL/IS NOT NULL instead of IN/NOT IN. In packages/mongo-knex/lib/convertor.js
lines 195-238, update applyComparison while preserving the existing raw SQL and
bindings; in packages/mongo-knex/test/unit/convertor.test.js lines 827-900, add
coverage asserting the generated SQL for a relation JSON-path {$ne: null} or
plain null comparison.
In `@packages/mongo-knex/test/unit/convertor.test.js`:
- Around line 827-900: The JSON-path relation regression suite lacks coverage
for null negation. Add a test in the “JSON path in relations” describe block
using runQuery with posts_meta.meta_json.country and {$ne: null}, asserting the
generated relation subquery uses IS NOT NULL rather than NOT IN (NULL).
---
Nitpick comments:
In `@packages/mongo-knex/test/integration/relations.test.js`:
- Around line 1103-1127: Add integration coverage in the JSON path extraction
describe block for numeric comparisons against a JSON subfield, using a numeric
$gt or $lt query and expected matching IDs. Ensure the test executes through the
existing makeQuery(...).select() flow against both supported database dialects,
following the numeric comparison pattern from the convertor tests.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: a87aa15f-ee19-49a7-8d9a-6eaf7ef01bd1
📒 Files selected for processing (5)
packages/mongo-knex/lib/convertor.jspackages/mongo-knex/test/integration/relations.test.jspackages/mongo-knex/test/integration/suite1/fixtures/base.jsonpackages/mongo-knex/test/integration/suite1/schema.jspackages/mongo-knex/test/unit/convertor.test.js
A publisher can now filter and segment the members list by what a member holds for a custom field. Values are reached through a new `custom_fields` relation on the Member model, and a filter names the field by its key and matches on its value — the key rides in the value position of the NQL, so a hyphenated slug key needs no escaping. Text values, presence (set / not set), and address sub-fields are all supported; a sub-field is read out of the address JSON blob by path, which needs the JSON-path support added to mongo-knex (pinned here to a pre-release of TryGhost/NQL#192 until it ships). Values reference their field by its immutable key rather than its internal id, so the value's foreign key speaks the same identity the API, CSV, and now filters all use. Behind the membersCustomFields flag.
Follow-up from review of the two changes above, covering inputs neither the custom-fields consumer nor the initial tests exercised. An $elemMatch whose conditions are all negations was still inverting the whole subquery to a NOT IN — "has no row that is both x and y" rather than "has a row that is neither" — because the group-negation decision was made independently of the same-row bucketing. An $elemMatch group is now always matched positively, so each negation applies within the one row. A null comparison on a JSON path threw, because the relation path array-ifies a scalar null to `[null]` before the handler's value check; it now keys off the null-aware whereType and emits IS [NOT] NULL. An empty $elemMatch silently dropped its whole constraint, and an $elemMatch on a non-relation key failed obscurely deep in knex; both now throw a clear error. A shared helper replaces the duplicated relation-statement collection. Tests: unit coverage for the all-negation, empty, non-relation, and null-JSON-path cases; integration coverage (executed on both databases) for a numeric JSON-path comparison and for a regex on a related column — the latter a real shipped NQL filter shape the base converter emitted invalid SQL for.
A publisher can now filter and segment the members list by what a member holds for a custom field. Values are reached through a new `custom_fields` relation on the Member model, and a filter names the field by its key and matches on its value — the key rides in the value position of the NQL, so a hyphenated slug key needs no escaping. Text values, presence (set / not set), and address sub-fields are all supported; a sub-field is read out of the address JSON blob by path, which needs the JSON-path support added to mongo-knex (pinned here to a pre-release of TryGhost/NQL#192 until it ships). Values reference their field by its immutable key rather than its internal id, so the value's foreign key speaks the same identity the API, CSV, and now filters all use. Behind the membersCustomFields flag.
A publisher can now filter and segment the members list by what a member holds for a custom field. Values are reached through a new `custom_fields` relation on the Member model, and a filter names the field by its key and matches on its value — the key rides in the value position of the NQL, so a hyphenated slug key needs no escaping. Text values, presence (set / not set), and address sub-fields are all supported; a sub-field is read out of the address JSON blob by path, which needs the JSON-path support added to mongo-knex (pinned here to a pre-release of TryGhost/NQL#192 until it ships). Values reference their field by its immutable key rather than its internal id, so the value's foreign key speaks the same identity the API, CSV, and now filters all use. Behind the membersCustomFields flag.
Extending relation filters: JSON paths and single-row matching
Summary
Two additions to how mongo-knex filters a record by its related rows: reaching into a JSON value on a related row, and requiring several conditions to be satisfied by one and the same related row (
$elemMatch). Both are opt-in and additive — existing filters are unchanged.Motivation
NQL already lets a consumer filter a record by its relations — posts by their tags, members by their labels. Building "filter members by their custom fields" surfaced two things the relation grammar could not express. Custom fields are stored flexibly: a member has many key/value rows, and some values are structured (an address kept as a single JSON value).
Reaching inside a JSON value. A related row may hold structured data in one JSON column. A filter could match that column as a whole, but not a field inside it — you could not ask for "the address whose country is GB."
Requiring conditions to hold on the same related row. When a record has many rows for a relation, a compound condition is ambiguous. "field is company and value is not Ghost" can mean either one row (a company row whose value isn't Ghost) or across rows (has a company row, and separately has no row valued Ghost anywhere). NQL historically chose the second reading — the right default for tags, where "has tag A but not tag B" is genuinely about different rows. But for a key/value field the two halves describe a single row, and there was no way to say so. Crucially, "same row" is a property of the question being asked, not of the relation — so it cannot be inferred reliably from how the relation is configured.
What this adds
JSON path extraction. A dotted key can reach a value nested inside a JSON column of a related row — e.g. a member's address, stored as JSON, filtered by its
country. A nested value then compares and matches like any ordinary column.$elemMatch. An explicit operator meaning "a single related row satisfies all of these conditions at once." For example:reads as "has a company field whose value is not Ghost" — both conditions bound to one row. Anything not wrapped in
$elemMatchkeeps the existing per-condition behavior.Benefits
$elemMatchget the new grouping.$elemMatchworks for any relation, not just the feature that prompted it.Tradeoffs and non-goals
$elemMatchis opt-in by design. Changing the default relation behavior would silently alter the meaning of existing filters, so the default is left exactly as it was.Prior art
$elemMatch— same name and same meaning (a single array element matching all conditions). NQL already models filters as Mongo-style query objects, so this fits the existing vocabulary rather than inventing a concept.Backwards compatibility
Fully backwards compatible. Both capabilities are opt-in and additive; every existing filter produces identical output. Verified against both supported databases, including execution (not just generated SQL), so the JSON behavior is proven on each.
Draft: the consumer is Ghost's members custom-fields filtering; kept open so a pre-release artefact can be published for the Ghost PR to build against.