diff --git a/packages/db/tests/query/basic.test.ts b/packages/db/tests/query/basic.test.ts index cb299205..66970ecb 100644 --- a/packages/db/tests/query/basic.test.ts +++ b/packages/db/tests/query/basic.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, test } from "vitest" import { + concat, createLiveQueryCollection, eq, gt, @@ -15,18 +16,81 @@ type User = { age: number email: string active: boolean + profile?: { + bio: string + avatar: string + preferences: { + notifications: boolean + theme: `light` | `dark` + } + } + address?: { + street: string + city: string + country: string + coordinates: { + lat: number + lng: number + } + } } // Sample data for tests const sampleUsers: Array = [ - { id: 1, name: `Alice`, age: 25, email: `alice@example.com`, active: true }, - { id: 2, name: `Bob`, age: 19, email: `bob@example.com`, active: true }, + { + id: 1, + name: `Alice`, + age: 25, + email: `alice@example.com`, + active: true, + profile: { + bio: `Software engineer with 5 years experience`, + avatar: `https://example.com/alice.jpg`, + preferences: { + notifications: true, + theme: `dark`, + }, + }, + address: { + street: `123 Main St`, + city: `New York`, + country: `USA`, + coordinates: { + lat: 40.7128, + lng: -74.006, + }, + }, + }, + { + id: 2, + name: `Bob`, + age: 19, + email: `bob@example.com`, + active: true, + profile: { + bio: `Junior developer`, + avatar: `https://example.com/bob.jpg`, + preferences: { + notifications: false, + theme: `light`, + }, + }, + }, { id: 3, name: `Charlie`, age: 30, email: `charlie@example.com`, active: false, + address: { + street: `456 Oak Ave`, + city: `San Francisco`, + country: `USA`, + coordinates: { + lat: 37.7749, + lng: -122.4194, + }, + }, }, { id: 4, name: `Dave`, age: 22, email: `dave@example.com`, active: true }, ] @@ -715,6 +779,324 @@ function createBasicTests(autoIndex: `off` | `eager`) { expect(liveCollection.size).toBe(3) expect(liveCollection.get(5)).toBeUndefined() }) + + test(`should query nested object properties`, () => { + const usersWithProfiles = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + eq(user.profile.bio, `Software engineer with 5 years experience`) + ) + .select(({ user }) => ({ + id: user.id, + name: user.name, + bio: user.profile.bio, + })), + }) + + expect(usersWithProfiles.size).toBe(1) + expect(usersWithProfiles.get(1)).toMatchObject({ + id: 1, + name: `Alice`, + bio: `Software engineer with 5 years experience`, + }) + + // Query deeply nested properties + const darkThemeUsers = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.profile.preferences.theme, `dark`)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + theme: user.profile.preferences.theme, + })), + }) + + expect(darkThemeUsers.size).toBe(1) + expect(darkThemeUsers.get(1)).toMatchObject({ + id: 1, + name: `Alice`, + theme: `dark`, + }) + }) + + test(`should select nested object properties`, () => { + const nestedSelectCollection = createLiveQueryCollection({ + startSync: true, + query: (q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + name: user.name, + preferences: user.profile.preferences, + city: user.address.city, + coordinates: user.address.coordinates, + })), + }) + + const results = nestedSelectCollection.toArray + expect(results).toHaveLength(4) + + // Check Alice has all nested properties + const alice = results.find((u) => u.id === 1) + expect(alice).toMatchObject({ + id: 1, + name: `Alice`, + preferences: { + notifications: true, + theme: `dark`, + }, + city: `New York`, + coordinates: { + lat: 40.7128, + lng: -74.006, + }, + }) + + // Check Bob has profile but no address + const bob = results.find((u) => u.id === 2) + expect(bob).toMatchObject({ + id: 2, + name: `Bob`, + preferences: { + notifications: false, + theme: `light`, + }, + }) + expect(bob?.city).toBeUndefined() + expect(bob?.coordinates).toBeUndefined() + + // Check Charlie has address but no profile + const charlie = results.find((u) => u.id === 3) + expect(charlie).toMatchObject({ + id: 3, + name: `Charlie`, + city: `San Francisco`, + coordinates: { + lat: 37.7749, + lng: -122.4194, + }, + }) + expect(charlie?.preferences).toBeUndefined() + + // Check Dave has neither + const dave = results.find((u) => u.id === 4) + expect(dave).toMatchObject({ + id: 4, + name: `Dave`, + }) + expect(dave?.preferences).toBeUndefined() + expect(dave?.city).toBeUndefined() + }) + + test(`should handle updates to nested object properties`, () => { + const profileCollection = createLiveQueryCollection({ + startSync: true, + query: (q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + name: user.name, + theme: user.profile.preferences.theme, + notifications: user.profile.preferences.notifications, + })), + }) + + expect(profileCollection.size).toBe(4) // All users, but some will have undefined values + + // Update Bob's theme + const bob = sampleUsers.find((u) => u.id === 2)! + const updatedBob = { + ...bob, + profile: { + ...bob.profile!, + preferences: { + ...bob.profile!.preferences, + theme: `dark` as const, + }, + }, + } + + usersCollection.utils.begin() + usersCollection.utils.write({ + type: `update`, + value: updatedBob, + }) + usersCollection.utils.commit() + + expect(profileCollection.get(2)).toMatchObject({ + id: 2, + name: `Bob`, + theme: `dark`, + notifications: false, + }) + + // Add profile to Dave + const dave = sampleUsers.find((u) => u.id === 4)! + const daveWithProfile = { + ...dave, + profile: { + bio: `Full stack developer`, + avatar: `https://example.com/dave.jpg`, + preferences: { + notifications: true, + theme: `light` as const, + }, + }, + } + + usersCollection.utils.begin() + usersCollection.utils.write({ + type: `update`, + value: daveWithProfile, + }) + usersCollection.utils.commit() + + expect(profileCollection.size).toBe(4) // All users + expect(profileCollection.get(4)).toMatchObject({ + id: 4, + name: `Dave`, + theme: `light`, + notifications: true, + }) + + // Remove profile from Bob + const bobWithoutProfile = { + ...updatedBob, + profile: undefined, + } + + usersCollection.utils.begin() + usersCollection.utils.write({ + type: `update`, + value: bobWithoutProfile, + }) + usersCollection.utils.commit() + + expect(profileCollection.size).toBe(4) // All users still there, Bob will have undefined values + expect(profileCollection.get(2)).toMatchObject({ + id: 2, + name: `Bob`, + theme: undefined, + notifications: undefined, + }) + }) + + test(`should work with spread operator on nested objects`, () => { + const spreadCollection = createLiveQueryCollection({ + startSync: true, + query: (q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + name: user.name, + street: user.address.street, + city: user.address.city, + country: user.address.country, + coordinates: user.address.coordinates, + })), + }) + + const results = spreadCollection.toArray + expect(results).toHaveLength(4) // All users, but some will have undefined values + + const alice = results.find((u) => u.id === 1) + expect(alice).toMatchObject({ + id: 1, + name: `Alice`, + street: `123 Main St`, + city: `New York`, + country: `USA`, + coordinates: { + lat: 40.7128, + lng: -74.006, + }, + }) + }) + + test(`should filter based on deeply nested properties`, () => { + const nyUsers = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.address.city, `New York`)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + lat: user.address.coordinates.lat, + lng: user.address.coordinates.lng, + })), + }) + + expect(nyUsers.size).toBe(1) + expect(nyUsers.get(1)).toMatchObject({ + id: 1, + name: `Alice`, + lat: 40.7128, + lng: -74.006, + }) + + // Test with numeric comparison on nested property + const northernUsers = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ user: usersCollection }) + .where(({ user }) => gt(user.address.coordinates.lat, 38)) + .select(({ user }) => ({ + id: user.id, + name: user.name, + city: user.address.city, + })), + }) + + expect(northernUsers.size).toBe(1) // Only Alice (NY) + expect(northernUsers.get(1)).toMatchObject({ + id: 1, + name: `Alice`, + city: `New York`, + }) + }) + + test(`should handle computed fields with nested properties`, () => { + const computedCollection = createLiveQueryCollection({ + startSync: true, + query: (q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + name: user.name, + city: user.address.city, + country: user.address.country, + hasNotifications: user.profile.preferences.notifications, + profileSummary: concat(upper(user.name), ` - `, user.profile.bio), + })), + }) + + const results = computedCollection.toArray + expect(results).toHaveLength(4) + + const alice = results.find((u) => u.id === 1) + expect(alice).toMatchObject({ + id: 1, + name: `Alice`, + city: `New York`, + country: `USA`, + hasNotifications: true, + profileSummary: `ALICE - Software engineer with 5 years experience`, + }) + + const dave = results.find((u) => u.id === 4) + expect(dave).toMatchObject({ + id: 4, + name: `Dave`, + }) + expect(dave?.city).toBeUndefined() + expect(dave?.country).toBeUndefined() + expect(dave?.hasNotifications).toBeUndefined() + }) }) } diff --git a/packages/db/tests/query/builder/select.test.ts b/packages/db/tests/query/builder/select.test.ts index 2b632f7d..366c2f3a 100644 --- a/packages/db/tests/query/builder/select.test.ts +++ b/packages/db/tests/query/builder/select.test.ts @@ -10,6 +10,19 @@ interface Employee { department_id: number | null salary: number active: boolean + profile?: { + bio: string + skills: Array + contact: { + email: string + phone: string + } + } + address?: { + street: string + city: string + country: string + } } // Test collection @@ -172,4 +185,143 @@ describe(`QueryBuilder.select`, () => { expect(builtQuery.select).toBeDefined() expect(builtQuery.select).toHaveProperty(`is_high_earner`) }) + + it(`selects nested object properties`, () => { + const builder = new Query() + const query = builder + .from({ employees: employeesCollection }) + .select(({ employees }) => ({ + id: employees.id, + name: employees.name, + bio: employees.profile.bio, + skills: employees.profile.skills, + city: employees.address.city, + })) + + const builtQuery = getQueryIR(query) + expect(builtQuery.select).toBeDefined() + expect(builtQuery.select).toHaveProperty(`bio`) + expect(builtQuery.select).toHaveProperty(`skills`) + expect(builtQuery.select).toHaveProperty(`city`) + }) + + it(`selects deeply nested properties`, () => { + const builder = new Query() + const query = builder + .from({ employees: employeesCollection }) + .select(({ employees }) => ({ + id: employees.id, + email: employees.profile.contact.email, + phone: employees.profile.contact.phone, + })) + + const builtQuery = getQueryIR(query) + expect(builtQuery.select).toBeDefined() + expect(builtQuery.select).toHaveProperty(`email`) + expect(builtQuery.select).toHaveProperty(`phone`) + }) + + it(`handles spread operator with nested objects`, () => { + const builder = new Query() + const query = builder + .from({ employees: employeesCollection }) + .select(({ employees }) => ({ + id: employees.id, + name: employees.name, + ...employees.profile, + })) + + const builtQuery = getQueryIR(query) + expect(builtQuery.select).toBeDefined() + expect(builtQuery.select).toHaveProperty(`id`) + expect(builtQuery.select).toHaveProperty(`name`) + // Note: The actual spreading behavior would depend on the implementation + }) + + it(`combines nested and computed properties`, () => { + const builder = new Query() + const query = builder + .from({ employees: employeesCollection }) + .select(({ employees }) => ({ + id: employees.id, + upperCity: upper(employees.address.city), + skillCount: count(employees.profile.skills), + fullAddress: employees.address, + })) + + const builtQuery = getQueryIR(query) + expect(builtQuery.select).toBeDefined() + expect(builtQuery.select).toHaveProperty(`upperCity`) + expect(builtQuery.select).toHaveProperty(`skillCount`) + expect(builtQuery.select).toHaveProperty(`fullAddress`) + + const upperCityExpr = (builtQuery.select as any).upperCity + expect(upperCityExpr.type).toBe(`func`) + expect(upperCityExpr.name).toBe(`upper`) + }) + + it(`selects nested arrays and objects with aliasing`, () => { + const builder = new Query() + const query = builder + .from({ employees: employeesCollection }) + .select(({ employees }) => ({ + employeeId: employees.id, + employeeName: employees.name, + employeeSkills: employees.profile.skills, + contactInfo: employees.profile.contact, + location: { + city: employees.address.city, + country: employees.address.country, + }, + })) + + const builtQuery = getQueryIR(query) + expect(builtQuery.select).toBeDefined() + expect(builtQuery.select).toHaveProperty(`employeeId`) + expect(builtQuery.select).toHaveProperty(`employeeName`) + expect(builtQuery.select).toHaveProperty(`employeeSkills`) + expect(builtQuery.select).toHaveProperty(`contactInfo`) + expect(builtQuery.select).toHaveProperty(`location`) + }) + + it(`handles optional chaining with nested properties`, () => { + const builder = new Query() + const query = builder + .from({ employees: employeesCollection }) + .select(({ employees }) => ({ + id: employees.id, + hasProfile: employees.profile !== undefined, + profileBio: employees.profile.bio, + addressStreet: employees.address.street, + contactEmail: employees.profile.contact?.email, + })) + + const builtQuery = getQueryIR(query) + expect(builtQuery.select).toBeDefined() + expect(builtQuery.select).toHaveProperty(`hasProfile`) + expect(builtQuery.select).toHaveProperty(`profileBio`) + expect(builtQuery.select).toHaveProperty(`addressStreet`) + expect(builtQuery.select).toHaveProperty(`contactEmail`) + }) + + it(`selects partial nested objects`, () => { + const builder = new Query() + const query = builder + .from({ employees: employeesCollection }) + .select(({ employees }) => ({ + id: employees.id, + partialProfile: { + bio: employees.profile.bio, + skillCount: employees.profile.skills.length, + }, + partialAddress: { + city: employees.address.city, + }, + })) + + const builtQuery = getQueryIR(query) + expect(builtQuery.select).toBeDefined() + expect(builtQuery.select).toHaveProperty(`partialProfile`) + expect(builtQuery.select).toHaveProperty(`partialAddress`) + }) }) diff --git a/packages/db/tests/query/group-by.test.ts b/packages/db/tests/query/group-by.test.ts index 684eb472..3790304d 100644 --- a/packages/db/tests/query/group-by.test.ts +++ b/packages/db/tests/query/group-by.test.ts @@ -27,6 +27,32 @@ type Order = { quantity: number discount: number sales_rep_id: number | null + customer?: { + name: string + tier: `bronze` | `silver` | `gold` | `platinum` + address: { + city: string + state: string + country: string + } + preferences: { + newsletter: boolean + marketing: boolean + } + } + shipping?: { + method: string + cost: number + address: { + street: string + city: string + zipCode: string + } + tracking?: { + number: string + status: string + } + } } // Sample order data @@ -41,6 +67,32 @@ const sampleOrders: Array = [ quantity: 2, discount: 0, sales_rep_id: 1, + customer: { + name: `John Doe`, + tier: `gold`, + address: { + city: `New York`, + state: `NY`, + country: `USA`, + }, + preferences: { + newsletter: true, + marketing: false, + }, + }, + shipping: { + method: `express`, + cost: 15.99, + address: { + street: `123 Main St`, + city: `New York`, + zipCode: `10001`, + }, + tracking: { + number: `TRK123456`, + status: `delivered`, + }, + }, }, { id: 2, @@ -52,6 +104,28 @@ const sampleOrders: Array = [ quantity: 1, discount: 10, sales_rep_id: 1, + customer: { + name: `John Doe`, + tier: `gold`, + address: { + city: `New York`, + state: `NY`, + country: `USA`, + }, + preferences: { + newsletter: true, + marketing: false, + }, + }, + shipping: { + method: `standard`, + cost: 5.99, + address: { + street: `123 Main St`, + city: `New York`, + zipCode: `10001`, + }, + }, }, { id: 3, @@ -63,6 +137,28 @@ const sampleOrders: Array = [ quantity: 3, discount: 5, sales_rep_id: 2, + customer: { + name: `Jane Smith`, + tier: `silver`, + address: { + city: `Los Angeles`, + state: `CA`, + country: `USA`, + }, + preferences: { + newsletter: false, + marketing: true, + }, + }, + shipping: { + method: `standard`, + cost: 7.99, + address: { + street: `456 Oak Ave`, + city: `Los Angeles`, + zipCode: `90210`, + }, + }, }, { id: 4, @@ -952,6 +1048,280 @@ function createGroupByTests(autoIndex: `off` | `eager`): void { expect(customer1?.max_quantity).toBe(2) }) }) + + describe(`Nested Object GroupBy`, () => { + let ordersCollection: ReturnType + + beforeEach(() => { + ordersCollection = createOrdersCollection(autoIndex) + }) + + test(`group by nested object properties`, () => { + const tierSummary = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ orders: ordersCollection }) + .where(({ orders }) => orders.customer !== undefined) + .groupBy(({ orders }) => orders.customer.tier) + .select(({ orders }) => ({ + tier: orders.customer.tier, + order_count: count(orders.id), + total_amount: sum(orders.amount), + avg_amount: avg(orders.amount), + })), + }) + + const results = tierSummary.toArray + expect(results).toHaveLength(2) // gold and silver + + const goldTier = results.find((r) => r.tier === `gold`) + expect(goldTier).toBeDefined() + expect(goldTier?.order_count).toBe(2) // Orders 1 and 2 + expect(goldTier?.total_amount).toBe(300) // 100 + 200 + + const silverTier = results.find((r) => r.tier === `silver`) + expect(silverTier).toBeDefined() + expect(silverTier?.order_count).toBe(1) // Order 3 + expect(silverTier?.total_amount).toBe(150) + }) + + test(`group by deeply nested properties`, () => { + const stateSummary = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ orders: ordersCollection }) + .where(({ orders }) => orders.customer.address !== undefined) + .groupBy(({ orders }) => orders.customer.address.state) + .select(({ orders }) => ({ + state: orders.customer.address.state, + order_count: count(orders.id), + total_amount: sum(orders.amount), + cities: orders.customer.address.city, + })), + }) + + const results = stateSummary.toArray + expect(results).toHaveLength(2) // NY and CA + + const nyOrders = results.find((r) => r.state === `NY`) + expect(nyOrders).toBeDefined() + expect(nyOrders?.order_count).toBe(2) // Orders from New York + expect(nyOrders?.total_amount).toBe(300) // 100 + 200 + + const caOrders = results.find((r) => r.state === `CA`) + expect(caOrders).toBeDefined() + expect(caOrders?.order_count).toBe(1) // Order from Los Angeles + expect(caOrders?.total_amount).toBe(150) + }) + + test(`group by shipping method with nested aggregation`, () => { + const shippingSummary = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ orders: ordersCollection }) + .where(({ orders }) => orders.shipping !== undefined) + .groupBy(({ orders }) => orders.shipping.method) + .select(({ orders }) => ({ + method: orders.shipping.method, + order_count: count(orders.id), + total_amount: sum(orders.amount), + avg_shipping_cost: avg(orders.shipping.cost), + total_shipping_cost: sum(orders.shipping.cost), + })), + }) + + const results = shippingSummary.toArray + expect(results).toHaveLength(2) // express and standard + + const expressOrders = results.find((r) => r.method === `express`) + expect(expressOrders).toBeDefined() + expect(expressOrders?.order_count).toBe(1) // Order 1 + expect(expressOrders?.total_amount).toBe(100) + expect(expressOrders?.avg_shipping_cost).toBe(15.99) + + const standardOrders = results.find((r) => r.method === `standard`) + expect(standardOrders).toBeDefined() + expect(standardOrders?.order_count).toBe(2) // Orders 2 and 3 + expect(standardOrders?.total_amount).toBe(350) // 200 + 150 + expect(standardOrders?.avg_shipping_cost).toBeCloseTo(6.99, 2) // (5.99 + 7.99) / 2 + }) + + test(`group by multiple nested properties`, () => { + const complexGrouping = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ orders: ordersCollection }) + .where(({ orders }) => + and( + orders.customer !== undefined, + orders.shipping !== undefined + ) + ) + .groupBy(({ orders }) => orders.customer.tier) + .groupBy(({ orders }) => orders.shipping.method) + .select(({ orders }) => ({ + tier: orders.customer.tier, + method: orders.shipping.method, + order_count: count(orders.id), + total_amount: sum(orders.amount), + })), + }) + + const results = complexGrouping.toArray + expect(results.length).toBeGreaterThan(0) + + // Should have groups for each tier-method combination + const goldExpress = results.find( + (r) => r.tier === `gold` && r.method === `express` + ) + expect(goldExpress).toBeDefined() + expect(goldExpress?.order_count).toBe(1) + expect(goldExpress?.total_amount).toBe(100) + + const goldStandard = results.find( + (r) => r.tier === `gold` && r.method === `standard` + ) + expect(goldStandard).toBeDefined() + expect(goldStandard?.order_count).toBe(1) + expect(goldStandard?.total_amount).toBe(200) + }) + + test(`group by with nested boolean properties`, () => { + const preferenceSummary = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ orders: ordersCollection }) + .where(({ orders }) => orders.customer.preferences !== undefined) + .groupBy(({ orders }) => orders.customer.preferences.newsletter) + .select(({ orders }) => ({ + newsletter_subscribed: orders.customer.preferences.newsletter, + order_count: count(orders.id), + total_amount: sum(orders.amount), + avg_amount: avg(orders.amount), + })), + }) + + const results = preferenceSummary.toArray + expect(results).toHaveLength(2) // true and false + + const subscribedUsers = results.find( + (r) => r.newsletter_subscribed === true + ) + expect(subscribedUsers).toBeDefined() + expect(subscribedUsers?.order_count).toBe(2) // Orders from John Doe (gold tier) + expect(subscribedUsers?.total_amount).toBe(300) // 100 + 200 + + const unsubscribedUsers = results.find( + (r) => r.newsletter_subscribed === false + ) + expect(unsubscribedUsers).toBeDefined() + expect(unsubscribedUsers?.order_count).toBe(1) // Order from Jane Smith + expect(unsubscribedUsers?.total_amount).toBe(150) + }) + + test(`group by with conditional nested properties`, () => { + const trackingSummary = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ orders: ordersCollection }) + .groupBy(({ orders }) => + orders.shipping.tracking !== undefined ? `tracked` : `untracked` + ) + .select(({ orders }) => ({ + tracking_status: + orders.shipping.tracking !== undefined + ? `tracked` + : `untracked`, + order_count: count(orders.id), + total_amount: sum(orders.amount), + has_tracking: orders.shipping.tracking !== undefined, + })), + }) + + const results = trackingSummary.toArray + expect(results).toHaveLength(2) // tracked and untracked + + const tracked = results.find((r) => r.tracking_status === `tracked`) + expect(tracked).toBeDefined() + expect(tracked?.order_count).toBe(1) // Only order 1 has tracking + expect(tracked?.total_amount).toBe(100) + + const untracked = results.find((r) => r.tracking_status === `untracked`) + expect(untracked).toBeDefined() + expect(untracked?.order_count).toBeGreaterThan(0) // Orders without tracking + orders without shipping + }) + + test(`handles live updates with nested group by`, () => { + const tierSummary = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ orders: ordersCollection }) + .where(({ orders }) => orders.customer !== undefined) + .groupBy(({ orders }) => orders.customer.tier) + .select(({ orders }) => ({ + tier: orders.customer.tier, + order_count: count(orders.id), + total_amount: sum(orders.amount), + })), + }) + + // Initial state + let results = tierSummary.toArray + const initialGoldCount = + results.find((r) => r.tier === `gold`)?.order_count || 0 + + // Add a new order for a platinum customer + const newOrder: Order = { + id: 999, + customer_id: 999, + amount: 500, + status: `completed`, + date: `2023-03-01`, + product_category: `luxury`, + quantity: 1, + discount: 0, + sales_rep_id: 1, + customer: { + name: `Premium Customer`, + tier: `platinum`, + address: { + city: `Miami`, + state: `FL`, + country: `USA`, + }, + preferences: { + newsletter: true, + marketing: true, + }, + }, + } + + ordersCollection.utils.begin() + ordersCollection.utils.write({ + type: `insert`, + value: newOrder, + }) + ordersCollection.utils.commit() + + // Should now have a platinum tier group + results = tierSummary.toArray + const platinumTier = results.find((r) => r.tier === `platinum`) + expect(platinumTier).toBeDefined() + expect(platinumTier?.order_count).toBe(1) + expect(platinumTier?.total_amount).toBe(500) + + // Gold tier should remain unchanged + const goldTier = results.find((r) => r.tier === `gold`) + expect(goldTier?.order_count).toBe(initialGoldCount) + }) + }) }) } diff --git a/packages/db/tests/query/order-by.test.ts b/packages/db/tests/query/order-by.test.ts index b13fed30..eaa68401 100644 --- a/packages/db/tests/query/order-by.test.ts +++ b/packages/db/tests/query/order-by.test.ts @@ -11,6 +11,22 @@ type Person = { email: string isActive: boolean team: string + profile?: { + bio: string + score: number + stats: { + tasksCompleted: number + rating: number + } + } + address?: { + city: string + country: string + coordinates: { + lat: number + lng: number + } + } } const initialPersons: Array = [ @@ -21,6 +37,22 @@ const initialPersons: Array = [ email: `john.doe@example.com`, isActive: true, team: `team1`, + profile: { + bio: `Senior developer with 5 years experience`, + score: 85, + stats: { + tasksCompleted: 120, + rating: 4.5, + }, + }, + address: { + city: `New York`, + country: `USA`, + coordinates: { + lat: 40.7128, + lng: -74.006, + }, + }, }, { id: `2`, @@ -29,6 +61,22 @@ const initialPersons: Array = [ email: `jane.doe@example.com`, isActive: true, team: `team2`, + profile: { + bio: `Junior developer`, + score: 92, + stats: { + tasksCompleted: 85, + rating: 4.8, + }, + }, + address: { + city: `Los Angeles`, + country: `USA`, + coordinates: { + lat: 34.0522, + lng: -118.2437, + }, + }, }, { id: `3`, @@ -37,6 +85,14 @@ const initialPersons: Array = [ email: `john.smith@example.com`, isActive: true, team: `team1`, + profile: { + bio: `Lead engineer`, + score: 78, + stats: { + tasksCompleted: 200, + rating: 4.2, + }, + }, }, ] @@ -622,6 +678,258 @@ function createOrderByTests(autoIndex: `off` | `eager`): void { expect(results).toHaveLength(0) }) }) + + describe(`Nested Object OrderBy`, () => { + let personsCollection: ReturnType> + + beforeEach(() => { + personsCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-persons-nested`, + getKey: (person) => person.id, + initialData: initialPersons, + autoIndex, + }) + ) + }) + + it(`orders by nested object properties ascending`, async () => { + const collection = createLiveQueryCollection((q) => + q + .from({ persons: personsCollection }) + .orderBy(({ persons }) => persons.profile.score, `asc`) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + score: persons.profile.score, + })) + ) + await collection.preload() + + const results = Array.from(collection.values()) + expect(results).toHaveLength(3) + expect(results.map((r) => r.score)).toEqual([78, 85, 92]) // John Smith, John Doe, Jane Doe + expect(results.map((r) => r.name)).toEqual([ + `John Smith`, + `John Doe`, + `Jane Doe`, + ]) + }) + + it(`orders by nested object properties descending`, async () => { + const collection = createLiveQueryCollection((q) => + q + .from({ persons: personsCollection }) + .orderBy(({ persons }) => persons.profile.score, `desc`) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + score: persons.profile.score, + })) + ) + await collection.preload() + + const results = Array.from(collection.values()) + expect(results).toHaveLength(3) + expect(results.map((r) => r.score)).toEqual([92, 85, 78]) // Jane Doe, John Doe, John Smith + expect(results.map((r) => r.name)).toEqual([ + `Jane Doe`, + `John Doe`, + `John Smith`, + ]) + }) + + it(`orders by deeply nested properties`, async () => { + const collection = createLiveQueryCollection((q) => + q + .from({ persons: personsCollection }) + .orderBy(({ persons }) => persons.profile.stats.rating, `desc`) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + rating: persons.profile.stats.rating, + tasksCompleted: persons.profile.stats.tasksCompleted, + })) + ) + await collection.preload() + + const results = Array.from(collection.values()) + expect(results).toHaveLength(3) + expect(results.map((r) => r.rating)).toEqual([4.8, 4.5, 4.2]) // Jane, John Doe, John Smith + expect(results.map((r) => r.name)).toEqual([ + `Jane Doe`, + `John Doe`, + `John Smith`, + ]) + }) + + it(`orders by multiple nested properties`, async () => { + const collection = createLiveQueryCollection((q) => + q + .from({ persons: personsCollection }) + .orderBy(({ persons }) => persons.team, `asc`) + .orderBy(({ persons }) => persons.profile.score, `desc`) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + team: persons.team, + score: persons.profile.score, + })) + ) + await collection.preload() + + const results = Array.from(collection.values()) + expect(results).toHaveLength(3) + + // Should be ordered by team ASC, then score DESC within each team + // team1: John Doe (85), John Smith (78) + // team2: Jane Doe (92) + expect(results.map((r) => r.team)).toEqual([`team1`, `team1`, `team2`]) + expect(results.map((r) => r.name)).toEqual([ + `John Doe`, + `John Smith`, + `Jane Doe`, + ]) + expect(results.map((r) => r.score)).toEqual([85, 78, 92]) + }) + + it(`orders by coordinates (nested numeric properties)`, async () => { + const collection = createLiveQueryCollection((q) => + q + .from({ persons: personsCollection }) + .where(({ persons }) => persons.address !== undefined) + .orderBy(({ persons }) => persons.address.coordinates.lat, `asc`) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + city: persons.address.city, + lat: persons.address.coordinates.lat, + })) + ) + await collection.preload() + + const results = Array.from(collection.values()) + expect(results).toHaveLength(2) // Only John Doe and Jane Doe have addresses + expect(results.map((r) => r.lat)).toEqual([34.0522, 40.7128]) // LA, then NY + expect(results.map((r) => r.city)).toEqual([`Los Angeles`, `New York`]) + }) + + it(`handles null/undefined nested properties in ordering`, async () => { + // Add a person without profile for testing + const personWithoutProfile: Person = { + id: `4`, + name: `Test Person`, + age: 40, + email: `test@example.com`, + isActive: true, + team: `team3`, + } + + personsCollection.utils.begin() + personsCollection.utils.write({ + type: `insert`, + value: personWithoutProfile, + }) + personsCollection.utils.commit() + + const collection = createLiveQueryCollection((q) => + q + .from({ persons: personsCollection }) + .orderBy(({ persons }) => persons.profile.score, `desc`) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + score: persons.profile.score, + })) + ) + await collection.preload() + + const results = Array.from(collection.values()) + expect(results).toHaveLength(4) + + // Person without profile should have score 0 and be last + expect(results.map((r) => r.score)).toEqual([92, 85, 78, 0]) + expect(results[3].name).toBe(`Test Person`) + }) + + it(`maintains ordering during live updates of nested properties`, async () => { + const collection = createLiveQueryCollection((q) => + q + .from({ persons: personsCollection }) + .orderBy(({ persons }) => persons.profile.score, `desc`) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + score: persons.profile.score, + })) + ) + await collection.preload() + + // Initial order should be Jane (92), John Doe (85), John Smith (78) + let results = Array.from(collection.values()) + expect(results.map((r) => r.name)).toEqual([ + `Jane Doe`, + `John Doe`, + `John Smith`, + ]) + + // Update John Smith's score to be highest + const johnSmith = initialPersons.find((p) => p.id === `3`)! + const updatedJohnSmith: Person = { + ...johnSmith, + profile: { + ...johnSmith.profile!, + score: 95, // Higher than Jane's 92 + }, + } + + personsCollection.utils.begin() + personsCollection.utils.write({ + type: `update`, + value: updatedJohnSmith, + }) + personsCollection.utils.commit() + + // Order should now be John Smith (95), Jane (92), John Doe (85) + results = Array.from(collection.values()) + expect(results.map((r) => r.name)).toEqual([ + `John Smith`, + `Jane Doe`, + `John Doe`, + ]) + expect(results.map((r) => r.score)).toEqual([95, 92, 85]) + }) + + it(`handles string ordering on nested properties`, async () => { + const collection = createLiveQueryCollection((q) => + q + .from({ persons: personsCollection }) + .orderBy(({ persons }) => persons.address.city, `asc`) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + city: persons.address.city, + })) + ) + await collection.preload() + + const results = Array.from(collection.values()) + expect(results).toHaveLength(3) + + // Should be ordered: Los Angeles, New York, undefined (John Smith has no address) + // Note: undefined values in ORDER BY may be handled differently by the query engine + expect(results.map((r) => r.city)).toEqual([ + `Los Angeles`, + `New York`, + undefined, + ]) + expect(results.map((r) => r.name)).toEqual([ + `Jane Doe`, + `John Doe`, + `John Smith`, + ]) + }) + }) }) } diff --git a/packages/db/tests/query/where.test.ts b/packages/db/tests/query/where.test.ts index 8dc673f3..b1f6c546 100644 --- a/packages/db/tests/query/where.test.ts +++ b/packages/db/tests/query/where.test.ts @@ -33,6 +33,36 @@ type Employee = { first_name: string last_name: string age: number + profile?: { + skills: Array + certifications: Array<{ + name: string + date: string + valid: boolean + }> + experience: { + years: number + companies: Array<{ + name: string + role: string + duration: number + }> + } + } + contact?: { + phone: string | null + address: { + street: string + city: string + state: string + zip: string + } | null + emergency: { + name: string + relation: string + phone: string + } + } } // Sample employee data @@ -48,6 +78,34 @@ const sampleEmployees: Array = [ first_name: `Alice`, last_name: `Johnson`, age: 28, + profile: { + skills: [`JavaScript`, `TypeScript`, `React`], + certifications: [ + { name: `AWS Certified Developer`, date: `2022-05-15`, valid: true }, + { name: `Scrum Master`, date: `2021-03-10`, valid: true }, + ], + experience: { + years: 5, + companies: [ + { name: `TechCorp`, role: `Senior Developer`, duration: 3 }, + { name: `StartupXYZ`, role: `Developer`, duration: 2 }, + ], + }, + }, + contact: { + phone: `555-0101`, + address: { + street: `123 Main St`, + city: `San Francisco`, + state: `CA`, + zip: `94105`, + }, + emergency: { + name: `John Johnson`, + relation: `Spouse`, + phone: `555-0102`, + }, + }, }, { id: 2, @@ -60,6 +118,28 @@ const sampleEmployees: Array = [ first_name: `Bob`, last_name: `Smith`, age: 32, + profile: { + skills: [`Python`, `Django`, `PostgreSQL`], + certifications: [ + { name: `Python Developer`, date: `2020-08-20`, valid: true }, + ], + experience: { + years: 8, + companies: [ + { name: `DataCorp`, role: `Backend Developer`, duration: 5 }, + { name: `WebAgency`, role: `Junior Developer`, duration: 3 }, + ], + }, + }, + contact: { + phone: `555-0201`, + address: null, + emergency: { + name: `Mary Smith`, + relation: `Sister`, + phone: `555-0202`, + }, + }, }, { id: 3, @@ -72,6 +152,20 @@ const sampleEmployees: Array = [ first_name: `Charlie`, last_name: `Brown`, age: 35, + profile: { + skills: [`Java`, `Spring`, `Kubernetes`], + certifications: [ + { name: `Java Certified`, date: `2019-02-15`, valid: false }, + { name: `Kubernetes Admin`, date: `2023-01-20`, valid: true }, + ], + experience: { + years: 10, + companies: [ + { name: `EnterpriseCo`, role: `Lead Developer`, duration: 7 }, + { name: `CloudTech`, role: `Senior Developer`, duration: 3 }, + ], + }, + }, }, { id: 4, @@ -84,6 +178,20 @@ const sampleEmployees: Array = [ first_name: `Diana`, last_name: `Miller`, age: 29, + contact: { + phone: null, + address: { + street: `789 Elm St`, + city: `San Francisco`, + state: `CA`, + zip: `94110`, + }, + emergency: { + name: `Robert Miller`, + relation: `Father`, + phone: `555-0401`, + }, + }, }, { id: 5, @@ -1271,6 +1379,287 @@ function createWhereTests(autoIndex: `off` | `eager`): void { employeesCollection.utils.commit() }) }) + + describe(`Nested Object Queries`, () => { + let employeesCollection: ReturnType + + beforeEach(() => { + employeesCollection = createEmployeesCollection(autoIndex) + }) + + test(`should filter by nested object properties`, () => { + // Filter by nested profile.skills array + const jsDevs = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ emp: employeesCollection }) + .where(({ emp }) => inArray(`JavaScript`, emp.profile.skills)) + .select(({ emp }) => ({ + id: emp.id, + name: emp.name, + skills: emp.profile.skills, + })), + }) + + expect(jsDevs.size).toBe(1) // Only Alice + expect(jsDevs.get(1)?.skills).toContain(`JavaScript`) + + // Filter by deeply nested property + const sfEmployees = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ emp: employeesCollection }) + .where(({ emp }) => eq(emp.contact.address.city, `San Francisco`)) + .select(({ emp }) => ({ + id: emp.id, + name: emp.name, + city: emp.contact.address.city, + })), + }) + + expect(sfEmployees.size).toBe(2) // Alice and Diana + expect( + sfEmployees.toArray.every((e) => e.city === `San Francisco`) + ).toBe(true) + }) + + test(`should handle null checks in nested properties`, () => { + // Employees with no address + const noAddress = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ emp: employeesCollection }) + .where(({ emp }) => eq(emp.contact.address, null)) + .select(({ emp }) => ({ + id: emp.id, + name: emp.name, + hasAddress: emp.contact.address !== null, + })), + }) + + expect(noAddress.size).toBe(1) // Only Bob + expect(noAddress.get(2)?.name).toBe(`Bob Smith`) + + // Note: Complex array operations like .some() and .filter() are not supported in query builder + // This would require implementation of array-specific query functions + // For now, we'll test simpler nested property access + const employeesWithProfiles = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ emp: employeesCollection }) + .where(({ emp }) => emp.profile !== undefined) + .select(({ emp }) => ({ + id: emp.id, + name: emp.name, + skills: emp.profile.skills, + years: emp.profile.experience.years, + })), + }) + + expect(employeesWithProfiles.size).toBe(3) // Alice, Bob, Charlie have profiles + }) + + test(`should combine nested and non-nested conditions`, () => { + // Active employees in CA with 5+ years experience + const seniorCAEmployees = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ emp: employeesCollection }) + .where(({ emp }) => + and( + eq(emp.active, true), + eq(emp.contact.address.state, `CA`), + gte(emp.profile.experience.years, 5) + ) + ) + .select(({ emp }) => ({ + id: emp.id, + name: emp.name, + years: emp.profile.experience.years, + state: emp.contact.address.state, + })), + }) + + expect(seniorCAEmployees.size).toBe(1) // Only Alice (active, CA, 5 years) + expect(seniorCAEmployees.get(1)).toMatchObject({ + id: 1, + name: `Alice Johnson`, + years: 5, + state: `CA`, + }) + + // High earners with Python skills + const pythonHighEarners = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ emp: employeesCollection }) + .where(({ emp }) => + and( + gt(emp.salary, 60000), + inArray(`Python`, emp.profile.skills) + ) + ) + .select(({ emp }) => ({ + id: emp.id, + name: emp.name, + salary: emp.salary, + skills: emp.profile.skills, + })), + }) + + expect(pythonHighEarners.size).toBe(1) // Only Bob + expect(pythonHighEarners.get(2)?.skills).toContain(`Python`) + }) + + test(`should handle updates to nested properties`, () => { + // Track employees with emergency contacts + const emergencyContacts = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ emp: employeesCollection }) + .where(({ emp }) => emp.contact.emergency !== undefined) + .select(({ emp }) => ({ + id: emp.id, + name: emp.name, + emergencyName: emp.contact.emergency.name, + relation: emp.contact.emergency.relation, + })), + }) + + expect(emergencyContacts.size).toBe(3) // Alice, Bob, Diana + + // Add emergency contact to Eve + const eve = sampleEmployees.find((e) => e.id === 5)! + const eveWithContact: Employee = { + ...eve, + contact: { + phone: `555-0501`, + address: null, + emergency: { + name: `Tom Wilson`, + relation: `Brother`, + phone: `555-0502`, + }, + }, + } + + employeesCollection.utils.begin() + employeesCollection.utils.write({ + type: `update`, + value: eveWithContact, + }) + employeesCollection.utils.commit() + + expect(emergencyContacts.size).toBe(4) // Now includes Eve + expect(emergencyContacts.get(5)).toMatchObject({ + id: 5, + name: `Eve Wilson`, + emergencyName: `Tom Wilson`, + relation: `Brother`, + }) + + // Update Alice's emergency contact + const alice = sampleEmployees.find((e) => e.id === 1)! + const aliceUpdated: Employee = { + ...alice, + contact: { + ...alice.contact!, + emergency: { + name: `Jane Doe`, + relation: `Friend`, + phone: `555-0103`, + }, + }, + } + + employeesCollection.utils.begin() + employeesCollection.utils.write({ type: `update`, value: aliceUpdated }) + employeesCollection.utils.commit() + + expect(emergencyContacts.get(1)?.emergencyName).toBe(`Jane Doe`) + expect(emergencyContacts.get(1)?.relation).toBe(`Friend`) + }) + + test(`should work with computed expressions on nested properties`, () => { + // Filter by experience years (simple property access) + const experiencedDevs = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ emp: employeesCollection }) + .where(({ emp }) => gte(emp.profile.experience.years, 5)) + .select(({ emp }) => ({ + id: emp.id, + name: emp.name, + years: emp.profile.experience.years, + })), + }) + + expect(experiencedDevs.size).toBe(3) // Alice (5), Bob (8), Charlie (10) + expect(experiencedDevs.get(1)?.years).toBe(5) + expect(experiencedDevs.get(2)?.years).toBe(8) + expect(experiencedDevs.get(3)?.years).toBe(10) + + // Test array length function (if supported by query builder) + const profiledEmployees = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ emp: employeesCollection }) + .where(({ emp }) => emp.profile.skills !== undefined) + .select(({ emp }) => ({ + id: emp.id, + name: emp.name, + skillCount: length(emp.profile.skills), + })), + }) + + expect(profiledEmployees.size).toBe(3) // Alice, Bob, Charlie have skills + }) + + test(`should handle OR conditions with nested properties`, () => { + // Employees in SF OR with Python skills + const sfOrPython = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ emp: employeesCollection }) + .where(({ emp }) => + or( + eq(emp.contact.address.city, `San Francisco`), + inArray(`Python`, emp.profile.skills) + ) + ) + .select(({ emp }) => ({ + id: emp.id, + name: emp.name, + city: emp.contact.address.city, + hasPython: inArray(`Python`, emp.profile.skills), + })), + }) + + expect(sfOrPython.size).toBe(3) // Alice (SF), Bob (Python), Diana (SF) + + const results = sfOrPython.toArray + const alice = results.find((e) => e.id === 1) + const bob = results.find((e) => e.id === 2) + const diana = results.find((e) => e.id === 4) + + expect(alice?.city).toBe(`San Francisco`) + expect(alice?.hasPython).toBe(false) + expect(bob?.city).toBeNull() + expect(bob?.hasPython).toBe(true) + expect(diana?.city).toBe(`San Francisco`) + expect(diana?.hasPython).toBe(false) + }) + }) }) }