diff --git a/packages/db/tests/collection-indexes.test.ts b/packages/db/tests/collection-indexes.test.ts index a441a5520..6faf07aa0 100644 --- a/packages/db/tests/collection-indexes.test.ts +++ b/packages/db/tests/collection-indexes.test.ts @@ -625,6 +625,19 @@ describe(`Collection Indexes`, () => { }) }) + it(`should exclude the boundary value from greater than queries on dates`, () => { + // gt must be strict for date fields: Bob was created exactly on + // 2023-01-02, so only rows created strictly later may be returned. + collection.createIndex((row) => row.createdAt) + + const result = collection.currentStateAsChanges({ + where: gt(new PropRef([`createdAt`]), new Date(`2023-01-02`)), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Charlie`, `Diana`, `Eve`]) + }) + it(`should perform greater than or equal queries`, () => { withIndexTracking(collection, (tracker) => { const result = collection.currentStateAsChanges({ @@ -1179,6 +1192,103 @@ describe(`Collection Indexes`, () => { }) }) }) + + it(`should include rows matched by any OR condition when conditions mix indexed and non-indexed expressions`, () => { + // An OR query must return the union of rows matching each condition: + // eq(age, 25) matches Alice (age 25) + // gt(length(name), 6) matches Charlie (name length 7) + // `age` has an index while `length(name)` is a computed expression + // without one, but the chosen execution strategy must not change the + // result: both Alice and Charlie satisfy the OR and must be returned. + const result = collection.currentStateAsChanges({ + where: or( + eq(new PropRef([`age`]), 25), + gt(length(new PropRef([`name`])), 6), + ), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Alice`, `Charlie`]) + }) + + it(`should only return rows matching every AND condition when conditions mix indexed and non-indexed expressions`, () => { + // An AND query must return only the rows matching all conditions: + // eq(status, 'active') matches Alice, Charlie and Eve + // gt(length(name), 6) matches only Charlie (name length 7) + // `status` has an index while `length(name)` is a computed expression + // without one, but every condition must still be enforced: only + // Charlie satisfies both. + const result = collection.currentStateAsChanges({ + where: and( + eq(new PropRef([`status`]), `active`), + gt(length(new PropRef([`name`])), 6), + ), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Charlie`]) + }) + + it(`should apply the strictest lower bound when range conditions share the same value`, () => { + // gte(age, 25) AND gt(age, 25) reduces to age > 25: the strict + // comparison wins at the shared boundary, so Alice (age 25) must be + // excluded regardless of the order the conditions appear in. + const result = collection.currentStateAsChanges({ + where: and(gte(new PropRef([`age`]), 25), gt(new PropRef([`age`]), 25)), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Bob`, `Charlie`, `Diana`]) + }) + + it(`should apply the strictest upper bound when range conditions share the same value`, () => { + // lte(age, 30) AND lt(age, 30) reduces to age < 30: the strict + // comparison wins at the shared boundary, so Bob (age 30) must be + // excluded. + const result = collection.currentStateAsChanges({ + where: and(lte(new PropRef([`age`]), 30), lt(new PropRef([`age`]), 30)), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Alice`, `Diana`, `Eve`]) + }) + + it(`should apply the strictest bound for date ranges sharing the same value`, () => { + // Distinct Date instances representing the same point in time must be + // treated as equal values: gte(createdAt, jan2) AND gt(createdAt, jan2) + // reduces to createdAt > jan2, so Bob (created 2023-01-02) must be + // excluded. + collection.createIndex((row) => row.createdAt) + + const result = collection.currentStateAsChanges({ + where: and( + gte(new PropRef([`createdAt`]), new Date(`2023-01-02`)), + gt(new PropRef([`createdAt`]), new Date(`2023-01-02`)), + ), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Charlie`, `Diana`, `Eve`]) + }) + + it(`should enforce every AND condition when a range on one field is combined with conditions on other fields`, () => { + // An AND query that contains a compound range on one field plus a + // condition on another field must enforce all of them: + // gt(age, 24) AND lt(age, 36) matches Alice (25), Bob (30), + // Charlie (35) and Diana (28) + // eq(status, 'active') matches Alice, Charlie and Eve + // Only Alice and Charlie satisfy the full conjunction. + const result = collection.currentStateAsChanges({ + where: and( + gt(new PropRef([`age`]), 24), + lt(new PropRef([`age`]), 36), + eq(new PropRef([`status`]), `active`), + ), + })! + + const names = result.map((r) => r.value.name).sort() + expect(names).toEqual([`Alice`, `Charlie`]) + }) }) describe(`Index Usage Verification`, () => {