From 96558ae3b1a6669ced15293e3ff3fa7edb6544d9 Mon Sep 17 00:00:00 2001 From: Rob Lester Date: Mon, 10 Aug 2026 16:59:13 +0100 Subject: [PATCH] Added a $elemMatch operator for single related-row matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groups a set of conditions into one correlated subquery so they must all match a single related row, with a $not-wrapped form that attaches the same subquery as NOT IN — flipping only the outer membership and keeping each inner operator literal, kept distinct in the compiler from the existing De Morgan all-negation grouping. A validateElemMatchStatements pre-pass rejects an invalid match (a non-object or empty value, an unrecognised inner operator, a logical operator or dotted column in the body, a non-relation target) during conversion, so it surfaces as a parse error rather than at render. Filters that do not use $elemMatch are unchanged, except that a regular expression on a relation column, previously emitted as invalid SQL, now compiles to the same LIKE form used elsewhere. Specified in full in the pull request description. --- packages/mongo-knex/lib/convertor.js | 245 ++++++++++++++++-- .../test/integration/relations.test.js | 21 ++ .../mongo-knex/test/unit/convertor.test.js | 107 ++++++++ 3 files changed, 350 insertions(+), 23 deletions(-) diff --git a/packages/mongo-knex/lib/convertor.js b/packages/mongo-knex/lib/convertor.js index 650c1c60..d96645f5 100644 --- a/packages/mongo-knex/lib/convertor.js +++ b/packages/mongo-knex/lib/convertor.js @@ -74,6 +74,18 @@ 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`); + +//eslint-disable-next-line ghost/ghost-custom/no-native-error +const elemMatchOperatorError = (relationName, op) => new Error(`$elemMatch on "${relationName}" does not support the operator "${op}"`); + +//eslint-disable-next-line ghost/ghost-custom/no-native-error +const elemMatchColumnError = (relationName, column) => new Error(`$elemMatch on "${relationName}" cannot use a dotted column ("${column}")`); + /** * 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 +188,23 @@ class MongoToKnex { Object.assign(this.config, {relations: {}}, config); } + /** + * Apply one comparison to a query builder. A regex reaches here only via a relation + * subquery — the top-level path handles it in buildComparison — so it is converted + * to the same LIKE-with-ESCAPE form that contains/startsWith/endsWith use, so they + * work on a related column too. Everything else is the plain + * `qb[whereType](column, op, value)` call. + */ + applyComparison(builder, whereType, column, op, value) { + 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'); @@ -306,6 +335,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 +407,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,16 +433,26 @@ 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. + // Two distinct negations converge on a NOT IN outer membership: + // - legacyNegate: a group whose conditions are all negations (e.g. {$ne, $ne}) + // and no $elemMatch. By De Morgan the whole group negates, and each inner + // condition flips to its positive form ($in) inside the subquery. + // - elemMatchNegate: a $not-wrapped $elemMatch. The whole single-row match is + // negated (parent.id NOT IN that subquery), but the conditions inside keep + // their literal operators — only the outer membership flips. + // A positive $elemMatch negates neither: each condition applies within the one row. + const legacyNegate = reference.elemMatchGroup === undefined + && _.every(statements.map(s => s.operator), (operator) => { + return isNegationOp(operator); + }); + const negateGroup = reference.elemMatchNegate === true || legacyNegate; const comp = negateGroup ? compOps.$nin : compOps.$in; - const whereType = ['whereNull', 'whereNotNull'].includes(reference.whereType) ? 'andWhere' : (['orWhereNull', 'orWhereNotNull'].includes(reference.whereType) ? 'orWhere' : reference.whereType); + const whereType = reference.outerWhereType || (['whereNull', 'whereNotNull'].includes(reference.whereType) ? 'andWhere' : (['orWhereNull', 'orWhereNotNull'].includes(reference.whereType) ? 'orWhere' : reference.whereType)); // CASE: WHERE resource.id (IN | NOT IN) (SELECT ...) qb[whereType](`${this.tableName}.id`, comp, function () { @@ -433,7 +491,7 @@ class MongoToKnex { const statementColumn = `${statement.joinTable || statement.table}.${statement.column}`; let statementOp; - if (negateGroup) { + if (legacyNegate) { statementOp = compOps.$in; } else { if (isNegationOp(statement.operator)) { @@ -450,7 +508,7 @@ class MongoToKnex { statementValue = !_.isArray(statement.value) ? [statement.value] : statement.value; } - innerQB[statement.whereType](statementColumn, statementOp, statementValue); + self.applyComparison(innerQB, statement.whereType, statementColumn, statementOp, statementValue); }); if (debugExtended.enabled) { @@ -465,17 +523,27 @@ 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. + // Two distinct negations converge on a NOT IN outer membership: + // - legacyNegate: a group whose conditions are all negations (e.g. {$ne, $ne}) + // and no $elemMatch. By De Morgan the whole group negates, and each inner + // condition flips to its positive form ($in) inside the subquery. + // - elemMatchNegate: a $not-wrapped $elemMatch. The whole single-row match is + // negated (parent.id NOT IN that subquery), but the conditions inside keep + // their literal operators — only the outer membership flips. + // A positive $elemMatch negates neither: each condition applies within the one row. + const legacyNegate = reference.elemMatchGroup === undefined + && _.every(statements.map(s => s.operator), (operator) => { + return isNegationOp(operator); + }); + const negateGroup = reference.elemMatchNegate === true || legacyNegate; const comp = negateGroup ? compOps.$nin : compOps.$in; const tableName = this.tableName; - const where = reference.whereType === 'orWhere' ? 'orWhere' : 'where'; + const where = (reference.outerWhereType || reference.whereType) === 'orWhere' ? 'orWhere' : 'where'; qb[where](`${this.tableName}.id`, comp, function () { const joinFilterStatements = groupedRelations[key].joinFilterStatements; @@ -507,9 +575,10 @@ class MongoToKnex { const statementColumn = `${statement.table}.${statement.column}`; let statementOp; - // NOTE: this negation is here to ensure records with no relation are - // include in negation (e.g. `relation.columnName: {$ne: null}) - if (negateGroup) { + // NOTE: this null flip ensures records with no relation are included in a + // De Morgan negation (e.g. `relation.columnName: {$ne: null}`). A + // $not $elemMatch keeps its conditions literal, so it does not flip. + if (legacyNegate) { statementOp = compOps.$in; if (statement.value === null) { @@ -530,7 +599,7 @@ class MongoToKnex { statementValue = !_.isArray(statement.value) ? [statement.value] : statement.value; } - innerQB[statement.whereType](statementColumn, statementOp, statementValue); + self.applyComparison(innerQB, statement.whereType, statementColumn, statementOp, statementValue); }); if (debugExtended.enabled) { @@ -705,11 +774,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 +801,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, comp, value); } /** @@ -758,7 +823,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 +837,67 @@ 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) { + // The relation, the non-empty-object shape, and the inner operators have already + // been validated by validateElemMatchStatements during conversion, so this builds + // from conditions it can trust. + 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 names a column on the related row. + _.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 attaches to the outer query with the outer mode's conjunction. + // Carried out-of-band on the first statement rather than overwriting its + // whereType, which still drives its own inner comparison — a null condition + // needs its whereNull left in place, or it degrades to `= NULL`. + if (mode === '$or' && statements.length) { + statements[0].outerWhereType = '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'}} @@ -865,6 +997,72 @@ class MongoToKnex { }); } + // Walks the filter for $elemMatch clauses (bare or wrapped in $not) and validates + // each one. Raised here, before query building, so a match nested in an $and/$or + // group fails at conversion rather than deep inside a knex where-callback at render + // time — the same reason validateAggregateStatements exists. + validateElemMatchStatements(sub) { + if (!_.isPlainObject(sub)) { + return; + } + + _.forIn(sub, (value, key) => { + if (isLogicOp(key)) { + _.castArray(value).forEach(group => this.validateElemMatchStatements(group)); + return; + } + + if (isOp(key) || !_.isPlainObject(value)) { + return; + } + + const conditions = value.$elemMatch !== undefined + ? value.$elemMatch + : (_.isPlainObject(value.$not) ? value.$not.$elemMatch : undefined); + + if (conditions !== undefined) { + this.validateElemMatch(key, conditions); + } + }); + } + + validateElemMatch(relationName, conditions) { + if (!this.config.relations[relationName]) { + throw elemMatchRelationError(relationName); + } + + // A non-object (string, number, array) would iterate as bogus columns; an empty + // one places no constraint and silently widens the result to every parent. + if (!_.isPlainObject(conditions) || _.isEmpty(conditions)) { + throw elemMatchEmptyError(relationName); + } + + _.forIn(conditions, (conditionValue, conditionColumn) => { + // A condition names a column, not an operator: a logical or nested operator in + // the match body (e.g. $or, a nested $elemMatch) is a non-goal, not a column. + if (isOp(conditionColumn)) { + throw elemMatchOperatorError(relationName, conditionColumn); + } + + // A dotted key would be split by processStatement into relation.column and lose + // every segment after the first, silently comparing a different column. + if (conditionColumn.includes('.')) { + throw elemMatchColumnError(relationName, conditionColumn); + } + + // Each operator applied to that column must be one the subquery can compile, + // so a typo'd operator is rejected rather than silently dropped — which would + // leave fewer conditions in place and widen the single-row match. + if (_.isPlainObject(conditionValue)) { + _.forIn(conditionValue, (operatorValue, op) => { + if (!isCompOp(op)) { + throw elemMatchOperatorError(relationName, op); + } + }); + } + }); + } + /** * The converter receives sub query objects e.g. `qb.where('..', (qb) => {})`, which * we then pass around to our class methods. That's why we pass the parent `qb` object @@ -879,6 +1077,7 @@ class MongoToKnex { } this.validateAggregateStatements(mongoJSON); + this.validateElemMatchStatements(mongoJSON); // 'and' is the default behaviour this.buildQuery(qb, '$and', mongoJSON); diff --git a/packages/mongo-knex/test/integration/relations.test.js b/packages/mongo-knex/test/integration/relations.test.js index 8f1b7683..bae0509f 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' @@ -1096,6 +1106,17 @@ describe('Relations', function () { }); 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/unit/convertor.test.js b/packages/mongo-knex/test/unit/convertor.test.js index ba243985..173db6e3 100644 --- a/packages/mongo-knex/test/unit/convertor.test.js +++ b/packages/mongo-knex/test/unit/convertor.test.js @@ -824,3 +824,110 @@ describe('RegExp/Like queries', function () { .should.eql('select * from `posts` where lower(`posts`.`title`) like \'%\\\';select ** from `settings` where `value` like \\\'%\' ESCAPE \'*\''); }); }); + +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\'))'); + }); + + // $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\'))'); + }); + + // The guards fire at conversion (buildQuery), before any SQL is rendered, so a + // consumer's filter-parse error handling catches them — hence buildQuery, not runQuery. + it('throws on an empty match rather than dropping the constraint', function () { + (() => buildQuery({tags: {$elemMatch: {}}})).should.throw(/needs at least one condition/); + }); + + it('throws when used on a non-relation key', function () { + (() => buildQuery({title: {$elemMatch: {foo: 'x'}}})).should.throw(/can only be used on a relation/); + }); + + it('throws when used on an aggregate relation', function () { + (() => buildQuery({tag_count: {$elemMatch: {slug: 'a'}}})).should.throw(/[Aa]ggregate relation/); + }); + + it('throws on a non-object match value rather than iterating it', function () { + (() => buildQuery({tags: {$elemMatch: 'a'}})).should.throw(/needs at least one condition/); + }); + + // Nested in an $and group, the guard still fires at conversion, not deferred into a + // knex where-callback that only runs at render (which would be a 500, not a 4xx). + it('throws at conversion for a match nested inside a group', function () { + (() => buildQuery({$and: [{status: 'draft'}, {tags: {$elemMatch: {}}}]})).should.throw(/needs at least one condition/); + }); + + // An unrecognised inner operator is rejected, not silently dropped — dropping it would + // leave `{key:'company', value:{$typo:'x'}}` matching every member with a company field. + it('throws on an unrecognised operator inside the match', function () { + (() => buildQuery({tags: {$elemMatch: {slug: 'a', visibility: {$bogus: 'x'}}}})).should.throw(/does not support the operator/); + }); + + it('throws on a logical operator inside the match body', function () { + (() => buildQuery({tags: {$elemMatch: {$or: [{slug: 'a'}, {slug: 'b'}]}}})).should.throw(/does not support the operator/); + }); + + // A dotted key would be truncated to relation.column by processStatement, silently + // comparing a different column, so it is rejected rather than run. + it('throws on a dotted column inside the match', function () { + (() => buildQuery({posts_meta: {$elemMatch: {'meta_title.sub': 'x'}}})).should.throw(/cannot use a dotted column/); + }); + + // The outer $or must not clobber a null condition's IS NULL: the match's first + // condition here is a null, and it has to stay `is null`, not become `= NULL`. + it('keeps a null condition literal when the match sits under $or', function () { + runQuery({$or: [{status: 'draft'}, {posts_meta: {$elemMatch: {meta_title: null, meta_description: 'd'}}}]}) + .should.eql('select * from `posts` where (`posts`.`status` = \'draft\' 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` is null and `posts_meta`.`meta_description` = \'d\'))'); + }); + + // 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` = \'A\' and `posts_meta`.`meta_description` = \'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` = \'a\')'); + }); + + // The negation is only the outer membership: the conditions inside keep their literal + // operators, so a non-equality condition survives. Forcing them to $in (as the + // De Morgan path does) would silently drop the operator — here it would ask for "no + // tag whose slug IS a" instead of "no tag whose slug is NOT a". + it('keeps inner operators literal under a $not $elemMatch', function () { + runQuery({tags: {$not: {$elemMatch: {slug: {$ne: '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` not in (\'a\'))'); + }); +});