Skip to content

Commit cef914f

Browse files
lukemeliaclaude
andcommitted
Add 'in' filter operator to card query language
Adds a new `in` filter that checks if a field value is in a specified set, replacing the verbose `any` + `eq` workaround for set membership queries. Handles empty arrays (matches nothing), null values, and nested field paths. Supports interpolation from containsMany fields in query-backed fields. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 20a185a commit cef914f

6 files changed

Lines changed: 332 additions & 1 deletion

File tree

packages/host/tests/unit/index-query-engine-test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,174 @@ module('Unit | query', function (hooks) {
740740
);
741741
});
742742

743+
test(`can filter using 'in'`, async function (assert) {
744+
let { mango, vangogh, ringo } = testCards;
745+
await setupIndex(dbAdapter, [
746+
{ card: mango, data: { search_doc: { name: 'Mango' } } },
747+
{ card: vangogh, data: { search_doc: { name: 'Van Gogh' } } },
748+
{ card: ringo, data: { search_doc: { name: 'Ringo' } } },
749+
]);
750+
751+
let type = await personCardType(testCards);
752+
let { cards: results, meta } = await indexQueryEngine.searchCards(
753+
new URL(testRealmURL),
754+
{
755+
filter: {
756+
in: { name: ['Mango', 'Ringo'] },
757+
on: type,
758+
},
759+
},
760+
);
761+
762+
assert.strictEqual(meta.page.total, 2, 'the total results meta is correct');
763+
assert.deepEqual(
764+
getIds(results),
765+
[mango.id, ringo.id],
766+
'results are correct',
767+
);
768+
});
769+
770+
test(`can filter using 'in' with a single value`, async function (assert) {
771+
let { mango, vangogh } = testCards;
772+
await setupIndex(dbAdapter, [
773+
{ card: mango, data: { search_doc: { name: 'Mango' } } },
774+
{ card: vangogh, data: { search_doc: { name: 'Van Gogh' } } },
775+
]);
776+
777+
let type = await personCardType(testCards);
778+
let { cards: results, meta } = await indexQueryEngine.searchCards(
779+
new URL(testRealmURL),
780+
{
781+
filter: {
782+
in: { name: ['Mango'] },
783+
on: type,
784+
},
785+
},
786+
);
787+
788+
assert.strictEqual(meta.page.total, 1, 'the total results meta is correct');
789+
assert.deepEqual(getIds(results), [mango.id], 'results are correct');
790+
});
791+
792+
test(`can filter using 'in' with an empty array`, async function (assert) {
793+
let { mango, vangogh } = testCards;
794+
await setupIndex(dbAdapter, [
795+
{ card: mango, data: { search_doc: { name: 'Mango' } } },
796+
{ card: vangogh, data: { search_doc: { name: 'Van Gogh' } } },
797+
]);
798+
799+
let type = await personCardType(testCards);
800+
let { cards: results, meta } = await indexQueryEngine.searchCards(
801+
new URL(testRealmURL),
802+
{
803+
filter: {
804+
in: { name: [] },
805+
on: type,
806+
},
807+
},
808+
);
809+
810+
assert.strictEqual(meta.page.total, 0, 'the total results meta is correct');
811+
assert.deepEqual(getIds(results), [], 'results are correct');
812+
});
813+
814+
test(`can filter using 'in' with null values`, async function (assert) {
815+
let { mango, vangogh, ringo } = testCards;
816+
await setupIndex(dbAdapter, [
817+
{ card: mango, data: { search_doc: { name: 'Mango' } } },
818+
{ card: vangogh, data: { search_doc: { name: null } } },
819+
{ card: ringo, data: { search_doc: { name: 'Ringo' } } },
820+
]);
821+
822+
let type = await personCardType(testCards);
823+
let { cards: results, meta } = await indexQueryEngine.searchCards(
824+
new URL(testRealmURL),
825+
{
826+
filter: {
827+
in: { name: ['Mango', null] },
828+
on: type,
829+
},
830+
},
831+
);
832+
833+
assert.strictEqual(meta.page.total, 2, 'the total results meta is correct');
834+
assert.deepEqual(
835+
getIds(results),
836+
[mango.id, vangogh.id],
837+
'results are correct',
838+
);
839+
});
840+
841+
test(`can filter using 'in' thru nested fields`, async function (assert) {
842+
let { mango, vangogh, ringo } = testCards;
843+
await setupIndex(dbAdapter, [
844+
{
845+
card: mango,
846+
data: {
847+
search_doc: {
848+
name: 'Mango',
849+
address: { city: 'Barksville' },
850+
},
851+
},
852+
},
853+
{
854+
card: vangogh,
855+
data: {
856+
search_doc: {
857+
name: 'Van Gogh',
858+
address: { city: 'Barksville' },
859+
},
860+
},
861+
},
862+
{
863+
card: ringo,
864+
data: {
865+
search_doc: {
866+
name: 'Ringo',
867+
address: { city: 'Waggington' },
868+
},
869+
},
870+
},
871+
]);
872+
873+
let type = await personCardType(testCards);
874+
let { cards: results, meta } = await indexQueryEngine.searchCards(
875+
new URL(testRealmURL),
876+
{
877+
filter: {
878+
on: type,
879+
in: { 'address.city': ['Waggington'] },
880+
},
881+
},
882+
);
883+
884+
assert.strictEqual(meta.page.total, 1, 'the total results meta is correct');
885+
assert.deepEqual(getIds(results), [ringo.id], 'results are correct');
886+
});
887+
888+
test(`can negate an 'in' filter with 'not'`, async function (assert) {
889+
let { mango, vangogh, ringo } = testCards;
890+
await setupIndex(dbAdapter, [
891+
{ card: mango, data: { search_doc: { name: 'Mango' } } },
892+
{ card: vangogh, data: { search_doc: { name: 'Van Gogh' } } },
893+
{ card: ringo, data: { search_doc: { name: 'Ringo' } } },
894+
]);
895+
896+
let type = await personCardType(testCards);
897+
let { cards: results, meta } = await indexQueryEngine.searchCards(
898+
new URL(testRealmURL),
899+
{
900+
filter: {
901+
on: type,
902+
not: { in: { name: ['Mango', 'Ringo'] } },
903+
},
904+
},
905+
);
906+
907+
assert.strictEqual(meta.page.total, 1, 'the total results meta is correct');
908+
assert.deepEqual(getIds(results), [vangogh.id], 'results are correct');
909+
});
910+
743911
test(`can search with a 'not' filter`, async function (assert) {
744912
let { mango, vangogh, ringo } = testCards;
745913
await setupIndex(dbAdapter, [

packages/host/tests/unit/query-field-normalization-test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,55 @@ module('normalizeQueryDefinition', function () {
239239
});
240240
});
241241

242+
test('resolves in filter with array interpolation from containsMany field', function (assert) {
243+
let realmURL = new URL('https://realm.example/');
244+
let instance = { tags: ['red', 'blue', 'green'] };
245+
let relativeTo = new URL('https://realm.example/cards/instance');
246+
247+
let normalized = normalizeQueryDefinition({
248+
fieldDefinition,
249+
queryDefinition: {
250+
filter: { in: { color: '$this.tags' } },
251+
},
252+
realmURL,
253+
fieldName: 'matchingItems',
254+
resolvePathValue: (path) => resolvePath(instance, path),
255+
relativeTo,
256+
});
257+
258+
let targetRef = codeRefWithAbsoluteURL(
259+
fieldDefinition.fieldOrCard,
260+
relativeTo,
261+
);
262+
assert.deepEqual(normalized?.query.filter, {
263+
in: { color: ['red', 'blue', 'green'] },
264+
on: targetRef,
265+
});
266+
});
267+
268+
test('aborts in filter when interpolated array is undefined', function (assert) {
269+
let realmURL = new URL('https://realm.example/');
270+
let instance = {};
271+
let relativeTo = new URL('https://realm.example/cards/instance');
272+
273+
let normalized = normalizeQueryDefinition({
274+
fieldDefinition,
275+
queryDefinition: {
276+
filter: { in: { color: '$this.tags' } },
277+
},
278+
realmURL,
279+
fieldName: 'matchingItems',
280+
resolvePathValue: (path) => resolvePath(instance, path),
281+
relativeTo,
282+
});
283+
284+
assert.strictEqual(
285+
normalized,
286+
null,
287+
'query is aborted when in value is undefined',
288+
);
289+
});
290+
242291
test('resolves live instances via custom path resolver', function (assert) {
243292
let realmURL = new URL('https://realm.example/');
244293
let instance = { address: { city: 'Paris' } };

packages/runtime-common/index-query-engine.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
type Query,
4444
type Filter,
4545
type EqFilter,
46+
type InFilter,
4647
type NotFilter,
4748
type ContainsFilter,
4849
type Sort,
@@ -812,6 +813,8 @@ export class IndexQueryEngine {
812813

813814
if ('eq' in filter) {
814815
return this.eqCondition(filter, on, typeConditionRef);
816+
} else if ('in' in filter) {
817+
return this.inCondition(filter, on, typeConditionRef);
815818
} else if ('contains' in filter) {
816819
return this.containsCondition(filter, on, typeConditionRef);
817820
} else if ('not' in filter) {
@@ -860,6 +863,20 @@ export class IndexQueryEngine {
860863
]);
861864
}
862865

866+
private inCondition(
867+
filter: InFilter,
868+
on: CodeRef,
869+
typeConditionRef?: CodeRef,
870+
): CardExpression {
871+
let typeRef = typeConditionRef;
872+
return every([
873+
...(typeRef ? [this.typeCondition(typeRef)] : []),
874+
...Object.entries(filter.in).map(([key, values]) => {
875+
return this.fieldInFilter(key, values as JSONTypes.Value[], on);
876+
}),
877+
]);
878+
}
879+
863880
private containsCondition(
864881
filter: ContainsFilter,
865882
on: CodeRef,
@@ -930,6 +947,59 @@ export class IndexQueryEngine {
930947
];
931948
}
932949

950+
private fieldInFilter(
951+
key: string,
952+
values: JSONTypes.Value[],
953+
onRef: CodeRef,
954+
): CardExpression {
955+
if (values.length === 0) {
956+
// Empty set matches nothing
957+
return ['false'];
958+
}
959+
let nonNullValues = values.filter((v) => v !== null);
960+
let hasNull = values.some((v) => v === null);
961+
962+
let conditions: CardExpression[] = [];
963+
964+
if (nonNullValues.length > 0) {
965+
let query = fieldQuery(key, onRef, false, 'filter');
966+
let inList: CardExpression = [];
967+
nonNullValues.forEach((v, i) => {
968+
if (i > 0) {
969+
inList.push(',');
970+
}
971+
inList.push(fieldValue(key, [param(v)], onRef, 'filter'));
972+
});
973+
conditions.push([
974+
fieldArity({
975+
type: onRef,
976+
path: key,
977+
value: [query, 'IN', '(', ...inList, ')'],
978+
errorHint: 'filter',
979+
}),
980+
]);
981+
}
982+
983+
if (hasNull) {
984+
let query = fieldQuery(key, onRef, true, 'filter');
985+
conditions.push([
986+
fieldArity({
987+
type: onRef,
988+
path: key,
989+
value: [query, 'IS NULL'],
990+
pluralValue: [query, "= 'null'::jsonb"],
991+
usePluralContainer: true,
992+
errorHint: 'filter',
993+
}),
994+
]);
995+
}
996+
997+
if (conditions.length === 1) {
998+
return conditions[0];
999+
}
1000+
return any(conditions);
1001+
}
1002+
9331003
private fieldLikeFilter(
9341004
key: string,
9351005
value: JSONTypes.Value,

packages/runtime-common/query-field-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121

2222
const EMPTY_PREDICATE_KEYS = new Set([
2323
'eq',
24+
'in',
2425
'contains',
2526
'range',
2627
'any',

0 commit comments

Comments
 (0)