From 4452a15c4fd7190eadcad6b929581c708477badf Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 29 Jan 2026 23:52:37 -0500 Subject: [PATCH] Recursively inject `on` into combinator filter leaves in normalizeQueryDefinition Previously, `normalizeQueryDefinition` only injected the `on` reference at the top-level filter. Combinator filters (not, any, every) left their inner leaf attribute filters without `on`, producing invalid filter structures. This adds a recursive walk that injects `on` into each leaf filter (eq, contains, range) that lacks one, while skipping type filters. Co-Authored-By: Claude Opus 4.5 --- .../unit/query-field-normalization-test.ts | 187 ++++++++++++++++++ packages/runtime-common/query-field-utils.ts | 36 +++- 2 files changed, 221 insertions(+), 2 deletions(-) diff --git a/packages/host/tests/unit/query-field-normalization-test.ts b/packages/host/tests/unit/query-field-normalization-test.ts index 0469ded77df..c576d1c695d 100644 --- a/packages/host/tests/unit/query-field-normalization-test.ts +++ b/packages/host/tests/unit/query-field-normalization-test.ts @@ -52,6 +52,193 @@ module('normalizeQueryDefinition', function () { assert.strictEqual(normalized?.realm, 'https://other.realm/'); }); + test('injects on into leaf filter inside not', function (assert) { + let realmURL = new URL('https://realm.example/'); + let relativeTo = new URL('https://realm.example/cards/1'); + let targetRef = codeRefWithAbsoluteURL( + fieldDefinition.fieldOrCard, + relativeTo, + ); + + let normalized = normalizeQueryDefinition({ + fieldDefinition, + queryDefinition: { + filter: { not: { eq: { name: 'foo' } } }, + }, + realmURL, + fieldName: 'testField', + resolvePathValue: () => undefined, + relativeTo, + }); + + assert.ok(normalized, 'normalization succeeded'); + assert.deepEqual(normalized?.query.filter, { + not: { eq: { name: 'foo' }, on: targetRef }, + }); + }); + + test('injects on into each leaf filter inside any', function (assert) { + let realmURL = new URL('https://realm.example/'); + let relativeTo = new URL('https://realm.example/cards/1'); + let targetRef = codeRefWithAbsoluteURL( + fieldDefinition.fieldOrCard, + relativeTo, + ); + + let normalized = normalizeQueryDefinition({ + fieldDefinition, + queryDefinition: { + filter: { + any: [{ eq: { name: 'foo' } }, { contains: { title: 'bar' } }], + }, + }, + realmURL, + fieldName: 'testField', + resolvePathValue: () => undefined, + relativeTo, + }); + + assert.ok(normalized, 'normalization succeeded'); + assert.deepEqual(normalized?.query.filter, { + any: [ + { eq: { name: 'foo' }, on: targetRef }, + { contains: { title: 'bar' }, on: targetRef }, + ], + }); + }); + + test('injects on into each leaf filter inside every', function (assert) { + let realmURL = new URL('https://realm.example/'); + let relativeTo = new URL('https://realm.example/cards/1'); + let targetRef = codeRefWithAbsoluteURL( + fieldDefinition.fieldOrCard, + relativeTo, + ); + + let normalized = normalizeQueryDefinition({ + fieldDefinition, + queryDefinition: { + filter: { + every: [{ eq: { name: 'foo' } }, { range: { age: { gte: 18 } } }], + }, + }, + realmURL, + fieldName: 'testField', + resolvePathValue: () => undefined, + relativeTo, + }); + + assert.ok(normalized, 'normalization succeeded'); + assert.deepEqual(normalized?.query.filter, { + every: [ + { eq: { name: 'foo' }, on: targetRef }, + { range: { age: { gte: 18 } }, on: targetRef }, + ], + }); + }); + + test('injects on into deeply nested combinator filters', function (assert) { + let realmURL = new URL('https://realm.example/'); + let relativeTo = new URL('https://realm.example/cards/1'); + let targetRef = codeRefWithAbsoluteURL( + fieldDefinition.fieldOrCard, + relativeTo, + ); + + let normalized = normalizeQueryDefinition({ + fieldDefinition, + queryDefinition: { + filter: { + every: [ + { + any: [{ eq: { name: 'foo' } }, { eq: { name: 'bar' } }], + }, + { not: { contains: { title: 'baz' } } }, + ], + }, + }, + realmURL, + fieldName: 'testField', + resolvePathValue: () => undefined, + relativeTo, + }); + + assert.ok(normalized, 'normalization succeeded'); + assert.deepEqual(normalized?.query.filter, { + every: [ + { + any: [ + { eq: { name: 'foo' }, on: targetRef }, + { eq: { name: 'bar' }, on: targetRef }, + ], + }, + { not: { contains: { title: 'baz' }, on: targetRef } }, + ], + }); + }); + + test('skips type filters inside combinators', function (assert) { + let realmURL = new URL('https://realm.example/'); + let relativeTo = new URL('https://realm.example/cards/1'); + let targetRef = codeRefWithAbsoluteURL( + fieldDefinition.fieldOrCard, + relativeTo, + ); + let typeRef = { module: 'https://example.com/other', name: 'Other' }; + + let normalized = normalizeQueryDefinition({ + fieldDefinition, + queryDefinition: { + filter: { + any: [{ eq: { name: 'foo' } }, { type: typeRef }], + }, + }, + realmURL, + fieldName: 'testField', + resolvePathValue: () => undefined, + relativeTo, + }); + + assert.ok(normalized, 'normalization succeeded'); + assert.deepEqual(normalized?.query.filter, { + any: [{ eq: { name: 'foo' }, on: targetRef }, { type: typeRef }], + }); + }); + + test('does not overwrite existing on in leaf filters', function (assert) { + let realmURL = new URL('https://realm.example/'); + let relativeTo = new URL('https://realm.example/cards/1'); + let existingOn = { module: 'https://example.com/custom', name: 'Custom' }; + + let normalized = normalizeQueryDefinition({ + fieldDefinition, + queryDefinition: { + filter: { + any: [ + { eq: { name: 'foo' }, on: existingOn }, + { eq: { name: 'bar' } }, + ], + }, + }, + realmURL, + fieldName: 'testField', + resolvePathValue: () => undefined, + relativeTo, + }); + + assert.ok(normalized, 'normalization succeeded'); + let targetRef = codeRefWithAbsoluteURL( + fieldDefinition.fieldOrCard, + relativeTo, + ); + assert.deepEqual(normalized?.query.filter, { + any: [ + { eq: { name: 'foo' }, on: existingOn }, + { eq: { name: 'bar' }, on: targetRef }, + ], + }); + }); + test('resolves live instances via custom path resolver', function (assert) { let realmURL = new URL('https://realm.example/'); let instance = { address: { city: 'Paris' } }; diff --git a/packages/runtime-common/query-field-utils.ts b/packages/runtime-common/query-field-utils.ts index caf23d74be8..4056ce6be45 100644 --- a/packages/runtime-common/query-field-utils.ts +++ b/packages/runtime-common/query-field-utils.ts @@ -234,8 +234,8 @@ export function normalizeQueryDefinition({ let filter = queryAny.filter as Record | undefined; if (!filter || Object.keys(filter).length === 0) { queryAny.filter = { type: targetRef }; - } else if (!filter.on) { - filter.on = targetRef; + } else { + injectOnIntoLeafFilters(filter, targetRef); } if (Array.isArray(queryAny.sort)) { @@ -315,6 +315,38 @@ export function buildQuerySearchURL(realmHref: string, query: Query): string { return searchURL.href; } +function injectOnIntoLeafFilters( + filter: Record, + targetRef: any, +): void { + if ('type' in filter) { + return; + } + if ('not' in filter && filter.not && typeof filter.not === 'object') { + injectOnIntoLeafFilters(filter.not, targetRef); + return; + } + if ('any' in filter && Array.isArray(filter.any)) { + for (let child of filter.any) { + if (child && typeof child === 'object') { + injectOnIntoLeafFilters(child, targetRef); + } + } + return; + } + if ('every' in filter && Array.isArray(filter.every)) { + for (let child of filter.every) { + if (child && typeof child === 'object') { + injectOnIntoLeafFilters(child, targetRef); + } + } + return; + } + if (!filter.on) { + filter.on = targetRef; + } +} + export function cloneRelationship( relationship?: Relationship, ): Relationship | undefined {