diff --git a/.changeset/nested-optional-properties.md b/.changeset/nested-optional-properties.md new file mode 100644 index 00000000..2f1ee6f8 --- /dev/null +++ b/.changeset/nested-optional-properties.md @@ -0,0 +1,30 @@ +--- +"@tanstack/db": patch +--- + +## Enhanced Ref System with Nested Optional Properties + +Comprehensive refactor of the ref system to properly support nested structures and optionality, aligning the type system with JavaScript's optional chaining behavior. + +### ✨ New Features + +- **Nested Optional Properties**: Full support for deeply nested optional objects (`employees.profile?.bio`, `orders.customer?.address?.street`) +- **Enhanced Type Safety**: Optional types now correctly typed as `RefProxy | undefined` with optionality outside the ref +- **New Query Functions**: Added `isUndefined`, `isNotUndefined`, `isNull`, `isNotNull` for proper null/undefined checks +- **Improved JOIN Handling**: Fixed optionality in JOIN operations and multiple GROUP BY support + +### ⚠️ Breaking Changes + +**IMPORTANT**: Code that previously ignored optionality now requires proper optional chaining syntax. + +```typescript +// Before (worked but type-unsafe) +employees.profile.bio // ❌ Now throws type error + +// After (correct and type-safe) +employees.profile?.bio // ✅ Required syntax +``` + +### Migration + +Add `?.` when accessing potentially undefined nested properties diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index b5902bd6..9e11f1b3 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -2,19 +2,137 @@ import { Aggregate, Func } from "../ir" import { toExpression } from "./ref-proxy.js" import type { BasicExpression } from "../ir" import type { RefProxy } from "./ref-proxy.js" +import type { Ref } from "./types.js" + +type StringRef = Ref | Ref | Ref +type StringRefProxy = + | RefProxy + | RefProxy + | RefProxy +type StringBasicExpression = + | BasicExpression + | BasicExpression + | BasicExpression +type StringLike = + | StringRef + | StringRefProxy + | StringBasicExpression + | string + | null + | undefined + +type ComparisonOperand = + | RefProxy + | Ref + | T + | BasicExpression + | undefined + | null +type ComparisonOperandPrimitive = + | T + | BasicExpression + | undefined + | null // Helper type for any expression-like value -type ExpressionLike = BasicExpression | RefProxy | any +type ExpressionLike = BasicExpression | RefProxy | Ref | any + +// Helper type to extract the underlying type from various expression types +type ExtractType = + T extends RefProxy + ? U + : T extends Ref + ? U + : T extends BasicExpression + ? U + : T extends undefined + ? undefined + : T extends null + ? null + : T + +// Helper type to determine aggregate return type based on input nullability +type AggregateReturnType = + ExtractType extends infer U + ? U extends number | undefined | null + ? Aggregate + : U extends number + ? Aggregate + : Aggregate + : Aggregate + +// Helper type to determine string function return type based on input nullability +type StringFunctionReturnType = + ExtractType extends infer U + ? U extends string | undefined | null + ? BasicExpression + : U extends string + ? BasicExpression + : BasicExpression + : BasicExpression + +// Helper type to determine numeric function return type based on input nullability +// This handles string, array, and number inputs for functions like length() +type NumericFunctionReturnType = + ExtractType extends infer U + ? U extends string + ? BasicExpression + : U extends string | undefined + ? BasicExpression + : U extends string | null + ? BasicExpression + : U extends string | undefined | null + ? BasicExpression + : U extends Array + ? BasicExpression + : U extends Array | undefined + ? BasicExpression + : U extends Array | null + ? BasicExpression + : U extends Array | undefined | null + ? BasicExpression + : U extends number | undefined | null + ? BasicExpression + : U extends number + ? BasicExpression + : BasicExpression + : BasicExpression + +// Helper type for binary numeric operations (combines nullability of both operands) +type BinaryNumericReturnType = + ExtractType extends infer U1 + ? ExtractType extends infer U2 + ? U1 extends number + ? U2 extends number + ? BasicExpression + : U2 extends number | undefined + ? BasicExpression + : U2 extends number | null + ? BasicExpression + : BasicExpression + : U1 extends number | undefined + ? U2 extends number + ? BasicExpression + : U2 extends number | undefined + ? BasicExpression + : BasicExpression + : U1 extends number | null + ? U2 extends number + ? BasicExpression + : BasicExpression + : BasicExpression + : BasicExpression + : BasicExpression // Operators export function eq( - left: RefProxy, - right: T | RefProxy | BasicExpression + left: ComparisonOperand, + right: ComparisonOperand ): BasicExpression export function eq( - left: T | BasicExpression, - right: T | BasicExpression + left: ComparisonOperandPrimitive, + right: ComparisonOperandPrimitive ): BasicExpression export function eq(left: Aggregate, right: any): BasicExpression export function eq(left: any, right: any): BasicExpression { @@ -22,12 +140,12 @@ export function eq(left: any, right: any): BasicExpression { } export function gt( - left: RefProxy, - right: T | RefProxy | BasicExpression + left: ComparisonOperand, + right: ComparisonOperand ): BasicExpression export function gt( - left: T | BasicExpression, - right: T | BasicExpression + left: ComparisonOperandPrimitive, + right: ComparisonOperandPrimitive ): BasicExpression export function gt(left: Aggregate, right: any): BasicExpression export function gt(left: any, right: any): BasicExpression { @@ -35,12 +153,12 @@ export function gt(left: any, right: any): BasicExpression { } export function gte( - left: RefProxy, - right: T | RefProxy | BasicExpression + left: ComparisonOperand, + right: ComparisonOperand ): BasicExpression export function gte( - left: T | BasicExpression, - right: T | BasicExpression + left: ComparisonOperandPrimitive, + right: ComparisonOperandPrimitive ): BasicExpression export function gte(left: Aggregate, right: any): BasicExpression export function gte(left: any, right: any): BasicExpression { @@ -48,12 +166,12 @@ export function gte(left: any, right: any): BasicExpression { } export function lt( - left: RefProxy, - right: T | RefProxy | BasicExpression + left: ComparisonOperand, + right: ComparisonOperand ): BasicExpression export function lt( - left: T | BasicExpression, - right: T | BasicExpression + left: ComparisonOperandPrimitive, + right: ComparisonOperandPrimitive ): BasicExpression export function lt(left: Aggregate, right: any): BasicExpression export function lt(left: any, right: any): BasicExpression { @@ -61,12 +179,12 @@ export function lt(left: any, right: any): BasicExpression { } export function lte( - left: RefProxy, - right: T | RefProxy | BasicExpression + left: ComparisonOperand, + right: ComparisonOperand ): BasicExpression export function lte( - left: T | BasicExpression, - right: T | BasicExpression + left: ComparisonOperandPrimitive, + right: ComparisonOperandPrimitive ): BasicExpression export function lte(left: Aggregate, right: any): BasicExpression export function lte(left: any, right: any): BasicExpression { @@ -121,6 +239,25 @@ export function not(value: ExpressionLike): BasicExpression { return new Func(`not`, [toExpression(value)]) } +// Null/undefined checking functions +export function isUndefined(value: ExpressionLike): BasicExpression { + return new Func(`isUndefined`, [toExpression(value)]) +} + +export function isNotUndefined( + value: ExpressionLike +): BasicExpression { + return new Func(`isNotUndefined`, [toExpression(value)]) +} + +export function isNull(value: ExpressionLike): BasicExpression { + return new Func(`isNull`, [toExpression(value)]) +} + +export function isNotNull(value: ExpressionLike): BasicExpression { + return new Func(`isNotNull`, [toExpression(value)]) +} + export function inArray( value: ExpressionLike, array: ExpressionLike @@ -129,64 +266,38 @@ export function inArray( } export function like( - left: - | RefProxy - | RefProxy - | RefProxy - | string - | BasicExpression, - right: string | RefProxy | BasicExpression + left: StringLike, + right: StringLike ): BasicExpression export function like(left: any, right: any): BasicExpression { return new Func(`like`, [toExpression(left), toExpression(right)]) } export function ilike( - left: - | RefProxy - | RefProxy - | RefProxy - | string - | BasicExpression, - right: string | RefProxy | BasicExpression + left: StringLike, + right: StringLike ): BasicExpression { return new Func(`ilike`, [toExpression(left), toExpression(right)]) } // Functions -export function upper( - arg: - | RefProxy - | RefProxy - | string - | BasicExpression -): BasicExpression { - return new Func(`upper`, [toExpression(arg)]) +export function upper( + arg: T +): StringFunctionReturnType { + return new Func(`upper`, [toExpression(arg)]) as StringFunctionReturnType } -export function lower( - arg: - | RefProxy - | RefProxy - | string - | BasicExpression -): BasicExpression { - return new Func(`lower`, [toExpression(arg)]) +export function lower( + arg: T +): StringFunctionReturnType { + return new Func(`lower`, [toExpression(arg)]) as StringFunctionReturnType } -export function length( - arg: - | RefProxy - | RefProxy - | RefProxy> - | RefProxy | undefined> - | string - | Array - | BasicExpression - | BasicExpression> -): BasicExpression { - return new Func(`length`, [toExpression(arg)]) +export function length( + arg: T +): NumericFunctionReturnType { + return new Func(`length`, [toExpression(arg)]) as NumericFunctionReturnType } export function concat( @@ -205,19 +316,14 @@ export function coalesce(...args: Array): BasicExpression { ) } -export function add( - left: - | RefProxy - | RefProxy - | number - | BasicExpression, - right: - | RefProxy - | RefProxy - | number - | BasicExpression -): BasicExpression { - return new Func(`add`, [toExpression(left), toExpression(right)]) +export function add( + left: T1, + right: T2 +): BinaryNumericReturnType { + return new Func(`add`, [ + toExpression(left), + toExpression(right), + ]) as BinaryNumericReturnType } // Aggregates @@ -226,44 +332,20 @@ export function count(arg: ExpressionLike): Aggregate { return new Aggregate(`count`, [toExpression(arg)]) } -export function avg( - arg: - | RefProxy - | RefProxy - | number - | BasicExpression -): Aggregate { - return new Aggregate(`avg`, [toExpression(arg)]) +export function avg(arg: T): AggregateReturnType { + return new Aggregate(`avg`, [toExpression(arg)]) as AggregateReturnType } -export function sum( - arg: - | RefProxy - | RefProxy - | number - | BasicExpression -): Aggregate { - return new Aggregate(`sum`, [toExpression(arg)]) +export function sum(arg: T): AggregateReturnType { + return new Aggregate(`sum`, [toExpression(arg)]) as AggregateReturnType } -export function min( - arg: - | RefProxy - | RefProxy - | number - | BasicExpression -): Aggregate { - return new Aggregate(`min`, [toExpression(arg)]) +export function min(arg: T): AggregateReturnType { + return new Aggregate(`min`, [toExpression(arg)]) as AggregateReturnType } -export function max( - arg: - | RefProxy - | RefProxy - | number - | BasicExpression -): Aggregate { - return new Aggregate(`max`, [toExpression(arg)]) +export function max(arg: T): AggregateReturnType { + return new Aggregate(`max`, [toExpression(arg)]) as AggregateReturnType } /** diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index c30b2597..8cbe1fcb 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -23,7 +23,7 @@ import type { Context, GroupByCallback, JoinOnCallback, - MergeContext, + MergeContextForJoinCallback, MergeContextWithJoinType, OrderByCallback, OrderByOptions, @@ -142,7 +142,7 @@ export class BaseQueryBuilder { >( source: TSource, onCallback: JoinOnCallback< - MergeContext> + MergeContextForJoinCallback> >, type: TJoinType = `left` as TJoinType ): QueryBuilder< @@ -154,7 +154,7 @@ export class BaseQueryBuilder { const currentAliases = this._getCurrentAliases() const newAliases = [...currentAliases, alias] const refProxy = createRefProxy(newAliases) as RefProxyForContext< - MergeContext> + MergeContextForJoinCallback> > // Get the join condition expression @@ -209,7 +209,7 @@ export class BaseQueryBuilder { leftJoin( source: TSource, onCallback: JoinOnCallback< - MergeContext> + MergeContextForJoinCallback> > ): QueryBuilder< MergeContextWithJoinType, `left`> @@ -235,7 +235,7 @@ export class BaseQueryBuilder { rightJoin( source: TSource, onCallback: JoinOnCallback< - MergeContext> + MergeContextForJoinCallback> > ): QueryBuilder< MergeContextWithJoinType, `right`> @@ -261,7 +261,7 @@ export class BaseQueryBuilder { innerJoin( source: TSource, onCallback: JoinOnCallback< - MergeContext> + MergeContextForJoinCallback> > ): QueryBuilder< MergeContextWithJoinType, `inner`> @@ -287,7 +287,7 @@ export class BaseQueryBuilder { fullJoin( source: TSource, onCallback: JoinOnCallback< - MergeContext> + MergeContextForJoinCallback> > ): QueryBuilder< MergeContextWithJoinType, `full`> @@ -436,6 +436,7 @@ export class BaseQueryBuilder { select[key] = toExpression(value) } else if ( typeof value === `object` && + value !== null && `type` in value && (value.type === `agg` || value.type === `func`) ) { @@ -552,10 +553,11 @@ export class BaseQueryBuilder { ? result.map((r) => toExpression(r)) : [toExpression(result)] - // Replace existing groupBy expressions instead of extending them + // Extend existing groupBy expressions (multiple groupBy calls should accumulate) + const existingGroupBy = this.query.groupBy || [] return new BaseQueryBuilder({ ...this.query, - groupBy: newExpressions, + groupBy: [...existingGroupBy, ...newExpressions], }) as any } @@ -792,4 +794,4 @@ export type ExtractContext = : never // Export the types from types.ts for convenience -export type { Context, Source, GetResult } from "./types.js" +export type { Context, Source, GetResult, Ref } from "./types.js" diff --git a/packages/db/src/query/builder/ref-proxy.ts b/packages/db/src/query/builder/ref-proxy.ts index 1fc195f3..6c19fc0b 100644 --- a/packages/db/src/query/builder/ref-proxy.ts +++ b/packages/db/src/query/builder/ref-proxy.ts @@ -1,5 +1,6 @@ import { PropRef, Value } from "../ir.js" import type { BasicExpression } from "../ir.js" +import type { Ref } from "./types.js" export interface RefProxy { /** @internal */ @@ -19,7 +20,7 @@ export type SingleRowRefProxy = ? { [K in keyof T]: T[K] extends Record ? SingleRowRefProxy & RefProxy - : RefProxy + : Ref } & RefProxy : RefProxy diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 763447d8..71f2c314 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -3,6 +3,28 @@ import type { Aggregate, BasicExpression, OrderByDirection } from "../ir.js" import type { QueryBuilder } from "./index.js" import type { ResolveType } from "../../types.js" +/** + * Context - The central state container for query builder operations + * + * This interface tracks all the information needed to build and type-check queries: + * + * **Schema Management**: + * - `baseSchema`: The original tables/collections from the `from()` clause + * - `schema`: Current available tables (expands with joins, contracts with subqueries) + * + * **Query State**: + * - `fromSourceName`: Which table was used in `from()` - needed for optionality logic + * - `hasJoins`: Whether any joins have been added (affects result type inference) + * - `joinTypes`: Maps table aliases to their join types for optionality calculations + * + * **Result Tracking**: + * - `result`: The final shape after `select()` - undefined until select is called + * + * The context evolves through the query builder chain: + * 1. `from()` sets baseSchema and schema to the same thing + * 2. `join()` expands schema and sets hasJoins/joinTypes + * 3. `select()` sets result to the projected shape + */ export interface Context { // The collections available in the base schema baseSchema: ContextSchema @@ -21,20 +43,57 @@ export interface Context { result?: any } +/** + * ContextSchema - The shape of available tables/collections in a query context + * + * This is simply a record mapping table aliases to their TypeScript types. + * It evolves as the query progresses: + * - Initial: Just the `from()` table + * - After joins: Includes all joined tables with proper optionality + * - In subqueries: May be a subset of the outer query's schema + */ export type ContextSchema = Record +/** + * Source - Input definition for query builder `from()` clause + * + * Maps table aliases to either: + * - `CollectionImpl`: A database collection/table + * - `QueryBuilder`: A subquery that can be used as a table + * + * Example: `{ users: usersCollection, orders: ordersCollection }` + */ export type Source = { [alias: string]: CollectionImpl | QueryBuilder } -// Helper type to infer collection type from CollectionImpl -// This uses ResolveType directly to ensure consistency with collection creation logic +/** + * InferCollectionType - Extracts the TypeScript type from a CollectionImpl + * + * This helper ensures we get the same type that would be used when creating + * the collection itself. It uses the internal `ResolveType` logic to maintain + * consistency between collection creation and query type inference. + * + * The complex generic parameters extract: + * - U: The base document type + * - TSchema: The schema definition + * - The resolved type combines these with any transforms + */ export type InferCollectionType = T extends CollectionImpl ? ResolveType : never -// Helper type to create schema from source +/** + * SchemaFromSource - Converts a Source definition into a ContextSchema + * + * This transforms the input to `from()` into the schema format used throughout + * the query builder. For each alias in the source: + * - Collections → their inferred TypeScript type + * - Subqueries → their result type (what they would return if executed) + * + * The `Prettify` wrapper ensures clean type display in IDEs. + */ export type SchemaFromSource = Prettify<{ [K in keyof T]: T[K] extends CollectionImpl ? InferCollectionType @@ -43,49 +102,208 @@ export type SchemaFromSource = Prettify<{ : never }> -// Helper type to get all aliases from a context +/** + * GetAliases - Extracts all table aliases available in a query context + * + * Simple utility type that returns the keys of the schema, representing + * all table/collection aliases that can be referenced in the current query. + */ export type GetAliases = keyof TContext[`schema`] -// Callback type for where/having clauses +/** + * WhereCallback - Type for where/having clause callback functions + * + * These callbacks receive a `refs` object containing RefProxy instances for + * all available tables. The callback should return a boolean expression + * that will be used to filter query results. + * + * Example: `(refs) => eq(refs.users.age, 25)` + */ export type WhereCallback = ( refs: RefProxyForContext ) => any -// Callback return type for select clauses +/** + * SelectValue - Union of all valid values in a select clause + * + * This type defines what can be used as values in the object passed to `select()`. + * + * **Core Expression Types**: + * - `BasicExpression`: Function calls like `upper(users.name)` + * - `Aggregate`: Aggregations like `count()`, `avg()` + * - `RefProxy/Ref`: Direct field references like `users.name` + * + * **JavaScript Literals** (for constant values in projections): + * - `string`: String literals like `'active'`, `'N/A'` + * - `number`: Numeric literals like `0`, `42`, `3.14` + * - `boolean`: Boolean literals `true`, `false` + * - `null`: Explicit null values + * + * **Advanced Features**: + * - `undefined`: Allows optional projection values + * - `{ [key: string]: SelectValue }`: Nested object projection + * - `PrecomputeRefStructure`: Spread operations like `...users` + * + * Note: The runtime spread implementation uses internal RefProxy properties + * but these are hidden from the type system for cleaner typing. + * + * Examples: + * ```typescript + * select({ + * id: users.id, + * name: users.name, + * status: 'active', // string literal + * priority: 1, // number literal + * verified: true, // boolean literal + * notes: null, // explicit null + * profile: { + * name: users.name, + * email: users.email + * } + * }) + * ``` + */ +type SelectValue = + | BasicExpression + | Aggregate + | RefProxy + | RefProxyFor + | Ref + | string // String literals + | number // Numeric literals + | boolean // Boolean literals + | null // Explicit null + | undefined // Optional values + | { [key: string]: SelectValue } + | PrecomputeRefStructure + | SpreadableRefProxy // For spread operations without internal properties + | Array> + +/** + * SelectObject - Wrapper type for select clause objects + * + * This ensures that objects passed to `select()` have valid SelectValue types + * for all their properties. It's a simple wrapper that provides better error + * messages when invalid selections are attempted. + */ export type SelectObject< - T extends Record< - string, - BasicExpression | Aggregate | RefProxy | RefProxyFor - > = Record>, + T extends Record = Record, > = T -// Helper type to get the result type from a select object +/** + * ResultTypeFromSelect - Infers the result type from a select object + * + * This complex type transforms the input to `select()` into the actual TypeScript + * type that the query will return. It handles all the different kinds of values + * that can appear in a select clause: + * + * **Ref/RefProxy Extraction**: + * - `RefProxy` → `T`: Extracts the underlying type + * - `Ref | undefined` → `T | undefined`: Preserves optionality + * - `Ref | null` → `T | null`: Preserves nullability + * + * **Expression Types**: + * - `BasicExpression` → `T`: Function results like `upper()` → `string` + * - `Aggregate` → `T`: Aggregation results like `count()` → `number` + * + * **JavaScript Literals** (pass through as-is): + * - `string` → `string`: String literals remain strings + * - `number` → `number`: Numeric literals remain numbers + * - `boolean` → `boolean`: Boolean literals remain booleans + * - `null` → `null`: Explicit null remains null + * + * **Nested Objects** (recursive): + * - Plain objects are recursively processed to handle nested projections + * - RefProxy objects are detected and excluded from recursion + * + * **Special Cases**: + * - `undefined` → `undefined`: Direct undefined values + * - Objects with `__type` → the type (for internal RefProxy handling) + * + * Example transformation: + * ```typescript + * // Input: + * { id: Ref, name: Ref, status: 'active', count: 42, profile: { bio: Ref } } + * + * // Output: + * { id: number, name: string, status: 'active', count: 42, profile: { bio: string } } + * ``` + */ export type ResultTypeFromSelect = { [K in keyof TSelectObject]: TSelectObject[K] extends RefProxy ? T - : TSelectObject[K] extends BasicExpression + : TSelectObject[K] extends Ref ? T - : TSelectObject[K] extends Aggregate - ? T - : TSelectObject[K] extends RefProxyFor - ? T - : TSelectObject[K] extends undefined - ? undefined - : TSelectObject[K] extends { __type: infer U } - ? U - : never + : TSelectObject[K] extends Ref | undefined + ? T | undefined + : TSelectObject[K] extends Ref | null + ? T | null + : TSelectObject[K] extends RefProxy | undefined + ? T | undefined + : TSelectObject[K] extends RefProxy | null + ? T | null + : TSelectObject[K] extends BasicExpression + ? T + : TSelectObject[K] extends Aggregate + ? T + : TSelectObject[K] extends RefProxyFor + ? T + : TSelectObject[K] extends SpreadableRefProxy + ? ResultTypeFromSelect> + : TSelectObject[K] extends string + ? string + : TSelectObject[K] extends number + ? number + : TSelectObject[K] extends boolean + ? boolean + : TSelectObject[K] extends null + ? null + : TSelectObject[K] extends undefined + ? undefined + : TSelectObject[K] extends { __type: infer U } + ? U + : TSelectObject[K] extends Record + ? TSelectObject[K] extends { + __refProxy: true + } + ? never // This is a RefProxy, handled above + : ResultTypeFromSelect // Recursive for nested objects + : never } -// Callback type for orderBy clauses +/** + * OrderByCallback - Type for orderBy clause callback functions + * + * Similar to WhereCallback, these receive refs for all available tables + * and should return expressions that will be used for sorting. + * + * Example: `(refs) => refs.users.createdAt` + */ export type OrderByCallback = ( refs: RefProxyForContext ) => any +/** + * OrderByOptions - Configuration for orderBy operations + * + * Combines direction and null handling with string-specific sorting options. + * The intersection with StringSortOpts allows for either simple lexical sorting + * or locale-aware sorting with customizable options. + */ export type OrderByOptions = { direction?: OrderByDirection nulls?: `first` | `last` } & StringSortOpts +/** + * StringSortOpts - Options for string sorting behavior + * + * This discriminated union allows for two types of string sorting: + * - **Lexical**: Simple character-by-character comparison (default) + * - **Locale**: Locale-aware sorting with optional customization + * + * The union ensures that locale options are only available when locale sorting is selected. + */ export type StringSortOpts = | { stringSort?: `lexical` @@ -96,6 +314,13 @@ export type StringSortOpts = localeOptions?: object } +/** + * CompareOptions - Final resolved options for comparison operations + * + * This is the internal type used after all orderBy options have been resolved + * to their concrete values. Unlike OrderByOptions, all fields are required + * since defaults have been applied. + */ export type CompareOptions = { direction: OrderByDirection nulls: `first` | `last` @@ -104,91 +329,315 @@ export type CompareOptions = { localeOptions?: object } -// Callback type for groupBy clauses +/** + * GroupByCallback - Type for groupBy clause callback functions + * + * These callbacks receive refs for all available tables and should return + * expressions that will be used for grouping query results. + * + * Example: `(refs) => refs.orders.status` + */ export type GroupByCallback = ( refs: RefProxyForContext ) => any -// Callback type for join on clauses +/** + * JoinOnCallback - Type for join condition callback functions + * + * These callbacks receive refs for all available tables (including the newly + * joined table) and should return a boolean expression defining the join condition. + * + * Important: The newly joined table is NOT marked as optional in this callback, + * even for left/right/full joins, because optionality is applied AFTER the join + * condition is evaluated. + * + * Example: `(refs) => eq(refs.users.id, refs.orders.userId)` + */ export type JoinOnCallback = ( refs: RefProxyForContext ) => any -// Type for creating RefProxy objects based on context +/** + * RefProxyForContext - Creates ref proxies for all tables/collections in a query context + * + * This is the main entry point for creating ref objects in query builder callbacks. + * It handles optionality by placing undefined/null OUTSIDE the RefProxy to enable + * JavaScript's optional chaining operator (?.): + * + * Examples: + * - Required field: `RefProxy` → user.name works + * - Optional field: `RefProxy | undefined` → user?.name works + * - Nullable field: `RefProxy | null` → user?.name works + * + * The key insight is that `RefProxy` would NOT allow `user?.name` + * because the undefined is "inside" the proxy, but `RefProxy | undefined` + * does allow it because the undefined is "outside" the proxy. + */ export type RefProxyForContext = { - [K in keyof TContext[`schema`]]: RefProxyFor + [K in keyof TContext[`schema`]]: IsExactlyUndefined< + TContext[`schema`][K] + > extends true + ? // T is exactly undefined - wrap in RefProxy as-is + RefProxy + : IsExactlyNull extends true + ? // T is exactly null - wrap in RefProxy as-is + RefProxy + : IsOptional extends true + ? // T is optional (T | undefined) but not exactly undefined + // Extract the non-undefined part and place undefined outside + RefProxy> | undefined + : IsNullable extends true + ? // T is nullable (T | null) but not exactly null + // Extract the non-null part and place null outside + RefProxy> | null + : // T is not optional or nullable - always wrap in RefProxy for top-level schema types + RefProxy } +/** + * Type Detection Helpers + * + * These helpers distinguish between different kinds of optionality/nullability: + * - IsExactlyUndefined: T is literally `undefined` (not `string | undefined`) + * - IsOptional: T includes undefined (like `string | undefined`) + * - IsExactlyNull: T is literally `null` (not `string | null`) + * - IsNullable: T includes null (like `string | null`) + * + * The [T] extends [undefined] pattern prevents distributive conditional types, + * ensuring we check the exact type rather than distributing over union members. + */ + // Helper type to check if T is exactly undefined type IsExactlyUndefined = [T] extends [undefined] ? true : false +// Helper type to check if T is exactly null +type IsExactlyNull = [T] extends [null] ? true : false + // Helper type to check if T includes undefined (is optional) type IsOptional = undefined extends T ? true : false +// Helper type to check if T includes null (is nullable) +type IsNullable = null extends T ? true : false + +/** + * Type Extraction Helpers + * + * These helpers extract the "useful" part of a type by removing null/undefined: + * - NonUndefined: `string | undefined` → `string` + * - NonNull: `string | null` → `string` + */ + // Helper type to extract non-undefined type type NonUndefined = T extends undefined ? never : T -// Helper type to create RefProxy for a specific type with optionality passthrough -// This is used to create the RefProxy object that is used in the query builder. -// Much of the complexity here is due to the fact that we need to handle optionality -// from joins. A left join will make the joined table optional, a right join will make -// the main table optional etc. This is applied to the schema, with the new namespaced -// source being `SourceType | undefined`. -// We then follow this through the ref proxy system so that accessing a property on -// and optional source will itsself be optional. -// If for example we join in `joinedTable` with a left join, then -// `where(({ joinedTable }) => joinedTable.name === `John`)` -// we want the the type of `name` to be `RefProxy` to indicate that -// the `name` property is optional, as the joinedTable is also optional. -export type RefProxyFor = OmitRefProxy< +// Helper type to extract non-null type +type NonNull = T extends null ? never : T + +/** + * PrecomputeRefStructure - Transforms object types into ref structures + * + * This is a key architectural decision: only LEAF values are wrapped in Ref, + * while intermediate objects remain as plain TypeScript objects. This allows: + * + * 1. Natural spread operator: `...user.profile` works because profile is a plain object + * 2. Clean type display: Objects show their actual structure, not RefProxy internals + * 3. Better IDE experience: Autocomplete works on intermediate objects + * + * Examples: + * Input: { bio: string, contact: { email: string, phone?: string } } + * Output: { bio: Ref, contact: { email: Ref, phone: Ref | undefined } } + * + * The recursion handles nested objects while preserving optionality/nullability: + * - For optional objects: The object structure is preserved, undefined goes outside + * - For optional leaves: Ref | undefined (undefined outside the Ref) + * - For nullable objects: The object structure is preserved, null goes outside + * - For nullable leaves: Ref | null (null outside the Ref) + */ +export type PrecomputeRefStructure> = { + [K in keyof T]: IsExactlyUndefined extends true + ? Ref + : IsExactlyNull extends true + ? Ref + : IsOptional extends true + ? NonUndefined extends Record + ? // Optional object: recurse on non-undefined version, place undefined outside + PrecomputeRefStructure> | undefined + : // Optional leaf: wrap in Ref, place undefined outside + Ref> | undefined + : IsNullable extends true + ? NonNull extends Record + ? // Nullable object: recurse on non-null version, place null outside + PrecomputeRefStructure> | null + : // Nullable leaf: wrap in Ref, place null outside + Ref> | null + : T[K] extends Record + ? // Non-optional/nullable object: recurse to handle nested structure + PrecomputeRefStructure + : // Non-optional/nullable leaf: wrap in Ref + Ref +} + +/** + * RefProxyFor - Backward compatibility wrapper for creating refs from any type + * + * This is a simplified version of the ref creation logic that can be used + * for individual types rather than entire query contexts. It's useful for: + * - Standalone composable functions + * - Reusable query fragments + * - Backward compatibility with existing code + * + * It follows the same principles as PrecomputeRefStructure and RefProxyForContext: + * - Place undefined/null outside refs for optional chaining support + * - Only wrap leaf values in RefProxy/Ref + * - Preserve object structures for spread operations + */ +export type RefProxyFor = IsExactlyUndefined extends true - ? // T is exactly undefined - RefProxy + ? RefProxy : IsOptional extends true - ? // T is optional (T | undefined) but not exactly undefined - NonUndefined extends Record - ? { - [K in keyof NonUndefined]-?: NonUndefined[K] extends Record< - string, - any - > - ? RefProxyFor[K]> & - RefProxy[K] | undefined> - : RefProxy[K] | undefined> - } & RefProxy + ? NonUndefined extends Record + ? PrecomputeRefStructure> | undefined : RefProxy - : // T is not optional - T extends Record - ? { - // Make all properties required, but for optional ones, include undefined in the RefProxy type - [K in keyof T]-?: undefined extends T[K] - ? T[K] extends Record - ? RefProxyFor & RefProxy - : RefProxy - : T[K] extends Record - ? RefProxyFor & RefProxy - : RefProxy - } & RefProxy + : T extends Record + ? PrecomputeRefStructure : RefProxy -> - -// This is the public type that is exported from the query builder -// and is used when constructing reusable query callbacks. -export type Ref = RefProxyFor - -type OmitRefProxy = Omit -// The core RefProxy interface -export interface RefProxy { - /** @internal */ +/** + * RefProxy - The core ref interface that powers the query builder + * + * This is the foundational type that represents a reference to a value in the query. + * It contains internal metadata for tracking the path to the value and its type, + * plus user-facing properties that mirror the structure of the referenced type. + * + * Key features: + * 1. **Internal metadata**: __refProxy, __path, __type for runtime and type system + * 2. **Recursive structure**: Object properties become nested RefProxy/Ref types + * 3. **Optionality handling**: undefined/null are placed outside refs for ?. support + * 4. **Type preservation**: The structure mirrors the original type as closely as possible + * 5. **Spread support**: Internal properties are hidden during spread operations + * + * Examples: + * RefProxy → { __refProxy: true, __path: [...], __type: string } + * RefProxy<{name: string}> → { + * __refProxy: true, __path: [...], __type: {...}, + * name: Ref // Clean display, same as RefProxy + * } + * + * The intersection (&) with the conditional type ensures that: + * - For primitive types: Only internal metadata is added + * - For object types: Properties are recursively transformed with optionality preserved + * - For undefined: No additional properties (just metadata) + * - For spreads: Internal properties are excluded from type checking + */ +export type RefProxy = { + /** @internal - Runtime marker to identify ref proxy objects */ readonly __refProxy: true - /** @internal */ + /** @internal - Path segments for building query expressions */ readonly __path: Array - /** @internal */ + /** @internal - Type information for TypeScript inference */ readonly __type: T -} +} & (T extends undefined + ? {} // undefined types get no additional properties + : T extends Record + ? { + // Object types get recursive property transformation + [K in keyof T]: IsExactlyUndefined extends true + ? Ref // Exactly undefined: wrap in Ref as-is + : IsExactlyNull extends true + ? Ref // Exactly null: wrap in Ref as-is + : IsOptional extends true + ? NonUndefined extends Record + ? RefProxy> | undefined // Optional object + : Ref> | undefined // Optional leaf + : IsNullable extends true + ? NonNull extends Record + ? RefProxy> | null // Nullable object + : Ref> | null // Nullable leaf + : T[K] extends Record + ? RefProxy // Required object: full RefProxy + : Ref // Required leaf: clean Ref display + } + : {}) // Primitive types get no additional properties + +/** + * SpreadableRefProxy - Type for spread operations that excludes internal properties + * + * This type represents what you get when you spread a RefProxy. It omits the internal + * properties (__refProxy, __path, __type) and only includes the user-facing properties + * that should be part of the spread operation. + * + * This enables clean spread operations like: + * ```typescript + * select({ + * id: employees.id, + * name: employees.name, + * ...employees.profile // Only spreads bio, skills, contact - not __refProxy etc. + * }) + * ``` + */ +export type SpreadableRefProxy = Omit< + RefProxy, + `__refProxy` | `__path` | `__type` +> -// Helper type to apply join optionality immediately when merging contexts +/** + * Ref - The user-facing ref type with clean IDE display + * + * Structurally compatible with `RefProxy` so runtime proxies can be used + * wherever a `Ref` is expected, while hiding internal proxy properties. + * + * - For object types: exposes the same nested property structure, with leaves + * typed as `Ref` and optionality/nullability preserved outside the Ref. + * - For primitives: an opaque branded wrapper so hovers show `Ref`. + */ +declare const RefBrand: unique symbol +type RefLeaf = { readonly [RefBrand]?: T } +export type Ref = T extends undefined + ? RefLeaf + : T extends Record + ? { + [K in keyof T]: IsExactlyUndefined extends true + ? Ref + : IsExactlyNull extends true + ? Ref + : IsOptional extends true + ? NonUndefined extends Record + ? + | (Ref> | RefProxy>) + | undefined + : Ref> | undefined + : IsNullable extends true + ? NonNull extends Record + ? (Ref> | RefProxy>) | null + : Ref> | null + : T[K] extends Record + ? Ref | RefProxy + : Ref + } & RefLeaf + : RefLeaf + +/** + * MergeContextWithJoinType - Creates a new context after a join operation + * + * This is the core type that handles the complex logic of merging schemas + * when tables are joined, applying the correct optionality based on join type. + * + * **Key Responsibilities**: + * 1. **Schema Merging**: Combines existing schema with newly joined tables + * 2. **Optionality Logic**: Applies join-specific optionality rules: + * - `LEFT JOIN`: New table becomes optional + * - `RIGHT JOIN`: Existing tables become optional + * - `FULL JOIN`: Both existing and new become optional + * - `INNER JOIN`: No tables become optional + * 3. **State Tracking**: Updates hasJoins and joinTypes for future operations + * + * **Context Evolution**: + * - `baseSchema`: Unchanged (always the original `from()` tables) + * - `schema`: Expanded with new tables and proper optionality + * - `hasJoins`: Set to true + * - `joinTypes`: Updated to track this join type + * - `result`: Preserved from previous operations + */ export type MergeContextWithJoinType< TContext extends Context, TNewSchema extends ContextSchema, @@ -213,7 +662,31 @@ export type MergeContextWithJoinType< result: TContext[`result`] } -// Helper type to apply join optionality when merging new schema +/** + * ApplyJoinOptionalityToMergedSchema - Applies optionality rules when merging schemas + * + * This type implements the SQL join optionality semantics: + * + * **For Existing Tables**: + * - `RIGHT JOIN` or `FULL JOIN`: Main table (from fromSourceName) becomes optional + * - Other join types: Existing tables keep their current optionality + * - Previously joined tables: Keep their already-applied optionality + * + * **For New Tables**: + * - `LEFT JOIN` or `FULL JOIN`: New table becomes optional + * - `INNER JOIN` or `RIGHT JOIN`: New table remains required + * + * **Examples**: + * ```sql + * FROM users LEFT JOIN orders -- orders becomes optional + * FROM users RIGHT JOIN orders -- users becomes optional + * FROM users FULL JOIN orders -- both become optional + * FROM users INNER JOIN orders -- both remain required + * ``` + * + * The intersection (&) ensures both existing and new schemas are merged + * into a single type while preserving all table references. + */ export type ApplyJoinOptionalityToMergedSchema< TExistingSchema extends ContextSchema, TNewSchema extends ContextSchema, @@ -237,7 +710,31 @@ export type ApplyJoinOptionalityToMergedSchema< TNewSchema[K] } -// Helper type to get the result type from a context +/** + * GetResult - Determines the final result type of a query + * + * This type implements the logic for what a query returns based on its current state: + * + * **Priority Order**: + * 1. **Explicit Result**: If `select()` was called, use the projected type + * 2. **Join Query**: If joins exist, return all tables with proper optionality + * 3. **Single Table**: Return just the main table from `from()` + * + * **Examples**: + * ```typescript + * // Single table query: + * from({ users }).where(...) // → User[] + * + * // Join query without select: + * from({ users }).leftJoin({ orders }, ...) // → { users: User, orders: Order | undefined }[] + * + * // Query with select: + * from({ users }).select({ id: users.id, name: users.name }) // → { id: number, name: string }[] + * ``` + * + * The `Prettify` wrapper ensures clean type display in IDEs by flattening + * complex intersection types into readable object types. + */ export type GetResult = Prettify< TContext[`result`] extends object ? TContext[`result`] @@ -248,7 +745,22 @@ export type GetResult = Prettify< TContext[`schema`][TContext[`fromSourceName`]] > -// Helper type to apply join optionality to the schema based on joinTypes +/** + * ApplyJoinOptionalityToSchema - Legacy helper for complex join scenarios + * + * This type was designed to handle complex scenarios with multiple joins + * where the optionality of tables might be affected by subsequent joins. + * Currently used in advanced join logic, but most cases are handled by + * the simpler `ApplyJoinOptionalityToMergedSchema`. + * + * **Logic**: + * 1. **Main Table**: Becomes optional if ANY right or full join exists in the chain + * 2. **Joined Tables**: Check their specific join type for optionality + * 3. **Complex Cases**: Handle scenarios where subsequent joins affect earlier tables + * + * This is primarily used for edge cases and may be simplified in future versions + * as the simpler merge-based approach covers most real-world scenarios. + */ export type ApplyJoinOptionalityToSchema< TSchema extends ContextSchema, TJoinTypes extends Record, @@ -275,7 +787,19 @@ export type ApplyJoinOptionalityToSchema< : TSchema[K] } -// Helper type to check if a table becomes optional due to subsequent joins +/** + * IsTableMadeOptionalBySubsequentJoins - Checks if later joins affect table optionality + * + * This helper determines if a table that was initially required becomes optional + * due to joins that happen later in the query chain. + * + * **Current Implementation**: + * - Main table: Becomes optional if any right/full joins exist + * - Joined tables: Not affected by subsequent joins (simplified model) + * + * This is a conservative approach that may be extended in the future to handle + * more complex join interaction scenarios. + */ type IsTableMadeOptionalBySubsequentJoins< TTableAlias extends string | number | symbol, TJoinTypes extends Record, @@ -286,7 +810,24 @@ type IsTableMadeOptionalBySubsequentJoins< : // Joined tables are not affected by subsequent joins in our current implementation false -// Helper type to check if any join has one of the specified types +/** + * HasJoinType - Utility to check if any join in a chain matches target types + * + * This type searches through all recorded join types to see if any match + * the specified target types. It's used to implement logic like "becomes optional + * if ANY right or full join exists in the chain". + * + * **How it works**: + * 1. Maps over all join types, checking each against target types + * 2. Creates a union of boolean results + * 3. Uses `true extends Union` pattern to check if any were true + * + * **Example**: + * ```typescript + * HasJoinType<{ orders: 'left', products: 'right' }, 'right' | 'full'> + * // → true (because products is a right join) + * ``` + */ export type HasJoinType< TJoinTypes extends Record, TTargetTypes extends string, @@ -296,20 +837,85 @@ export type HasJoinType< ? true : false -// Helper type to merge contexts (for joins) - backward compatibility -export type MergeContext< +/** + * MergeContextForJoinCallback - Special context for join condition callbacks + * + * This type creates a context specifically for the `onCallback` parameter of join operations. + * The key difference from `MergeContextWithJoinType` is that NO optionality is applied here. + * + * **Why No Optionality?** + * In SQL, join conditions are evaluated BEFORE optionality is determined. Both tables + * must be treated as available (non-optional) within the join condition itself. + * Optionality is only applied to the result AFTER the join logic executes. + * + * **Example**: + * ```typescript + * .leftJoin({ orders }, (refs) => { + * // refs.users is NOT optional here - we can access users.id directly + * // refs.orders is NOT optional here - we can access orders.userId directly + * return eq(refs.users.id, refs.orders.userId) + * }) + * .where((refs) => { + * // NOW refs.orders is optional because it's after the LEFT JOIN + * return refs.orders?.status === 'pending' + * }) + * ``` + * + * The simple intersection (&) merges schemas without any optionality transformation. + */ +export type MergeContextForJoinCallback< TContext extends Context, TNewSchema extends ContextSchema, -> = MergeContextWithJoinType +> = { + baseSchema: TContext[`baseSchema`] + // Merge schemas without applying join optionality - both are non-optional in join condition + schema: TContext[`schema`] & TNewSchema + fromSourceName: TContext[`fromSourceName`] + hasJoins: true + joinTypes: TContext[`joinTypes`] extends Record + ? TContext[`joinTypes`] + : {} + result: TContext[`result`] +} -// Helper type for updating context with result type +/** + * WithResult - Updates a context with a new result type after select() + * + * This utility type is used internally when the `select()` method is called + * to update the context with the projected result type. It preserves all + * other context properties while replacing the `result` field. + * + * **Usage**: + * When `select()` is called, the query builder uses this type to create + * a new context where `result` contains the shape of the selected fields. + * + * The double `Prettify` ensures both the overall context and the nested + * result type display cleanly in IDEs. + */ export type WithResult = Prettify< Omit & { result: Prettify } > -// Helper type to simplify complex types for better editor hints +/** + * Prettify - Utility type for clean IDE display + * + * This type flattens complex intersection types and conditional types + * into simple object types for better readability in IDE tooltips and + * error messages. + * + * **How it works**: + * The mapped type `{ [K in keyof T]: T[K] }` forces TypeScript to + * evaluate all the properties of T, and the intersection with `{}` + * flattens the result into a single object type. + * + * **Example**: + * ```typescript + * // Without Prettify: { name: string } & { age: number } & SomeComplexType + * // With Prettify: { name: string; age: number; ...otherProps } + * ``` + */ export type Prettify = { [K in keyof T]: T[K] } & {} diff --git a/packages/db/src/query/compiler/evaluators.ts b/packages/db/src/query/compiler/evaluators.ts index a6740c4a..06a22e01 100644 --- a/packages/db/src/query/compiler/evaluators.ts +++ b/packages/db/src/query/compiler/evaluators.ts @@ -334,6 +334,36 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { } } + // Null/undefined checking functions + case `isUndefined`: { + const arg = compiledArgs[0]! + return (data) => { + const value = arg(data) + return value === undefined + } + } + case `isNotUndefined`: { + const arg = compiledArgs[0]! + return (data) => { + const value = arg(data) + return value !== undefined + } + } + case `isNull`: { + const arg = compiledArgs[0]! + return (data) => { + const value = arg(data) + return value === null + } + } + case `isNotNull`: { + const arg = compiledArgs[0]! + return (data) => { + const value = arg(data) + return value !== null + } + } + default: throw new UnknownFunctionError(func.name) } diff --git a/packages/db/tests/collection-auto-index.test.ts b/packages/db/tests/collection-auto-index.test.ts index d1e1ac0b..c133fb99 100644 --- a/packages/db/tests/collection-auto-index.test.ts +++ b/packages/db/tests/collection-auto-index.test.ts @@ -283,7 +283,7 @@ describe(`Collection Auto-Indexing`, () => { }) const unsubscribe3 = autoIndexCollection.subscribeChanges(() => {}, { - whereExpression: lte(row.score!, 90), + whereExpression: lte(row.score, 90), }) // Should have created indexes for each field @@ -402,7 +402,7 @@ describe(`Collection Auto-Indexing`, () => { whereExpression: and( eq(row.status, `active`), gt(row.age, 25), - lte(row.score!, 90) + lte(row.score, 90) ), }) diff --git a/packages/db/tests/collection-indexes.test.ts b/packages/db/tests/collection-indexes.test.ts index dec9dc24..0c16675d 100644 --- a/packages/db/tests/collection-indexes.test.ts +++ b/packages/db/tests/collection-indexes.test.ts @@ -861,7 +861,7 @@ describe(`Collection Indexes`, () => { withIndexTracking(collection, (tracker) => { // Query only on fields without indexes (name and score fields don't have indexes) const result = collection.currentStateAsChanges({ - where: (row) => and(eq(row.name, `Alice`), eq(row.score!, 95)), + where: (row) => and(eq(row.name, `Alice`), eq(row.score, 95)), }) expect(result).toHaveLength(1) // Alice diff --git a/packages/db/tests/query/basic.test.ts b/packages/db/tests/query/basic.test.ts index cb299205..304ae237 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/buildQuery.test.ts b/packages/db/tests/query/builder/buildQuery.test.ts index 0347e9c7..fc2590fb 100644 --- a/packages/db/tests/query/builder/buildQuery.test.ts +++ b/packages/db/tests/query/builder/buildQuery.test.ts @@ -67,7 +67,7 @@ describe(`buildQuery function`, () => { ) .select(({ employees, departments }) => ({ employee_name: employees.name, - department_name: departments.name, + department_name: departments?.name, })) ) diff --git a/packages/db/tests/query/builder/callback-types.test-d.ts b/packages/db/tests/query/builder/callback-types.test-d.ts index 28771d79..23ff5efb 100644 --- a/packages/db/tests/query/builder/callback-types.test-d.ts +++ b/packages/db/tests/query/builder/callback-types.test-d.ts @@ -25,8 +25,7 @@ import { sum, upper, } from "../../../src/query/builder/functions.js" -import type { RefProxyFor } from "../../../src/query/builder/types.js" -import type { RefProxy } from "../../../src/query/builder/ref-proxy.js" +import type { Ref } from "../../../src/query/builder/types.js" import type { Aggregate, BasicExpression } from "../../../src/query/ir.js" // Sample data types for comprehensive callback type testing @@ -94,20 +93,15 @@ describe(`Query Builder Callback Types`, () => { describe(`SELECT callback types`, () => { test(`refProxy types in select callback`, () => { new Query().from({ user: usersCollection }).select(({ user }) => { - // Test that user is the correct RefProxy type - expectTypeOf(user).toEqualTypeOf>() - - // Test that properties are accessible and have correct types - expectTypeOf(user.id).toEqualTypeOf>() - expectTypeOf(user.name).toEqualTypeOf>() - expectTypeOf(user.email).toEqualTypeOf>() - expectTypeOf(user.age).toEqualTypeOf>() - expectTypeOf(user.active).toEqualTypeOf>() - expectTypeOf(user.department_id).toEqualTypeOf< - RefProxy - >() - expectTypeOf(user.salary).toEqualTypeOf>() - expectTypeOf(user.created_at).toEqualTypeOf>() + // Test that properties are accessible and have correct types (now using Ref) + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf(user.name).toEqualTypeOf>() + expectTypeOf(user.email).toEqualTypeOf>() + expectTypeOf(user.age).toEqualTypeOf>() + expectTypeOf(user.active).toEqualTypeOf>() + expectTypeOf(user.department_id).toEqualTypeOf | null>() + expectTypeOf(user.salary).toEqualTypeOf>() + expectTypeOf(user.created_at).toEqualTypeOf>() return { id: user.id, @@ -124,27 +118,17 @@ describe(`Query Builder Callback Types`, () => { eq(user.department_id, dept.id) ) .select(({ user, dept }) => { - // Test that both user and dept are available with correct types - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(dept).toEqualTypeOf< - RefProxyFor - >() - // Test cross-table property access - expectTypeOf(user.department_id).toEqualTypeOf< - RefProxy - >() - expectTypeOf(dept.id).toEqualTypeOf>() - expectTypeOf(dept.name).toEqualTypeOf>() - expectTypeOf(dept.budget).toEqualTypeOf< - RefProxy - >() + expectTypeOf(user.department_id).toEqualTypeOf | null>() + expectTypeOf(dept?.id).toEqualTypeOf | undefined>() + expectTypeOf(dept?.name).toEqualTypeOf | undefined>() + expectTypeOf(dept?.budget).toEqualTypeOf | undefined>() return { user_name: user.name, - dept_name: dept.name, + dept_name: dept?.name, user_email: user.email, - dept_budget: dept.budget, + dept_budget: dept?.budget, } }) }) @@ -203,13 +187,10 @@ describe(`Query Builder Callback Types`, () => { describe(`WHERE callback types`, () => { test(`refProxy types in where callback`, () => { new Query().from({ user: usersCollection }).where(({ user }) => { - // Test that user is the correct RefProxy type in where - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(user.id).toEqualTypeOf>() - expectTypeOf(user.active).toEqualTypeOf>() - expectTypeOf(user.department_id).toEqualTypeOf< - RefProxy - >() + // Test that user is the correct PrecomputeRefStructure type in where + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf(user.active).toEqualTypeOf>() + expectTypeOf(user.department_id).toEqualTypeOf | null>() return eq(user.active, true) }) @@ -277,16 +258,14 @@ describe(`Query Builder Callback Types`, () => { eq(user.department_id, dept.id) ) .where(({ user, dept }) => { - // Test that both user and dept are available with correct types - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(dept).toEqualTypeOf< - RefProxyFor - >() + expectTypeOf(user.active).toEqualTypeOf>() + expectTypeOf(dept?.active).toEqualTypeOf | undefined>() + expectTypeOf(dept?.budget).toEqualTypeOf | undefined>() return and( eq(user.active, true), - eq(dept.active, true), - gt(dept.budget, 100000) + eq(dept?.active, true), + gt(dept?.budget, 100000) ) }) }) @@ -297,17 +276,9 @@ describe(`Query Builder Callback Types`, () => { new Query() .from({ user: usersCollection }) .join({ dept: departmentsCollection }, ({ user, dept }) => { - // Test that both tables are available with correct types - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(dept).toEqualTypeOf< - RefProxyFor - >() - // Test property access for join conditions - expectTypeOf(user.department_id).toEqualTypeOf< - RefProxy - >() - expectTypeOf(dept.id).toEqualTypeOf>() + expectTypeOf(user.department_id).toEqualTypeOf | null>() + expectTypeOf(dept.id).toEqualTypeOf>() return eq(user.department_id, dept.id) }) @@ -333,18 +304,14 @@ describe(`Query Builder Callback Types`, () => { eq(user.department_id, dept.id) ) .join({ project: projectsCollection }, ({ user, dept, project }) => { - // Test that all three tables are available - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(dept).toEqualTypeOf< - RefProxyFor - >() - expectTypeOf(project).toEqualTypeOf< - RefProxyFor - >() + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf(dept?.id).toEqualTypeOf | undefined>() + expectTypeOf(project.user_id).toEqualTypeOf>() + expectTypeOf(project.department_id).toEqualTypeOf>() return and( eq(project.user_id, user.id), - eq(project.department_id, dept.id) + eq(project.department_id, dept?.id) ) }) }) @@ -353,11 +320,9 @@ describe(`Query Builder Callback Types`, () => { describe(`ORDER BY callback types`, () => { test(`refProxy types in orderBy callback`, () => { new Query().from({ user: usersCollection }).orderBy(({ user }) => { - // Test that user is the correct RefProxy type - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(user.name).toEqualTypeOf>() - expectTypeOf(user.age).toEqualTypeOf>() - expectTypeOf(user.created_at).toEqualTypeOf>() + expectTypeOf(user.name).toEqualTypeOf>() + expectTypeOf(user.age).toEqualTypeOf>() + expectTypeOf(user.created_at).toEqualTypeOf>() return user.name }) @@ -384,13 +349,11 @@ describe(`Query Builder Callback Types`, () => { eq(user.department_id, dept.id) ) .orderBy(({ user, dept }) => { - // Test that both tables are available in orderBy - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(dept).toEqualTypeOf< - RefProxyFor - >() + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf(dept?.id).toEqualTypeOf | undefined>() + expectTypeOf(dept?.name).toEqualTypeOf | undefined>() - return dept.name + return dept?.name }) }) }) @@ -399,11 +362,9 @@ describe(`Query Builder Callback Types`, () => { test(`refProxy types in groupBy callback`, () => { new Query().from({ user: usersCollection }).groupBy(({ user }) => { // Test that user is the correct RefProxy type - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(user.department_id).toEqualTypeOf< - RefProxy - >() - expectTypeOf(user.active).toEqualTypeOf>() + // expectTypeOf(user).toEqualTypeOf>() + expectTypeOf(user.department_id).toEqualTypeOf | null>() + expectTypeOf(user.active).toEqualTypeOf>() return user.department_id }) @@ -414,7 +375,7 @@ describe(`Query Builder Callback Types`, () => { // Test array return type for multiple columns const groupColumns = [user.department_id, user.active] expectTypeOf(groupColumns).toEqualTypeOf< - Array | RefProxy> + Array<(Ref | null) | Ref> >() return [user.department_id, user.active] @@ -428,13 +389,11 @@ describe(`Query Builder Callback Types`, () => { eq(user.department_id, dept.id) ) .groupBy(({ user, dept }) => { - // Test that both tables are available in groupBy - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(dept).toEqualTypeOf< - RefProxyFor - >() + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf(dept?.id).toEqualTypeOf | undefined>() + expectTypeOf(dept?.location).toEqualTypeOf | undefined>() - return dept.location + return dept?.location }) }) }) @@ -445,8 +404,8 @@ describe(`Query Builder Callback Types`, () => { .from({ user: usersCollection }) .groupBy(({ user }) => user.department_id) .having(({ user }) => { - // Test that user is the correct RefProxy type in having - expectTypeOf(user).toEqualTypeOf>() + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf(user.department_id).toEqualTypeOf | null>() return gt(count(user.id), 5) }) @@ -457,6 +416,8 @@ describe(`Query Builder Callback Types`, () => { .from({ user: usersCollection }) .groupBy(({ user }) => user.department_id) .having(({ user }) => { + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf(user.department_id).toEqualTypeOf | null>() // Test aggregate functions in having expectTypeOf(count(user.id)).toEqualTypeOf>() expectTypeOf(avg(user.age)).toEqualTypeOf>() @@ -504,13 +465,11 @@ describe(`Query Builder Callback Types`, () => { .join({ dept: departmentsCollection }, ({ user, dept }) => eq(user.department_id, dept.id) ) - .groupBy(({ dept }) => dept.location) + .groupBy(({ dept }) => dept?.location) .having(({ user, dept }) => { - // Test that both tables are available in having - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(dept).toEqualTypeOf< - RefProxyFor - >() + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf(dept?.id).toEqualTypeOf | undefined>() + expectTypeOf(dept?.location).toEqualTypeOf | undefined>() return and(gt(count(user.id), 3), gt(avg(user.salary), 70000)) }) @@ -522,77 +481,60 @@ describe(`Query Builder Callback Types`, () => { new Query() .from({ user: usersCollection }) .join({ dept: departmentsCollection }, ({ user, dept }) => { - // JOIN callback - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(dept).toEqualTypeOf< - RefProxyFor - >() + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf(dept.id).toEqualTypeOf>() + expectTypeOf(dept.location).toEqualTypeOf>() return eq(user.department_id, dept.id) }) .join({ project: projectsCollection }, ({ user, dept, project }) => { - // Second JOIN callback - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(dept).toEqualTypeOf< - RefProxyFor - >() - expectTypeOf(project).toEqualTypeOf< - RefProxyFor - >() + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf(dept?.id).toEqualTypeOf | undefined>() + expectTypeOf(dept?.location).toEqualTypeOf | undefined>() + expectTypeOf(project.user_id).toEqualTypeOf>() + expectTypeOf(project.department_id).toEqualTypeOf>() return eq(project.user_id, user.id) }) .where(({ user, dept, project }) => { - // WHERE callback - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(dept).toEqualTypeOf< - RefProxyFor + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf(dept?.id).toEqualTypeOf | undefined>() + expectTypeOf(dept?.location).toEqualTypeOf | undefined>() + expectTypeOf(project?.user_id).toEqualTypeOf< + Ref | undefined >() - expectTypeOf(project).toEqualTypeOf< - RefProxyFor + expectTypeOf(project?.department_id).toEqualTypeOf< + Ref | undefined >() return and( eq(user.active, true), - eq(dept.active, true), - eq(project.status, `active`) + eq(dept?.active, true), + eq(project?.status, `active`) ) }) .groupBy(({ dept }) => { - // GROUP BY callback - expectTypeOf(dept).toEqualTypeOf< - RefProxyFor - >() - return dept.location + expectTypeOf(dept?.id).toEqualTypeOf | undefined>() + expectTypeOf(dept?.location).toEqualTypeOf | undefined>() + return dept?.location }) .having(({ user, project }) => { - // HAVING callback - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(project).toEqualTypeOf< - RefProxyFor - >() - return and(gt(count(user.id), 2), gt(avg(project.budget), 50000)) + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf(project?.budget).toEqualTypeOf | undefined>() + return and(gt(count(user.id), 2), gt(avg(project?.budget), 50000)) }) .select(({ user, dept, project }) => { - // SELECT callback - expectTypeOf(user).toEqualTypeOf>() - expectTypeOf(dept).toEqualTypeOf< - RefProxyFor - >() - expectTypeOf(project).toEqualTypeOf< - RefProxyFor - >() + expectTypeOf(user.id).toEqualTypeOf>() + expectTypeOf(dept?.location).toEqualTypeOf | undefined>() + expectTypeOf(project?.budget).toEqualTypeOf | undefined>() return { - location: dept.location, + location: dept?.location, user_count: count(user.id), avg_salary: avg(user.salary), - total_project_budget: sum(project.budget), - avg_project_budget: avg(project.budget), + total_project_budget: sum(project?.budget), + avg_project_budget: avg(project?.budget), } }) .orderBy(({ dept }) => { - // ORDER BY callback - expectTypeOf(dept).toEqualTypeOf< - RefProxyFor - >() - return dept.location + expectTypeOf(dept?.location).toEqualTypeOf | undefined>() + return dept?.location }) }) }) diff --git a/packages/db/tests/query/builder/functional-variants.test.ts b/packages/db/tests/query/builder/functional-variants.test.ts index c70f69cf..eb87ac27 100644 --- a/packages/db/tests/query/builder/functional-variants.test.ts +++ b/packages/db/tests/query/builder/functional-variants.test.ts @@ -221,7 +221,7 @@ describe(`QueryBuilder functional variants (fn)`, () => { ({ employees, departments }) => eq(employees.department_id, departments.id) ) - .groupBy(({ departments }) => departments.name) + .groupBy(({ departments }) => departments?.name) .fn.having( (row) => row.employees.salary > 60000 && @@ -245,7 +245,7 @@ describe(`QueryBuilder functional variants (fn)`, () => { ) .fn.where((row) => row.employees.active) .fn.where((row) => row.employees.salary > 40000) - .groupBy(({ departments }) => departments.name) + .groupBy(({ departments }) => departments?.name) .fn.having((row) => row.employees.salary > 70000) .fn.select((row) => ({ departmentName: row.departments?.name || `Unknown`, diff --git a/packages/db/tests/query/builder/group-by.test.ts b/packages/db/tests/query/builder/group-by.test.ts index e98834dc..d12b2525 100644 --- a/packages/db/tests/query/builder/group-by.test.ts +++ b/packages/db/tests/query/builder/group-by.test.ts @@ -110,21 +110,26 @@ describe(`QueryBuilder.groupBy`, () => { expect(builtQuery.select).toBeDefined() }) - it(`overrides previous group by clauses`, () => { + it(`accumulates multiple group by clauses`, () => { const builder = new Query() const query = builder .from({ employees: employeesCollection }) .groupBy(({ employees }) => employees.department_id) - .groupBy(({ employees }) => employees.active) // This should override + .groupBy(({ employees }) => employees.active) // This should accumulate .select(({ employees }) => ({ + department_id: employees.department_id, active: employees.active, count: count(employees.id), })) const builtQuery = getQueryIR(query) expect(builtQuery.groupBy).toBeDefined() - expect(builtQuery.groupBy).toHaveLength(1) + expect(builtQuery.groupBy).toHaveLength(2) expect((builtQuery.groupBy![0] as any).path).toEqual([ + `employees`, + `department_id`, + ]) + expect((builtQuery.groupBy![1] as any).path).toEqual([ `employees`, `active`, ]) diff --git a/packages/db/tests/query/builder/join.test.ts b/packages/db/tests/query/builder/join.test.ts index 5cebac65..cbd09acf 100644 --- a/packages/db/tests/query/builder/join.test.ts +++ b/packages/db/tests/query/builder/join.test.ts @@ -75,7 +75,7 @@ describe(`QueryBuilder.join`, () => { eq(employees.department_id, departments.id) ) .join({ projects: projectsCollection }, ({ departments, projects }) => - eq(departments.id, projects.department_id) + eq(departments?.id, projects.department_id) ) const builtQuery = getQueryIR(query) @@ -101,8 +101,8 @@ describe(`QueryBuilder.join`, () => { .select(({ employees, departments }) => ({ id: employees.id, name: employees.name, - department_name: departments.name, - department_budget: departments.budget, + department_name: departments?.name, + department_budget: departments?.budget, })) const builtQuery = getQueryIR(query) @@ -122,7 +122,7 @@ describe(`QueryBuilder.join`, () => { ({ employees, departments }) => eq(employees.department_id, departments.id) ) - .where(({ departments }) => gt(departments.budget, 1000000)) + .where(({ departments }) => gt(departments?.budget, 1000000)) const builtQuery = getQueryIR(query) expect(builtQuery.where).toBeDefined() @@ -160,13 +160,13 @@ describe(`QueryBuilder.join`, () => { eq(employees.department_id, departments.id) ) .where(({ employees, departments }) => - and(gt(employees.salary, 50000), gt(departments.budget, 1000000)) + and(gt(employees.salary, 50000), gt(departments?.budget, 1000000)) ) .select(({ employees, departments }) => ({ id: employees.id, name: employees.name, - department_name: departments.name, - dept_location: departments.location, + department_name: departments?.name, + dept_location: departments?.location, })) const builtQuery = getQueryIR(query) @@ -360,7 +360,7 @@ describe(`QueryBuilder.join`, () => { .innerJoin( { projects: projectsCollection }, ({ departments, projects }) => - eq(departments.id, projects.department_id) + eq(departments?.id, projects.department_id) ) const builtQuery = getQueryIR(query) diff --git a/packages/db/tests/query/builder/select.test.ts b/packages/db/tests/query/builder/select.test.ts index 2b632f7d..c9bb10c7 100644 --- a/packages/db/tests/query/builder/select.test.ts +++ b/packages/db/tests/query/builder/select.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from "vitest" import { CollectionImpl } from "../../../src/collection.js" import { Query, getQueryIR } from "../../../src/query/builder/index.js" -import { avg, count, eq, upper } from "../../../src/query/builder/functions.js" +import { + avg, + count, + eq, + isUndefined, + length, + not, + upper, +} from "../../../src/query/builder/functions.js" // Test schema interface Employee { @@ -10,6 +18,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 +193,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: not(isUndefined(employees.profile)), + 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: length(employees.profile?.skills), + }, + 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/builder/subqueries.test-d.ts b/packages/db/tests/query/builder/subqueries.test-d.ts index f68fa89d..ee2b3850 100644 --- a/packages/db/tests/query/builder/subqueries.test-d.ts +++ b/packages/db/tests/query/builder/subqueries.test-d.ts @@ -119,7 +119,7 @@ describe(`Subquery Types`, () => { .select(({ issue, activeUser }) => ({ issueId: issue.id, issueTitle: issue.title, - userName: activeUser.name, + userName: activeUser?.name, })) // Verify the result type @@ -148,7 +148,7 @@ describe(`Subquery Types`, () => { ) .select(({ issue, activeUser }) => ({ issueId: issue.id, - userName: activeUser.name, + userName: activeUser?.name, })) // Verify the result type @@ -250,7 +250,7 @@ describe(`Subquery Types`, () => { ) .select(({ issue, activeUser }) => ({ issueId: issue.id, - userName: activeUser.name, + userName: activeUser?.name, })) // Verify the result type @@ -274,7 +274,7 @@ describe(`Subquery Types`, () => { ) .select(({ issue, user }) => ({ issueId: issue.id, - userName: user.name, + userName: user?.name, })) // Verify the result type diff --git a/packages/db/tests/query/compiler/subqueries.test.ts b/packages/db/tests/query/compiler/subqueries.test.ts index f69a4984..e64c38dc 100644 --- a/packages/db/tests/query/compiler/subqueries.test.ts +++ b/packages/db/tests/query/compiler/subqueries.test.ts @@ -217,7 +217,7 @@ describe(`Query2 Subqueries`, () => { .select(({ issue, activeUser }) => ({ issueId: issue.id, issueTitle: issue.title, - userName: activeUser.name, + userName: activeUser?.name, })) const builtQuery = getQueryIR(query) @@ -252,7 +252,7 @@ describe(`Query2 Subqueries`, () => { .select(({ issue, activeUser }) => ({ issueId: issue.id, issueTitle: issue.title, - userName: activeUser.name, + userName: activeUser?.name, })) const builtQuery = getQueryIR(query) diff --git a/packages/db/tests/query/functional-variants.test-d.ts b/packages/db/tests/query/functional-variants.test-d.ts index f20aa61a..9f05718f 100644 --- a/packages/db/tests/query/functional-variants.test-d.ts +++ b/packages/db/tests/query/functional-variants.test-d.ts @@ -450,11 +450,11 @@ describe(`Functional Variants Types`, () => { .join({ dept: departmentsCollection }, ({ user, dept }) => eq(user.department_id, dept.id) ) - .groupBy(({ dept }) => dept.name) + .groupBy(({ dept }) => dept?.name) .fn.having((row) => row.dept?.name !== `HR`) .select(({ dept, user }) => ({ - departmentId: dept.id, - departmentName: dept.name, + departmentId: dept?.id, + departmentName: dept?.name, totalEmployees: count(user.id), })), }) diff --git a/packages/db/tests/query/group-by.test.ts b/packages/db/tests/query/group-by.test.ts index 684eb472..beb3b93b 100644 --- a/packages/db/tests/query/group-by.test.ts +++ b/packages/db/tests/query/group-by.test.ts @@ -9,6 +9,7 @@ import { eq, gt, gte, + isNotUndefined, lt, max, min, @@ -27,6 +28,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 +68,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 +105,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 +138,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 +1049,277 @@ 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 }) => isNotUndefined(orders.customer)) + .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 }) => isNotUndefined(orders.customer?.address)) + .groupBy(({ orders }) => orders.customer?.address.state) + .select(({ orders }) => ({ + state: orders.customer?.address.state, + order_count: count(orders.id), + total_amount: sum(orders.amount), + })), + }) + + 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 }) => isNotUndefined(orders.shipping)) + .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( + isNotUndefined(orders.customer), + isNotUndefined(orders.shipping) + ) + ) + .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 }) => + isNotUndefined(orders.customer?.preferences) + ) + .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 }) => + isNotUndefined(orders.shipping?.tracking) + ) + .select(({ orders }) => ({ + tracking_status: isNotUndefined(orders.shipping?.tracking), + order_count: count(orders.id), + total_amount: sum(orders.amount), + })), + }) + + const results = trackingSummary.toArray + expect(results).toHaveLength(2) // tracked and untracked + + const tracked = results.find((r) => r.tracking_status === true) + 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 === false) + 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 }) => isNotUndefined(orders.customer)) + .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/join-subquery.test-d.ts b/packages/db/tests/query/join-subquery.test-d.ts index 3cb7c662..542749ba 100644 --- a/packages/db/tests/query/join-subquery.test-d.ts +++ b/packages/db/tests/query/join-subquery.test-d.ts @@ -386,7 +386,7 @@ describe(`Join Subquery Types`, () => { ) .select(({ issue, activeUser }) => ({ issue_title: issue.title, - user_name: activeUser.name, // Should now be string | undefined + user_name: activeUser?.name, // Should now be string | undefined issue_status: issue.status, })) }, diff --git a/packages/db/tests/query/join-subquery.test.ts b/packages/db/tests/query/join-subquery.test.ts index d730e4a4..adbf8a40 100644 --- a/packages/db/tests/query/join-subquery.test.ts +++ b/packages/db/tests/query/join-subquery.test.ts @@ -193,7 +193,7 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { ) .select(({ issue, activeUser }) => ({ issue_title: issue.title, - user_name: activeUser.name, + user_name: activeUser?.name, issue_status: issue.status, })) }, @@ -333,8 +333,8 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { .select(({ issue, activeUser }) => ({ issue_title: issue.title, issue_status: issue.status, - user_name: activeUser.name, - user_status: activeUser.status, + user_name: activeUser?.name, + user_status: activeUser?.status, })) }, }) diff --git a/packages/db/tests/query/join.test-d.ts b/packages/db/tests/query/join.test-d.ts index 717a0d62..bc5b2485 100644 --- a/packages/db/tests/query/join.test-d.ts +++ b/packages/db/tests/query/join.test-d.ts @@ -209,8 +209,8 @@ describe(`Join Types - Type Safety`, () => { ) .select(({ user, dept }) => ({ userName: user.name, - deptName: dept.name, // This should still be accessible in select - deptBudget: dept.budget, + deptName: dept?.name, // This should still be accessible in select + deptBudget: dept?.budget, })), }) @@ -383,8 +383,8 @@ describe(`Join Alias Methods - Type Safety`, () => { ) .select(({ user, dept }) => ({ userName: user.name, - deptName: dept.name, // This should be string | undefined due to left join - deptBudget: dept.budget, + deptName: dept?.name, // This should be string | undefined due to left join + deptBudget: dept?.budget, })), }) @@ -457,7 +457,7 @@ describe(`Join Alias Methods - Type Safety`, () => { ) .join( { project: projectsCollection }, - ({ dept, project }) => eq(dept.id, project.department_id), + ({ dept, project }) => eq(dept?.id, project.department_id), `inner` ), }) @@ -687,7 +687,7 @@ describe(`Join Alias Methods - Type Safety`, () => { ) .select(({ post, user }) => ({ postTitle: post.title, - authorName: user.name, // This will be string | undefined due to left join + authorName: user?.name, // This will be string | undefined due to left join })), }) @@ -835,7 +835,7 @@ describe(`Join with ArkType Schemas`, () => { ) .select(({ post, user }) => ({ postTitle: post.title, - authorName: user.name, // This will be string | undefined due to left join + authorName: user?.name, // This will be string | undefined due to left join })), }) diff --git a/packages/db/tests/query/join.test.ts b/packages/db/tests/query/join.test.ts index df1b5f01..f211a843 100644 --- a/packages/db/tests/query/join.test.ts +++ b/packages/db/tests/query/join.test.ts @@ -108,9 +108,9 @@ function testJoinType(joinType: JoinType, autoIndex: `off` | `eager`) { joinType ) .select(({ user, dept }) => ({ - user_name: user.name, - department_name: dept.name, - budget: dept.budget, + user_name: user?.name, + department_name: dept?.name, + budget: dept?.budget, })), }) @@ -289,8 +289,8 @@ function testJoinType(joinType: JoinType, autoIndex: `off` | `eager`) { joinType ) .select(({ user, dept }) => ({ - user_name: user.name, - department_name: dept.name, + user_name: user?.name, + department_name: dept?.name, })), }) @@ -332,8 +332,8 @@ function testJoinType(joinType: JoinType, autoIndex: `off` | `eager`) { joinType ) .select(({ user, dept }) => ({ - user_name: user.name, - department_name: dept.name, + user_name: user?.name, + department_name: dept?.name, })), }) @@ -371,8 +371,8 @@ function testJoinType(joinType: JoinType, autoIndex: `off` | `eager`) { joinType ) .select(({ user, dept }) => ({ - user_name: user.name, - department_name: dept.name, + user_name: user?.name, + department_name: dept?.name, })), }) @@ -420,8 +420,8 @@ function testJoinType(joinType: JoinType, autoIndex: `off` | `eager`) { joinType ) .select(({ user, dept }) => ({ - user_name: user.name, - department_name: dept.name, + user_name: user?.name, + department_name: dept?.name, })), }) @@ -603,7 +603,7 @@ function createJoinTests(autoIndex: `off` | `eager`): void { user_id: user.id, user_name: user.name, department_id: user.department_id, - department_name: dept.name, + department_name: dept?.name, })), }) diff --git a/packages/db/tests/query/nested-props.test-d.ts b/packages/db/tests/query/nested-props.test-d.ts new file mode 100644 index 00000000..2894eb60 --- /dev/null +++ b/packages/db/tests/query/nested-props.test-d.ts @@ -0,0 +1,347 @@ +import { describe, expectTypeOf, test } from "vitest" +import { + and, + createLiveQueryCollection, + eq, + gt, + or, +} from "../../src/query/index.js" +import { createCollection } from "../../src/collection.js" +import { mockSyncCollectionOptions } from "../utls.js" + +// Complex nested type for testing with optional properties +type Person = { + id: string + name: string + age: number + 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 + } + } +} + +// Sample data +const samplePersons: Array = [ + { + id: `1`, + name: `John Doe`, + age: 30, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + profile: { + bio: `Senior developer`, + score: 85, + stats: { + tasksCompleted: 120, + rating: 4.5, + }, + }, + address: { + city: `New York`, + country: `USA`, + coordinates: { + lat: 40.7128, + lng: -74.006, + }, + }, + }, +] + +function createPersonsCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `test-persons`, + getKey: (person) => person.id, + initialData: samplePersons, + }) + ) +} + +describe(`Nested Properties Types`, () => { + const personsCollection = createPersonsCollection() + + test(`select with nested properties returns correct types`, () => { + const collection = createLiveQueryCollection({ + query: (q) => + q.from({ persons: personsCollection }).select(({ persons }) => ({ + id: persons.id, + name: persons.name, + // Level 1 nesting + bio: persons.profile?.bio, + score: persons.profile?.score, + // Level 2 nesting + tasksCompleted: persons.profile?.stats.tasksCompleted, + rating: persons.profile?.stats.rating, + // Address nesting + city: persons.address?.city, + country: persons.address?.country, + // Coordinates (level 2 nesting under address) + lat: persons.address?.coordinates.lat, + lng: persons.address?.coordinates.lng, + })), + }) + + const results = collection.toArray + expectTypeOf(results).toEqualTypeOf< + Array<{ + id: string + name: string + bio: string | undefined // Now optional because profile is optional + score: number | undefined // Now optional because profile is optional + tasksCompleted: number | undefined // Now optional because profile?.stats is optional + rating: number | undefined // Now optional because profile?.stats is optional + city: string | undefined // Now optional because address is optional + country: string | undefined // Now optional because address is optional + lat: number | undefined // Now optional because address?.coordinates is optional + lng: number | undefined // Now optional because address?.coordinates is optional + }> + >() + }) + + test(`where clause with nested properties`, () => { + const collection = createLiveQueryCollection({ + query: (q) => + q + .from({ persons: personsCollection }) + // Test various nested property comparisons + .where(({ persons }) => gt(persons.profile?.score, 80)) + .where(({ persons }) => eq(persons.address?.country, `USA`)) + .where(({ persons }) => gt(persons.address?.coordinates.lat, 35)) + .where(({ persons }) => gt(persons.profile?.stats.rating, 4.0)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + }) + + const results = collection.toArray + expectTypeOf(results).toEqualTypeOf< + Array<{ + id: string + name: string + }> + >() + }) + + test(`where clause with complex nested expressions`, () => { + const collection = createLiveQueryCollection({ + query: (q) => + q + .from({ persons: personsCollection }) + .where(({ persons }) => + or( + gt(persons.profile?.score, 90), + and( + gt(persons.profile?.stats.tasksCompleted, 100), + gt(persons.profile?.stats.rating, 4.5) + ) + ) + ) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + score: persons.profile?.score, + })), + }) + + const results = collection.toArray + expectTypeOf(results).toEqualTypeOf< + Array<{ + id: string + name: string + score: number | undefined // Optional because profile is optional + }> + >() + }) + + test(`orderBy with nested properties`, () => { + const collection = createLiveQueryCollection({ + query: (q) => + q + .from({ persons: personsCollection }) + .orderBy(({ persons }) => persons.profile?.score, `desc`) + .orderBy(({ persons }) => persons.address?.coordinates.lat, `asc`) + .orderBy(({ persons }) => persons.profile?.stats.rating, `desc`) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + score: persons.profile?.score, + lat: persons.address?.coordinates.lat, + rating: persons.profile?.stats.rating, + })), + }) + + const results = collection.toArray + expectTypeOf(results).toEqualTypeOf< + Array<{ + id: string + name: string + score: number | undefined // Optional because profile is optional + lat: number | undefined // Optional because address?.coordinates is optional + rating: number | undefined // Optional because profile?.stats is optional + }> + >() + }) + + test(`orderBy with multiple nested properties and options`, () => { + const collection = createLiveQueryCollection({ + query: (q) => + q + .from({ persons: personsCollection }) + .orderBy(({ persons }) => persons.profile?.score, { + direction: `desc`, + nulls: `last`, + }) + .orderBy(({ persons }) => persons.address?.city, { + direction: `asc`, + nulls: `first`, + stringSort: `locale`, + }) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })), + }) + + const results = collection.toArray + expectTypeOf(results).toEqualTypeOf< + Array<{ + id: string + name: string + }> + >() + }) + + test(`deeply nested property access in all methods`, () => { + const collection = createLiveQueryCollection({ + query: (q) => + q + .from({ persons: personsCollection }) + // WHERE with deeply nested + .where(({ persons }) => gt(persons.profile?.stats.tasksCompleted, 50)) + // ORDER BY with deeply nested + .orderBy(({ persons }) => persons.profile?.stats.rating, `desc`) + .orderBy(({ persons }) => persons.address?.coordinates.lng, `asc`) + // SELECT with deeply nested + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + // Level 1 + team: persons.team, + // Level 2 under profile + bio: persons.profile?.bio, + score: persons.profile?.score, + // Level 3 under profile?.stats + tasksCompleted: persons.profile?.stats.tasksCompleted, + rating: persons.profile?.stats.rating, + // Level 2 under address + city: persons.address?.city, + country: persons.address?.country, + // Level 3 under address?.coordinates + lat: persons.address?.coordinates.lat, + lng: persons.address?.coordinates.lng, + })), + }) + + const results = collection.toArray + expectTypeOf(results).toEqualTypeOf< + Array<{ + id: string + name: string + team: string + bio: string | undefined + score: number | undefined + tasksCompleted: number | undefined + rating: number | undefined + city: string | undefined + country: string | undefined + lat: number | undefined + lng: number | undefined + }> + >() + }) + + test(`direct nested object access`, () => { + const collection = createLiveQueryCollection({ + query: (q) => + q.from({ persons: personsCollection }).select(({ persons }) => ({ + // These should be properly typed without optionality issues + profileExists: persons.profile, + addressExists: persons.address, + // Direct access works fine with required properties + score: persons.profile?.score, + coordinates: persons.address?.coordinates, + })), + }) + + const results = collection.toArray + expectTypeOf(results).toEqualTypeOf< + Array<{ + profileExists: + | { + bio: string + score: number + stats: { + tasksCompleted: number + rating: number + } + } + | undefined + addressExists: + | { + city: string + country: string + coordinates: { + lat: number + lng: number + } + } + | undefined + score: number | undefined + coordinates: + | { + lat: number + lng: number + } + | undefined + }> + >() + }) + + test(`nested properties work at runtime with correct types`, () => { + // Test that nested properties work correctly at runtime + const collection = createLiveQueryCollection({ + query: (q) => + q.from({ persons: personsCollection }).select(({ persons }) => ({ + id: persons.id, + profileScore: persons.profile?.score, + coordinatesLat: persons.address?.coordinates.lat, + })), + }) + + const results = collection.toArray + expectTypeOf(results).toEqualTypeOf< + Array<{ + id: string + profileScore: number | undefined + coordinatesLat: number | undefined + }> + >() + }) +}) diff --git a/packages/db/tests/query/optional-fields-negative.test-d.ts b/packages/db/tests/query/optional-fields-negative.test-d.ts index a0d24ca6..127c98e1 100644 --- a/packages/db/tests/query/optional-fields-negative.test-d.ts +++ b/packages/db/tests/query/optional-fields-negative.test-d.ts @@ -153,7 +153,7 @@ describe(`Optional Fields - Type Safety Tests`, () => { ) .select(({ user, dept }) => ({ user_name: user.name, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + dept_name: dept?.name, // Should be string | undefined due to left join })), }) diff --git a/packages/db/tests/query/optional-fields-runtime.test.ts b/packages/db/tests/query/optional-fields-runtime.test.ts index c3fe3532..48b195e4 100644 --- a/packages/db/tests/query/optional-fields-runtime.test.ts +++ b/packages/db/tests/query/optional-fields-runtime.test.ts @@ -166,7 +166,7 @@ describe(`Optional Fields - Runtime Tests`, () => { ) .select(({ user, dept }) => ({ user_name: user.name, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + dept_name: dept?.name, // Should be undefined for Bob })), }) diff --git a/packages/db/tests/query/order-by.test.ts b/packages/db/tests/query/order-by.test.ts index 7295dbcd..af810923 100644 --- a/packages/db/tests/query/order-by.test.ts +++ b/packages/db/tests/query/order-by.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest" import { createCollection } from "../../src/collection.js" import { mockSyncCollectionOptions } from "../utls.js" import { createLiveQueryCollection } from "../../src/query/live-query-collection.js" -import { eq, gt } from "../../src/query/builder/functions.js" +import { eq, gt, isNotUndefined } from "../../src/query/builder/functions.js" type Person = { id: string @@ -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, + }, + }, }, ] @@ -410,12 +466,12 @@ function createOrderByTests(autoIndex: `off` | `eager`): void { ({ employees, departments }) => eq(employees.department_id, departments.id) ) - .orderBy(({ departments }) => departments.name, `asc`) + .orderBy(({ departments }) => departments?.name, `asc`) .orderBy(({ employees }) => employees.salary, `desc`) .select(({ employees, departments }) => ({ id: employees.id, employee_name: employees.name, - department_name: departments.name, + department_name: departments?.name, salary: employees.salary, })) ) @@ -1004,6 +1060,262 @@ function createOrderByTests(autoIndex: `off` | `eager`): void { ]) }) }) + + describe(`Nested Object OrderBy`, () => { + const createPersonsCollection = () => { + return createCollection( + mockSyncCollectionOptions({ + id: `test-persons-nested`, + getKey: (person) => person.id, + initialData: initialPersons, + autoIndex, + }) + ) + } + + let personsCollection: ReturnType + + beforeEach(() => { + personsCollection = createPersonsCollection() + }) + + 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 }) => isNotUndefined(persons.address)) + .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 undefined score and be first (undefined sorts first) + expect(results.map((r) => r.score)).toEqual([undefined, 92, 85, 78]) + expect(results[0]!.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: undefined, Los Angeles, New York (undefined sorts first) + // Note: undefined values in ORDER BY sort first in our implementation + expect(results.map((r) => r.city)).toEqual([ + undefined, + `Los Angeles`, + `New York`, + ]) + expect(results.map((r) => r.name)).toEqual([ + `John Smith`, + `Jane Doe`, + `John Doe`, + ]) + }) + }) }) } diff --git a/packages/db/tests/query/where.test.ts b/packages/db/tests/query/where.test.ts index 8dc673f3..4bbcd03d 100644 --- a/packages/db/tests/query/where.test.ts +++ b/packages/db/tests/query/where.test.ts @@ -11,6 +11,8 @@ import { gt, gte, inArray, + isNotNull, + isNotUndefined, length, like, lower, @@ -33,6 +35,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 +80,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 +120,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 +154,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 +180,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 +1381,289 @@ 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: isNotNull(emp.contact?.address), + })), + }) + + 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 }) => isNotUndefined(emp.profile)) + .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 }) => isNotUndefined(emp.contact?.emergency)) + .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 }) => isNotUndefined(emp.profile?.skills)) + .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) + }) + }) }) }