diff --git a/packages/mongo-knex/lib/convertor.js b/packages/mongo-knex/lib/convertor.js index 650c1c60..1c8691dc 100644 --- a/packages/mongo-knex/lib/convertor.js +++ b/packages/mongo-knex/lib/convertor.js @@ -74,6 +74,12 @@ const aggregateOperatorError = relationName => new Error(`Aggregate relation "${ //eslint-disable-next-line ghost/ghost-custom/no-native-error const aggregateColumnError = relationName => new Error(`Aggregate relation "${relationName}" is queried by name only, without a column (e.g. "${relationName}:0")`); +//eslint-disable-next-line ghost/ghost-custom/no-native-error +const elemMatchRelationError = key => new Error(`$elemMatch can only be used on a relation, not "${key}"`); + +//eslint-disable-next-line ghost/ghost-custom/no-native-error +const elemMatchEmptyError = relationName => new Error(`$elemMatch on "${relationName}" needs at least one condition`); + /** * Whether an aggregate comparison would match a parent row with no related rows * (aggregate value 0). Such rows don't appear in the grouped subquery at all, so @@ -176,6 +182,68 @@ class MongoToKnex { Object.assign(this.config, {relations: {}}, config); } + /** + * The LHS expression that extracts a JSON scalar for comparison, as a raw + * fragment plus its bindings. `->>` unquotes the value on both databases Ghost + * runs on (MySQL 8+, SQLite 3.38+): `json_extract` alone returns a MySQL string + * scalar *with* its surrounding quotes (`"GB"`), so `LIKE 'G%'` and other string + * comparisons would match the quote, not the value — `->>` returns the bare `GB`. + */ + extractJsonColumn(column, path) { + return {expr: '?? ->> ?', bindings: [column, path]}; + } + + /** + * Apply one comparison to a query builder, transparently routing a statement + * that carries a `jsonPath` through JSON extraction. Without a path it's the + * plain `qb[whereType](column, op, value)` call every caller used before. + */ + applyComparison(builder, whereType, column, jsonPath, op, value) { + if (jsonPath && jsonPath.length > 0) { + const path = `$.${jsonPath.join('.')}`; + const {expr, bindings} = this.extractJsonColumn(column, path); + + // A null comparison is IS [NOT] NULL. Detected via the null-aware whereType + // rather than the value, because the relation path array-ifies a scalar + // null to `[null]` before this point — so `value` isn't a reliable signal. + // Handled first so the branches below only ever see a real value and their + // `${whereType}Raw` stays valid (there is no `whereNullRaw`). + if (whereType.endsWith('Null')) { + const rawWhere = whereType.startsWith('or') ? 'orWhereRaw' : 'whereRaw'; + const nullOp = whereType.endsWith('NotNull') ? 'is not null' : 'is null'; + return builder[rawWhere](`${expr} ${nullOp}`, bindings); + } + + // A regex (contains/startsWith/endsWith) compiles to a LIKE pattern; an + // ignore-case match lowers both sides — the extracted value here, the + // pattern in processRegExp. + if (value instanceof RegExp) { + const {source, ignoreCase} = processRegExp(value); + const lhs = ignoreCase ? `lower(${expr})` : expr; + return builder[`${whereType}Raw`](`${lhs} ${op} ? ESCAPE ?`, [...bindings, source, likeEscapeCharacter]); + } + // `in`/`not in` (a set, or a negated equality rewritten to `$nin`) isn't + // covered by knex's whereJsonPath, so it's spelled out here too. + if (op === 'in' || op === 'not in') { + const values = _.isArray(value) ? value : [value]; + const placeholders = values.map(() => '?').join(', '); + return builder[`${whereType}Raw`](`${expr} ${op} (${placeholders})`, [...bindings, ...values]); + } + return builder[`${whereType}Raw`](`${expr} ${op} ?`, [...bindings, value]); + } + + // A regex reaches here only via a relation subquery — the top-level path + // handles it in buildComparison. Convert it to the same LIKE-with-ESCAPE form + // so contains/startsWith/endsWith work on a related column too. + if (value instanceof RegExp) { + const {source, ignoreCase} = processRegExp(value); + const lhs = ignoreCase ? 'lower(??)' : '??'; + return builder[`${whereType}Raw`](`${lhs} ${op} ? ESCAPE ?`, [column, source, likeEscapeCharacter]); + } + + return builder[whereType](column, op, value); + } + processWhereType(mode, op, value) { if (value === null) { return (mode === '$or' ? 'orWhere' : 'where') + (op === '$ne' ? 'NotNull' : 'Null'); @@ -192,7 +260,10 @@ class MongoToKnex { * Determine if statement lives on parent table or if statement refers to a relation. */ processStatement(column, op, value) { - const [tableName, columnName] = column.split('.'); + // Segments past `relation.column` (or `column` on a relation's join/target + // table) are a JSON path into that column, e.g. + // `custom_fields.value_json.country` → column `value_json`, path `['country']`. + const [tableName, columnName, ...jsonPath] = column.split('.'); // CASE: relation? if (columnName) { @@ -228,6 +299,7 @@ class MongoToKnex { joinTable: relation.joinTable, table: relation.tableName, column: columnName, + jsonPath: jsonPath, operator: op, value: value, config: relation, @@ -238,6 +310,7 @@ class MongoToKnex { return { table: tableName, column: columnName, + jsonPath: jsonPath, operator: op, value: value, config: relation, @@ -306,6 +379,22 @@ class MongoToKnex { */ const isAggregate = statement.config && statement.config.type === 'aggregate'; + // CASE: all conditions of one `$elemMatch` must match a single related + // row, so they share a subquery keyed by the match's id regardless + // of operator - the "a negation matches a different row" reasoning + // below never applies to them. This is the explicit same-row escape + // hatch: everything outside an $elemMatch keeps the default grouping. + if (statement.elemMatchGroup !== undefined) { + const elemKey = `${statement.table}_elem_${statement.elemMatchGroup}`; + + if (!group[elemKey]) { + group[elemKey] = {innerWhereStatements: []}; + } + + group[elemKey].innerWhereStatements.push(statement); + return; + } + let shouldCreateSubGroup = !isAggregate && isNegationOp(statement.operator); if (!shouldCreateSubGroup && !isAggregate && group[statement.table]) { shouldCreateSubGroup = _.some(group[statement.table].innerWhereStatements, (innerStatement) => { @@ -362,6 +451,9 @@ class MongoToKnex { */ buildRelationQuery(qb, relations, mode) { debug(`(buildRelationQuery)`); + // The subquery bodies below are knex callbacks where `this` is the query + // builder, so hold onto the converter to reach its helpers from inside them. + const self = this; if (debugExtended.enabled) { debugExtended(`(buildRelationQuery) ${stringify(relations)}`); @@ -385,10 +477,16 @@ class MongoToKnex { if (reference.config.type === 'manyToMany') { if (_.every(statements.map(s => s.operator), isCompOp)) { // CASE: only negate whole group when all the operators in the group are negative, - // otherwise we cannot combine groups with negated and regular equation operators - const negateGroup = _.every(statements.map(s => s.operator), (operator) => { - return isNegationOp(operator); - }); + // otherwise we cannot combine groups with negated and regular equation operators. + // A positive $elemMatch group is exempt: it describes a single related row, so it + // must never negate even when all its conditions are negations — each applies within + // that one row (as $nin). A $not-wrapped $elemMatch is the opposite: the whole + // single-row match is negated (parent.id NOT IN that subquery), so it forces the + // negation regardless of the conditions inside. + const negateGroup = reference.elemMatchNegate === true + || (reference.elemMatchGroup === undefined && _.every(statements.map(s => s.operator), (operator) => { + return isNegationOp(operator); + })); const comp = negateGroup ? compOps.$nin @@ -450,7 +548,7 @@ class MongoToKnex { statementValue = !_.isArray(statement.value) ? [statement.value] : statement.value; } - innerQB[statement.whereType](statementColumn, statementOp, statementValue); + self.applyComparison(innerQB, statement.whereType, statementColumn, statement.jsonPath, statementOp, statementValue); }); if (debugExtended.enabled) { @@ -465,10 +563,16 @@ class MongoToKnex { } else if (reference.config.type === 'oneToOne') { if (_.every(statements.map(s => s.operator), isCompOp)) { // CASE: only negate whole group when all the operators in the group are negative, - // otherwise we cannot combine groups with negated and regular equation operators - const negateGroup = _.every(statements.map(s => s.operator), (operator) => { - return isNegationOp(operator); - }); + // otherwise we cannot combine groups with negated and regular equation operators. + // A positive $elemMatch group is exempt: it describes a single related row, so it + // must never negate even when all its conditions are negations — each applies within + // that one row (as $nin). A $not-wrapped $elemMatch is the opposite: the whole + // single-row match is negated (parent.id NOT IN that subquery), so it forces the + // negation regardless of the conditions inside. + const negateGroup = reference.elemMatchNegate === true + || (reference.elemMatchGroup === undefined && _.every(statements.map(s => s.operator), (operator) => { + return isNegationOp(operator); + })); const comp = negateGroup ? compOps.$nin @@ -530,7 +634,7 @@ class MongoToKnex { statementValue = !_.isArray(statement.value) ? [statement.value] : statement.value; } - innerQB[statement.whereType](statementColumn, statementOp, statementValue); + self.applyComparison(innerQB, statement.whereType, statementColumn, statement.jsonPath, statementOp, statementValue); }); if (debugExtended.enabled) { @@ -705,11 +809,7 @@ class MongoToKnex { } // CASE: if the statement is part of a group, collect the relation statements to be able to group them later - if (!Object.prototype.hasOwnProperty.call(qb, 'relations')) { - qb.relations = []; - } - - qb.relations.push(processedStatement); + this.collectRelationStatements(qb, [processedStatement]); return; } @@ -736,7 +836,7 @@ class MongoToKnex { } debug(`(buildComparison) whereType: ${whereType}, statement: ${statement}, op: ${op}, comp: ${comp}, value: ${value}`); - qb[whereType](column, comp, value); + this.applyComparison(qb, whereType, column, processedStatement.jsonPath, comp, value); } /** @@ -758,7 +858,13 @@ class MongoToKnex { // (unknown operators on aggregate relations were already rejected by // the validateAggregateStatements pre-pass in processJSON) _.forIn(sub, (value, op) => { - if (isCompOp(op)) { + if (op === '$elemMatch') { + this.buildElemMatch(qb, mode, statement, value, group, false); + } else if (op === '$not' && _.isObject(value) && value.$elemMatch) { + // `{relation: {$not: {$elemMatch: {…}}}}` negates the single-row match: + // no related row satisfies all the conditions (parent.id NOT IN …). + this.buildElemMatch(qb, mode, statement, value.$elemMatch, group, true); + } else if (isCompOp(op)) { this.buildComparison(qb, mode, statement, op, value, group); } else { debug('unknown operator'); @@ -766,6 +872,76 @@ class MongoToKnex { }); } + /** + * `{relation: {$elemMatch: {col: value, otherCol: {$ne: x}}}}` + * + * Match a single related row against all of the given conditions at once, + * emitted as one correlated subquery (`parent.id IN (SELECT … WHERE cond AND + * cond …)`). Without it, each condition on a multi-row relation is an + * independent existence check - a negation in particular becomes its own + * `NOT IN`, so a discriminator+value pair like `key = 'company' AND value != 'x'` + * would match different rows. `$elemMatch` is the explicit way to say the whole + * group describes one row; everything outside it keeps the default per-condition + * grouping untouched. + */ + buildElemMatch(qb, mode, relationName, conditions, group, negate = false) { + // $elemMatch groups its conditions onto one related row, so it only makes + // sense on a relation and needs at least one condition. Guard both misuses - + // otherwise a non-relation fails obscurely deep in knex, and an empty match + // silently drops the whole constraint (matching every row). + if (!this.config.relations[relationName]) { + throw elemMatchRelationError(relationName); + } + if (_.isEmpty(conditions)) { + throw elemMatchEmptyError(relationName); + } + + const collector = {}; + const elemMatchGroup = (this.elemMatchSeq = (this.elemMatchSeq || 0) + 1); + + // Inner conditions are always ANDed - a single row satisfies all of them - so + // process them as an $and regardless of the mode the match itself sits in. Each + // inner key is a column on the related row; a dotted key is a JSON path into + // that column (e.g. `address.country`), following the relation grammar used + // elsewhere. This shares the `column.jsonPath` shape, so a join-table-qualified + // column can't be expressed inside $elemMatch - fine for its single-row-value use. + _.forIn(conditions, (conditionValue, conditionColumn) => { + this.buildWhereClause(collector, '$and', `${relationName}.${conditionColumn}`, conditionValue, true); + }); + + const statements = collector.relations || []; + + statements.forEach((statement) => { + statement.elemMatchGroup = elemMatchGroup; + statement.elemMatchNegate = negate; + }); + + // The subquery itself attaches to the outer query with the outer mode's + // conjunction; grouping reads that from the group's first statement. + if (mode === '$or' && statements.length) { + statements[0].whereType = 'orWhere'; + } + + // CASE: not part of an outer group - attach the subquery immediately. + if (!group) { + this.buildRelationQuery(qb, statements, mode); + return; + } + + // CASE: part of a group - hand the statements to the group's relation flush + // so the subquery composes with sibling relation filters. + this.collectRelationStatements(qb, statements); + } + + // Stash relation statements on the builder for the group's deferred relation + // flush (see buildWhereGroup), lazily creating the list on first use. + collectRelationStatements(qb, statements) { + if (!Object.prototype.hasOwnProperty.call(qb, 'relations')) { + qb.relations = []; + } + qb.relations.push(...statements); + } + /** * {$and: [{author: 'carl'}, {status: 'draft'}]}} * {$and: {author: 'carl'}} diff --git a/packages/mongo-knex/test/integration/relations.test.js b/packages/mongo-knex/test/integration/relations.test.js index 8f1b7683..855b7ecf 100644 --- a/packages/mongo-knex/test/integration/relations.test.js +++ b/packages/mongo-knex/test/integration/relations.test.js @@ -126,6 +126,16 @@ describe('Relations', function () { }); }); + // A regex (contains/startsWith/endsWith) on a related column compiles to + // LIKE. Executed here because the base converter emitted invalid SQL for + // this shape (a stringified RegExp) and matched nothing - a real, shipped + // NQL filter shape (e.g. `tags.slug:~'ani'`), so the fix is pinned end to end. + it('matches a related column by a startsWith regex', function () { + return makeQuery({'tags.slug': {$regex: /^ani/}}) + .select() + .then(result => result.should.matchIds([2, 4, 6])); + }); + it('tags.visibility equals "internal"', function () { const mongoJSON = { 'tags.visibility': 'internal' @@ -1095,7 +1105,58 @@ describe('Relations', function () { }); }); + // These execute against the real database (sqlite3 AND mysql8 in CI), which is + // the point: MySQL's `json_extract` returns a scalar string *with* its quotes + // (`"London"`), so an anchored LIKE would match the quote, not the value. The + // converter uses `->>` to unquote, and only running the query on MySQL proves it. + // A .toQuery() string assertion can't - the SQL is identical on both dialects. + describe('JSON path extraction', function () { + it('matches an equal JSON subfield value', function () { + return makeQuery({'posts_meta.meta_json.city': 'London'}) + .select() + .then(result => result.should.matchIds([1])); + }); + + it('matches a startsWith on a JSON subfield', function () { + return makeQuery({'posts_meta.meta_json.city': {$regex: /^Lon/}}) + .select() + .then(result => result.should.matchIds([1])); + }); + + it('matches an endsWith on a JSON subfield', function () { + return makeQuery({'posts_meta.meta_json.city': {$regex: /ton$/}}) + .select() + .then(result => result.should.matchIds([4])); + }); + + it('matches a case-insensitive contains on a JSON subfield', function () { + return makeQuery({'posts_meta.meta_json.city': {$regex: /berlin/i}}) + .select() + .then(result => result.should.matchIds([5])); + }); + + // `->>` returns a native numeric on SQLite but a text scalar on MySQL, so a + // numeric comparison exercises each engine's coercion — only real execution + // proves both behave. + it('matches a numeric comparison on a JSON subfield', function () { + return makeQuery({'posts_meta.meta_json.score': {$gt: 20}}) + .select() + .then(result => result.should.matchIds([4, 5])); + }); + }); + describe('One-to-One', function () { + // A $not-wrapped $elemMatch excludes rows that HAVE a matching related row, so + // it also matches parents with no related row at all — proven here by execution + // (the NOT IN semantics differ from a positive match, and only real rows show it). + describe('$not $elemMatch (no matching related row)', function () { + it('excludes posts whose meta_title matches, keeping the rest', function () { + return makeQuery({posts_meta: {$not: {$elemMatch: {meta_title: 'Meta of Circle of Life'}}}}) + .select() + .then(result => result.should.matchIds([1, 2, 3, 5, 6, 7, 8])); + }); + }); + describe('EQUALS $eq', function () { it('posts_meta.meta_title equals "Meta of A Whole New World"', function () { const mongoJSON = { diff --git a/packages/mongo-knex/test/integration/suite1/fixtures/base.json b/packages/mongo-knex/test/integration/suite1/fixtures/base.json index 0d709d88..2d266a99 100644 --- a/packages/mongo-knex/test/integration/suite1/fixtures/base.json +++ b/packages/mongo-knex/test/integration/suite1/fixtures/base.json @@ -138,21 +138,24 @@ "post_id": 1, "meta_title": "Meta of A Whole New World", "meta_description": "A whole new world with new horizons to pursue I'll chase them anywhere", - "like_count": 10 + "like_count": 10, + "meta_json": "{\"city\":\"London\",\"score\":5}" }, { "id": 2, "post_id": 4, "meta_title": "Meta of Circle of Life", "meta_description": "Till we find our place nn the path unwinding in the circle the circle of life.", - "like_count": 42 + "like_count": 42, + "meta_json": "{\"city\":\"Boston\",\"score\":25}" }, { "id": 3, "post_id": 5, "meta_title": "Meta of Be Our Guest", "meta_description": null, - "like_count": 42 + "like_count": 42, + "meta_json": "{\"city\":\"Berlin\",\"score\":50}" } ] } diff --git a/packages/mongo-knex/test/integration/suite1/schema.js b/packages/mongo-knex/test/integration/suite1/schema.js index f608ce01..724a1bf4 100644 --- a/packages/mongo-knex/test/integration/suite1/schema.js +++ b/packages/mongo-knex/test/integration/suite1/schema.js @@ -34,6 +34,7 @@ module.exports.up = function (knex) { table.string('meta_description', 2000).nullable(); table.string('email_subject', 300).nullable(); table.integer('like_count').unsigned(); + table.json('meta_json').nullable(); })) .then(() => knex.schema.createTable('tags', (table) => { table.increments('id').primary(); diff --git a/packages/mongo-knex/test/unit/convertor.test.js b/packages/mongo-knex/test/unit/convertor.test.js index ba243985..3f56a442 100644 --- a/packages/mongo-knex/test/unit/convertor.test.js +++ b/packages/mongo-knex/test/unit/convertor.test.js @@ -824,3 +824,118 @@ describe('RegExp/Like queries', function () { .should.eql('select * from `posts` where lower(`posts`.`title`) like \'%\\\';select ** from `settings` where `value` like \\\'%\' ESCAPE \'*\''); }); }); + +describe('JSON path in relations', function () { + // Dot segments beyond `relation.column` are a JSON path into that column, so a + // consumer can filter a value nested inside a JSON column of a related row. + // Extraction uses `->>`, which unquotes the scalar on both MySQL 8+ and + // SQLite 3.38+ so string comparisons match the value, not MySQL's quoted form. + it('extracts a JSON path on a one-to-one relation column', function () { + runQuery({'posts_meta.meta_json.country': 'GB'}) + .should.eql('select * from `posts` where `posts`.`id` in (select `posts`.`id` from `posts` left join `posts_meta` on `posts_meta`.`post_id` = `posts`.`id` where `posts_meta`.`meta_json` ->> \'$.country\' = \'GB\')'); + }); + + it('extracts a JSON path on a many-to-many relation column', function () { + runQuery({'tags.meta.color': 'red'}) + .should.eql('select * from `posts` where `posts`.`id` in (select `posts_tags`.`post_id` from `posts_tags` inner join `tags` on `tags`.`id` = `posts_tags`.`tag_id` where `tags`.`meta` ->> \'$.color\' = \'red\')'); + }); + + it('applies a comparison operator to an extracted JSON path', function () { + runQuery({'posts_meta.meta_json.age': {$gt: 21}}) + .should.eql('select * from `posts` where `posts`.`id` in (select `posts`.`id` from `posts` left join `posts_meta` on `posts_meta`.`post_id` = `posts`.`id` where `posts_meta`.`meta_json` ->> \'$.age\' > 21)'); + }); + + it('applies a regex (LIKE) to an extracted JSON path', function () { + runQuery({'posts_meta.meta_json.name': {$regex: /^Gh/}}) + .should.eql('select * from `posts` where `posts`.`id` in (select `posts`.`id` from `posts` left join `posts_meta` on `posts_meta`.`post_id` = `posts`.`id` where `posts_meta`.`meta_json` ->> \'$.name\' like \'Gh%\' ESCAPE \'*\')'); + }); + + // A case-insensitive match lowers the extracted value too, not just the pattern. + it('lowers the extracted value for a case-insensitive regex', function () { + runQuery({'posts_meta.meta_json.name': {$regex: /Gh/i}}) + .should.eql('select * from `posts` where `posts`.`id` in (select `posts`.`id` from `posts` left join `posts_meta` on `posts_meta`.`post_id` = `posts`.`id` where lower(`posts_meta`.`meta_json` ->> \'$.name\') like \'%gh%\' ESCAPE \'*\')'); + }); + + it('leaves a plain relation column (no JSON path) unchanged', function () { + runQuery({'posts_meta.meta_title': 'Meta of A Whole New World'}) + .should.eql('select * from `posts` where `posts`.`id` in (select `posts`.`id` from `posts` left join `posts_meta` on `posts_meta`.`post_id` = `posts`.`id` where `posts_meta`.`meta_title` = \'Meta of A Whole New World\')'); + }); + + // A null comparison on an extracted path is IS [NOT] NULL. The relation path + // array-ifies a scalar null to `[null]`, so this must be keyed off the null-aware + // whereType, not the value - otherwise it would emit an unmatchable `not in (NULL)`. + it('emits IS NOT NULL for a negated null on a JSON path', function () { + runQuery({'posts_meta.meta_json.country': {$ne: null}}) + .should.eql('select * from `posts` where `posts`.`id` not in (select `posts`.`id` from `posts` left join `posts_meta` on `posts_meta`.`post_id` = `posts`.`id` where `posts_meta`.`meta_json` ->> \'$.country\' is null)'); + }); + + it('emits IS NULL for an equality null on a JSON path', function () { + runQuery({'posts_meta.meta_json.country': null}) + .should.eql('select * from `posts` where `posts`.`id` in (select `posts`.`id` from `posts` left join `posts_meta` on `posts_meta`.`post_id` = `posts`.`id` where `posts_meta`.`meta_json` ->> \'$.country\' is null)'); + }); +}); + +describe('$elemMatch (single related row)', function () { + // $elemMatch collapses all its conditions into ONE subquery, so a + // discriminator+value pair like `meta_title = 'A' AND meta_description != 'B'` + // matches a single related row rather than splitting the negation into an + // independent NOT IN. This is the explicit same-row escape hatch. + it('matches a discriminator and a negated value on the same one-to-one row', function () { + runQuery({posts_meta: {$elemMatch: {meta_title: 'A', meta_description: {$ne: 'B'}}}}) + .should.eql('select * from `posts` where `posts`.`id` in (select `posts`.`id` from `posts` left join `posts_meta` on `posts_meta`.`post_id` = `posts`.`id` where `posts_meta`.`meta_title` = \'A\' and `posts_meta`.`meta_description` not in (\'B\'))'); + }); + + // The same holds for a negated equality on a JSON subfield (`country is-not GB`). + it('matches a negated JSON-path value on the same row', function () { + runQuery({posts_meta: {$elemMatch: {meta_title: 'A', 'meta_json.country': {$ne: 'GB'}}}}) + .should.eql('select * from `posts` where `posts`.`id` in (select `posts`.`id` from `posts` left join `posts_meta` on `posts_meta`.`post_id` = `posts`.`id` where `posts_meta`.`meta_title` = \'A\' and `posts_meta`.`meta_json` ->> \'$.country\' not in (\'GB\'))'); + }); + + // $elemMatch is relation-agnostic: it forces same-row matching on a many-to-many + // relation too (one tag that is both slug 'a' and not internal). + it('matches a single row of a many-to-many relation', function () { + runQuery({tags: {$elemMatch: {slug: 'a', visibility: {$ne: 'internal'}}}}) + .should.eql('select * from `posts` where `posts`.`id` in (select `posts_tags`.`post_id` from `posts_tags` inner join `tags` on `tags`.`id` = `posts_tags`.`tag_id` where `tags`.`slug` = \'a\' and `tags`.`visibility` not in (\'internal\'))'); + }); + + // Without $elemMatch the default grouping is unchanged: a negation on a plain + // $and is still an independent "no row matches" subquery (has tag a AND not tag b). + it('leaves the default per-condition grouping untouched outside $elemMatch', function () { + runQuery({$and: [{'tags.slug': 'a'}, {'tags.slug': {$ne: 'b'}}]}) + .should.eql('select * from `posts` where (`posts`.`id` in (select `posts_tags`.`post_id` from `posts_tags` inner join `tags` on `tags`.`id` = `posts_tags`.`tag_id` where `tags`.`slug` = \'a\') and `posts`.`id` not in (select `posts_tags`.`post_id` from `posts_tags` inner join `tags` on `tags`.`id` = `posts_tags`.`tag_id` where `tags`.`slug` in (\'b\')))'); + }); + + // Two independent $elemMatch groups compose under OR. + it('composes multiple matches under $or', function () { + runQuery({$or: [{posts_meta: {$elemMatch: {meta_title: 'A', meta_description: 'B'}}}, {posts_meta: {$elemMatch: {meta_title: 'C', meta_description: 'D'}}}]}) + .should.eql('select * from `posts` where (`posts`.`id` in (select `posts`.`id` from `posts` left join `posts_meta` on `posts_meta`.`post_id` = `posts`.`id` where `posts_meta`.`meta_title` = \'A\' and `posts_meta`.`meta_description` = \'B\') or `posts`.`id` in (select `posts`.`id` from `posts` left join `posts_meta` on `posts_meta`.`post_id` = `posts`.`id` where `posts_meta`.`meta_title` = \'C\' and `posts_meta`.`meta_description` = \'D\'))'); + }); + + // An all-negation match stays positive: "has a tag that is neither a nor b" — + // one row where both conditions hold. It must NOT invert to a NOT IN subquery + // ("has no tag that is both a and b"), which is a different set. + it('keeps an all-negation match positive rather than inverting to NOT IN', function () { + runQuery({tags: {$elemMatch: {slug: {$ne: 'a'}, visibility: {$ne: 'b'}}}}) + .should.eql('select * from `posts` where `posts`.`id` in (select `posts_tags`.`post_id` from `posts_tags` inner join `tags` on `tags`.`id` = `posts_tags`.`tag_id` where `tags`.`slug` not in (\'a\') and `tags`.`visibility` not in (\'b\'))'); + }); + + it('throws on an empty match rather than dropping the constraint', function () { + (() => runQuery({tags: {$elemMatch: {}}})).should.throw(/needs at least one condition/); + }); + + it('throws when used on a non-relation key', function () { + (() => runQuery({title: {$elemMatch: {foo: 'x'}}})).should.throw(/can only be used on a relation/); + }); + + // A $not-wrapped $elemMatch negates the whole single-row match: no related row + // satisfies all the conditions, emitted as parent.id NOT IN that same subquery. + it('negates the single-row match with $not (NOT IN) on a one-to-one relation', function () { + runQuery({posts_meta: {$not: {$elemMatch: {meta_title: 'A', meta_description: 'B'}}}}) + .should.eql('select * from `posts` where `posts`.`id` not in (select `posts`.`id` from `posts` left join `posts_meta` on `posts_meta`.`post_id` = `posts`.`id` where `posts_meta`.`meta_title` in (\'A\') and `posts_meta`.`meta_description` in (\'B\'))'); + }); + + it('negates a single-condition match with $not on a many-to-many relation', function () { + runQuery({tags: {$not: {$elemMatch: {slug: 'a'}}}}) + .should.eql('select * from `posts` where `posts`.`id` not in (select `posts_tags`.`post_id` from `posts_tags` inner join `tags` on `tags`.`id` = `posts_tags`.`tag_id` where `tags`.`slug` in (\'a\'))'); + }); +});