From 29d7e0c151ff07fe33e3dbff2916314656583c32 Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Mon, 13 Apr 2026 23:00:50 +0100 Subject: [PATCH] Add immutable query builders, options types, and aggregate improvements --- .changeset/afraid-cars-reply.md | 6 + README.md | 34 +++-- src/collection/collection.ts | 16 +- src/collection/pipeline/aggregation.ts | 39 ++++- src/collection/pipeline/base.ts | 15 +- src/collection/query/bulk-write.ts | 12 +- src/collection/query/delete-many.ts | 14 +- src/collection/query/delete-one.ts | 14 +- src/collection/query/distinct.ts | 12 +- src/collection/query/find-one-and-delete.ts | 36 ++++- src/collection/query/find-one-and-replace.ts | 42 +++++- src/collection/query/find-one-and-update.ts | 74 +++++++-- src/collection/query/find-one.ts | 57 +++++-- src/collection/query/find.ts | 84 ++++++++--- src/collection/query/insert-many.ts | 14 +- src/collection/query/insert-one.ts | 16 +- src/collection/query/replace-one.ts | 14 +- src/collection/query/update-many.ts | 21 +-- src/collection/query/update-one.ts | 36 +++-- src/collection/types/pipeline-stage.ts | 3 +- src/index.ts | 16 ++ src/relations/relations.ts | 25 +++- tests/query/aggregate.test.ts | 50 ++++++- tests/query/query-methods.test.ts | 150 ++++++++++++++++++- 24 files changed, 634 insertions(+), 166 deletions(-) create mode 100644 .changeset/afraid-cars-reply.md diff --git a/.changeset/afraid-cars-reply.md b/.changeset/afraid-cars-reply.md new file mode 100644 index 0000000..07402f9 --- /dev/null +++ b/.changeset/afraid-cars-reply.md @@ -0,0 +1,6 @@ +--- +"monarch-orm": minor +--- + +Make query builder methods immutable — each method returns a new instance instead of mutating. + diff --git a/README.md b/README.md index 5f34869..3d0697a 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,8 @@ const userSchema = createSchema("users", { const schemas = defineSchemas({ userSchema }); -// Create and connect the MongoDB client. +// Create a MongoDB client. const client = createClient(process.env.MONGODB_URI!); -await client.connect(); // Create a database instance. const db = createDatabase(client.db("app"), schemas); @@ -469,8 +468,6 @@ Deletes one document by `_id` and returns it. const deleted = await db.collections.users.findByIdAndDelete("67f0123456789abcdef0123"); ``` -### Other Collection Methods - ### `distinct(key, filter?)` Returns a query for the distinct values of a field. @@ -518,13 +515,19 @@ Returns MongoDB's estimated document count for the collection. const totalCount = await db.collections.users.estimatedDocumentCount(); ``` -### `aggregate()` +### `aggregate(pipeline?)` -Builds an aggregation pipeline. +Builds an aggregation pipeline. Accepts an optional pipeline, and additional stages can be appended with `addStage()`. ```ts const result = await db.collections.users - .aggregate() + .aggregate<{ count: number }>([ + { $match: { isVerified: true } }, + { $group: { _id: "$isVerified", count: { $sum: 1 } } }, + ]); + +const result = await db.collections.users + .aggregate<{ count: number }>() .addStage({ $match: { isVerified: true } }) .addStage({ $group: { _id: "$isVerified", count: { $sum: 1 } } }); ``` @@ -537,19 +540,20 @@ Returns the underlying MongoDB collection. const rawUsers = await db.collections.users.raw().find().toArray(); ``` -Queries are lazy, so you can build and reuse them before execution. They run only when you `await` them or call a promise method like `.then()`, `.catch()`, or `.finally()`. +Queries are lazy and immutable — each builder method returns a new query instance, leaving the original unchanged. Queries run only when you `await` them or call a promise method like `.then()`, `.catch()`, or `.finally()`. ```ts -let verifiedUsersQuery = db.collections.users +// Each builder method returns a new query — the original is never modified. +const base = db.collections.users .find({ isVerified: true }) - .omit({ age: true }) - .sort({ email: "asc" }); + .omit({ age: true }); -if (limitResults) { - verifiedUsersQuery = verifiedUsersQuery.limit(10); -} +const sorted = base.sort({ email: "asc" }); // new instance +const limited = base.limit(10); // new instance from base, no sort -const verifiedUsers = await verifiedUsersQuery; +const sortedUsers = await sorted; // sorted, no limit +const limitedUsers = await limited; // no sort, limited to 10 +const allVerified = await base; // unchanged ``` ### Schema features diff --git a/src/collection/collection.ts b/src/collection/collection.ts index 5f42153..20cc60d 100644 --- a/src/collection/collection.ts +++ b/src/collection/collection.ts @@ -1,4 +1,5 @@ import { + type Abortable, type AnyBulkWriteOperation, type CountDocumentsOptions, type Db, @@ -30,6 +31,7 @@ import { InsertOneQuery } from "./query/insert-one"; import { ReplaceOneQuery } from "./query/replace-one"; import { UpdateManyQuery } from "./query/update-many"; import { UpdateOneQuery } from "./query/update-one"; +import type { PipelineStage } from "./types/pipeline-stage"; /** * Collection interface for MongoDB operations. @@ -119,7 +121,7 @@ export class Collection, "_id">, update: UpdateFilter | Document[]) { + public findByIdAndUpdate(id: Index, "_id">, update: UpdateFilter) { const _idType = Schema.types(this.schema)._id; const isObjectIdType = MonarchType.isInstanceOf(_idType, MonarchObjectId); @@ -180,7 +182,7 @@ export class Collection, update: UpdateFilter | Document[]) { + public findOneAndUpdate(filter: Filter, update: UpdateFilter) { return new FindOneAndUpdateQuery(this.schema, this.collection, this.readyPromise, filter, update); } @@ -242,7 +244,7 @@ export class Collection, update: UpdateFilter | Document[]) { + public updateOne(filter: Filter, update: UpdateFilter) { return new UpdateOneQuery(this.schema, this.collection, this.readyPromise, filter, update); } @@ -253,7 +255,7 @@ export class Collection, update: UpdateFilter | Document[]) { + public updateMany(filter: Filter, update: UpdateFilter) { return new UpdateManyQuery(this.schema, this.collection, this.readyPromise, filter, update); } @@ -282,8 +284,8 @@ export class Collection() { - return new AggregationPipeline(this.schema, this.collection, this.readyPromise); + public aggregate(pipeline: PipelineStage>[] = []) { + return new AggregationPipeline(this.schema, this.collection, this.readyPromise, {}, pipeline); } /** @@ -293,7 +295,7 @@ export class Collection = {}, options?: CountDocumentsOptions) { + public async countDocuments(filter: Filter = {}, options?: CountDocumentsOptions & Abortable) { return await this.collection.countDocuments(filter as MongoFilter>, options); } diff --git a/src/collection/pipeline/aggregation.ts b/src/collection/pipeline/aggregation.ts index adeaa50..3d9a8ea 100644 --- a/src/collection/pipeline/aggregation.ts +++ b/src/collection/pipeline/aggregation.ts @@ -1,30 +1,55 @@ -import type { AggregateOptions, AggregationCursor, Collection as MongoCollection } from "mongodb"; +import type { Abortable, AggregateOptions, AggregationCursor, Document, Collection as MongoCollection } from "mongodb"; import type { AnySchema } from "../../schema/schema"; import type { InferSchemaData } from "../../schema/type-helpers"; +import type { PipelineStage } from "../types/pipeline-stage"; import { Pipeline } from "./base"; +export type AggregationPipelineOptions = AggregateOptions & Abortable; + /** * Collection.aggregate(). */ -export class AggregationPipeline extends Pipeline { +export class AggregationPipeline extends Pipeline< + TSchema, + TOutput +> { constructor( schema: TSchema, collection: MongoCollection>, readyPromise: Promise, private _options: AggregateOptions = {}, + pipeline: PipelineStage>[] = [], ) { - super(schema, collection, readyPromise); + super(schema, collection, readyPromise, pipeline); + } + + /** + * Appends aggregation pipeline stage. + * + * @param stage - Pipeline stage + * @returns AggregationPipeline instance + */ + public addStage(stage: PipelineStage>): this { + return new AggregationPipeline(this.schema, this.collection, this.readyPromise, this._options, [ + ...this.pipeline, + stage, + ]) as this; } /** * Adds aggregation options. Options are merged into existing options. * - * @param options - AggregateOptions + * @param options - AggregationPipelineOptions * @returns AggregationPipeline instance */ - public options(options: AggregateOptions): this { - Object.assign(this._options, options); - return this; + public options(options: AggregationPipelineOptions): this { + return new AggregationPipeline( + this.schema, + this.collection, + this.readyPromise, + { ...this._options, ...options }, + this.pipeline, + ) as this; } /** diff --git a/src/collection/pipeline/base.ts b/src/collection/pipeline/base.ts index e46362c..fb00fb3 100644 --- a/src/collection/pipeline/base.ts +++ b/src/collection/pipeline/base.ts @@ -1,4 +1,4 @@ -import type { Collection as MongoCollection } from "mongodb"; +import type { Document, Collection as MongoCollection } from "mongodb"; import type { AnySchema } from "../../schema/schema"; import type { InferSchemaData } from "../../schema/type-helpers"; import type { PipelineStage } from "../types/pipeline-stage"; @@ -6,7 +6,7 @@ import type { PipelineStage } from "../types/pipeline-stage"; /** * Base aggregation pipeline class implementing thenable interface. */ -export abstract class Pipeline { +export abstract class Pipeline { constructor( protected schema: TSchema, protected collection: MongoCollection>, @@ -14,17 +14,6 @@ export abstract class Pipeline { protected pipeline: PipelineStage>[] = [], ) {} - /** - * Appends aggregation pipeline stage. - * - * @param stage - Pipeline stage - * @returns Pipeline instance - */ - public addStage(stage: PipelineStage>): this { - this.pipeline.push(stage); - return this; - } - protected abstract exec(): Promise; public async then( diff --git a/src/collection/query/bulk-write.ts b/src/collection/query/bulk-write.ts index e0e911d..89d3907 100644 --- a/src/collection/query/bulk-write.ts +++ b/src/collection/query/bulk-write.ts @@ -3,20 +3,24 @@ import type { AnySchema } from "../../schema/schema"; import type { InferSchemaData } from "../../schema/type-helpers"; import { Query } from "./base"; +export type BulkWriteQueryOptions = BulkWriteOptions; + export class BulkWriteQuery extends Query { constructor( schema: TSchema, collection: MongoCollection>, readyPromise: Promise, private _data: AnyBulkWriteOperation>[], - private _options: BulkWriteOptions = {}, + private _options: BulkWriteQueryOptions = {}, ) { super(schema, collection, readyPromise); } - public options(options: BulkWriteOptions): this { - Object.assign(this._options, options); - return this; + public options(options: BulkWriteQueryOptions): this { + return new BulkWriteQuery(this.schema, this.collection, this.readyPromise, this._data, { + ...this._options, + ...options, + }) as this; } protected async exec(): Promise { diff --git a/src/collection/query/delete-many.ts b/src/collection/query/delete-many.ts index 3de6b16..fe81667 100644 --- a/src/collection/query/delete-many.ts +++ b/src/collection/query/delete-many.ts @@ -3,6 +3,8 @@ import type { AnySchema } from "../../schema/schema"; import type { Filter, InferSchemaData } from "../../schema/type-helpers"; import { Query } from "./base"; +export type DeleteManyQueryOptions = DeleteOptions; + /** * Collection.deleteMany(). */ @@ -12,7 +14,7 @@ export class DeleteManyQuery extends Query>, readyPromise: Promise, private _filter: Filter, - private _options: DeleteOptions = {}, + private _options: DeleteManyQueryOptions = {}, ) { super(schema, collection, readyPromise); } @@ -20,12 +22,14 @@ export class DeleteManyQuery extends Query { diff --git a/src/collection/query/delete-one.ts b/src/collection/query/delete-one.ts index e93c583..db874dd 100644 --- a/src/collection/query/delete-one.ts +++ b/src/collection/query/delete-one.ts @@ -3,6 +3,8 @@ import type { AnySchema } from "../../schema/schema"; import type { Filter, InferSchemaData } from "../../schema/type-helpers"; import { Query } from "./base"; +export type DeleteOneQueryOptions = DeleteOptions; + /** * Collection.deleteOne(). */ @@ -12,7 +14,7 @@ export class DeleteOneQuery extends Query>, readyPromise: Promise, private _filter: Filter, - private _options: DeleteOptions = {}, + private _options: DeleteOneQueryOptions = {}, ) { super(schema, collection, readyPromise); } @@ -20,12 +22,14 @@ export class DeleteOneQuery extends Query { diff --git a/src/collection/query/distinct.ts b/src/collection/query/distinct.ts index 3d56469..7c2bde2 100644 --- a/src/collection/query/distinct.ts +++ b/src/collection/query/distinct.ts @@ -3,6 +3,8 @@ import type { AnySchema } from "../../schema/schema"; import type { DistinctFilter, Filter, InferSchemaData } from "../../schema/type-helpers"; import { Query } from "./base"; +export type DistinctQueryOptions = DistinctOptions; + export class DistinctQuery< TSchema extends AnySchema, Key extends keyof DistinctFilter, @@ -14,14 +16,16 @@ export class DistinctQuery< readyPromise: Promise, private _filter: Filter, private _key: Key, - private _options: DistinctOptions = {}, + private _options: DistinctQueryOptions = {}, ) { super(schema, collection, readyPromise); } - public options(options: DistinctOptions): this { - Object.assign(this._options, options); - return this; + public options(options: DistinctQueryOptions): this { + return new DistinctQuery(this.schema, this.collection, this.readyPromise, this._filter, this._key, { + ...this._options, + ...options, + }) as this; } protected async exec(): Promise { diff --git a/src/collection/query/find-one-and-delete.ts b/src/collection/query/find-one-and-delete.ts index 662bb54..1de77e7 100644 --- a/src/collection/query/find-one-and-delete.ts +++ b/src/collection/query/find-one-and-delete.ts @@ -6,6 +6,8 @@ import { addExtraInputsToProjection, makeProjection } from "../projection"; import type { BoolProjection, Projection } from "../types/query-options"; import { Query, type QueryOutput } from "./base"; +export type FindOneAndDeleteQueryOptions = Omit; + /** * Collection.findOneAndDelete(). */ @@ -30,12 +32,16 @@ export class FindOneAndDeleteQuery< /** * Adds delete options. Options are merged into existing options. * - * @param options - FindOneAndDeleteOptions + * @param options - FindOneAndDeleteQueryOptions * @returns FindOneAndDeleteQuery instance */ - public options(options: FindOneAndDeleteOptions): this { - Object.assign(this._options, options); - return this; + public options(options: FindOneAndDeleteQueryOptions): this { + const query = new FindOneAndDeleteQuery(this.schema, this.collection, this.readyPromise, this._filter, { + ...this._options, + ...options, + }); + query._projection = this._projection; + return query as this; } /** @@ -45,8 +51,15 @@ export class FindOneAndDeleteQuery< * @returns FindOneAndDeleteQuery instance */ public omit>>(projection: TProjection) { - this._projection = makeProjection("omit", projection); - return this as FindOneAndDeleteQuery]>; + const query = new FindOneAndDeleteQuery( + this.schema, + this.collection, + this.readyPromise, + this._filter, + this._options, + ); + query._projection = makeProjection("omit", projection); + return query as FindOneAndDeleteQuery]>; } /** @@ -56,8 +69,15 @@ export class FindOneAndDeleteQuery< * @returns FindOneAndDeleteQuery instance */ public select>>(projection: TProjection) { - this._projection = makeProjection("select", projection); - return this as FindOneAndDeleteQuery]>; + const query = new FindOneAndDeleteQuery( + this.schema, + this.collection, + this.readyPromise, + this._filter, + this._options, + ); + query._projection = makeProjection("select", projection); + return query as FindOneAndDeleteQuery]>; } protected async exec(): Promise | null> { diff --git a/src/collection/query/find-one-and-replace.ts b/src/collection/query/find-one-and-replace.ts index d8a5528..0908b02 100644 --- a/src/collection/query/find-one-and-replace.ts +++ b/src/collection/query/find-one-and-replace.ts @@ -17,6 +17,8 @@ import { addExtraInputsToProjection, makeProjection } from "../projection"; import type { BoolProjection, Projection } from "../types/query-options"; import { Query, type QueryOutput } from "./base"; +export type FindOneAndReplaceQueryOptions = Omit; + /** * Collection.findOneAndReplace(). */ @@ -42,12 +44,20 @@ export class FindOneAndReplaceQuery< /** * Adds replace options. Options are merged into existing options. * - * @param options - FindOneAndReplaceOptions + * @param options - FindOneAndReplaceQueryOptions * @returns FindOneAndReplaceQuery instance */ - public options(options: FindOneAndReplaceOptions): this { - Object.assign(this._options, options); - return this; + public options(options: FindOneAndReplaceQueryOptions): this { + const query = new FindOneAndReplaceQuery( + this.schema, + this.collection, + this.readyPromise, + this._filter, + this._replacement, + { ...this._options, ...options }, + ); + query._projection = this._projection; + return query as this; } /** @@ -57,8 +67,16 @@ export class FindOneAndReplaceQuery< * @returns FindOneAndReplaceQuery instance */ public omit>>(projection: TProjection) { - this._projection = makeProjection("omit", projection); - return this as FindOneAndReplaceQuery]>; + const query = new FindOneAndReplaceQuery( + this.schema, + this.collection, + this.readyPromise, + this._filter, + this._replacement, + this._options, + ); + query._projection = makeProjection("omit", projection); + return query as FindOneAndReplaceQuery]>; } /** @@ -68,8 +86,16 @@ export class FindOneAndReplaceQuery< * @returns FindOneAndReplaceQuery instance */ public select>>(projection: TProjection) { - this._projection = makeProjection("select", projection); - return this as FindOneAndReplaceQuery]>; + const query = new FindOneAndReplaceQuery( + this.schema, + this.collection, + this.readyPromise, + this._filter, + this._replacement, + this._options, + ); + query._projection = makeProjection("select", projection); + return query as FindOneAndReplaceQuery]>; } protected async exec(): Promise | null> { diff --git a/src/collection/query/find-one-and-update.ts b/src/collection/query/find-one-and-update.ts index d421b22..138c0c3 100644 --- a/src/collection/query/find-one-and-update.ts +++ b/src/collection/query/find-one-and-update.ts @@ -1,8 +1,8 @@ import type { - Document, FindOneAndUpdateOptions, Collection as MongoCollection, Filter as MongoFilter, + Sort as MongoSort, UpdateFilter as MongoUpdateFilter, } from "mongodb"; import { type AnySchema, Schema } from "../../schema/schema"; @@ -15,9 +15,11 @@ import type { } from "../../schema/type-helpers"; import type { TrueKeys } from "../../utils/type-helpers"; import { addExtraInputsToProjection, makeProjection } from "../projection"; -import type { BoolProjection, Projection } from "../types/query-options"; +import type { BoolProjection, Projection, Sort } from "../types/query-options"; import { Query, type QueryOutput } from "./base"; +export type FindOneAndUpdateQueryOptions = Omit; + /** * Collection.findOneAndUpdate(). */ @@ -33,7 +35,7 @@ export class FindOneAndUpdateQuery< collection: MongoCollection>, readyPromise: Promise, private _filter: Filter, - private _update: UpdateFilter | Document[], + private _update: UpdateFilter, private _options: FindOneAndUpdateOptions = {}, ) { super(schema, collection, readyPromise); @@ -43,12 +45,42 @@ export class FindOneAndUpdateQuery< /** * Adds update options. Options are merged into existing options. * - * @param options - FindOneAndUpdateOptions + * @param options - FindOneAndUpdateQueryOptions + * @returns FindOneAndUpdateQuery instance + */ + public options(options: FindOneAndUpdateQueryOptions): this { + const query = new FindOneAndUpdateQuery( + this.schema, + this.collection, + this.readyPromise, + this._filter, + this._update, + { + ...this._options, + ...options, + }, + ); + query._projection = this._projection; + return query as this; + } + + /** + * Sets sort order to determine which document is updated when multiple match. + * + * @param sort - Sort specification * @returns FindOneAndUpdateQuery instance */ - public options(options: FindOneAndUpdateOptions): this { - Object.assign(this._options, options); - return this; + public sort(sort: Sort>): this { + const query = new FindOneAndUpdateQuery( + this.schema, + this.collection, + this.readyPromise, + this._filter, + this._update, + { ...this._options, sort: sort as MongoSort }, + ); + query._projection = this._projection; + return query as this; } /** @@ -58,8 +90,16 @@ export class FindOneAndUpdateQuery< * @returns FindOneAndUpdateQuery instance */ public omit>>(projection: TProjection) { - this._projection = makeProjection("omit", projection); - return this as FindOneAndUpdateQuery]>; + const query = new FindOneAndUpdateQuery( + this.schema, + this.collection, + this.readyPromise, + this._filter, + this._update, + this._options, + ); + query._projection = makeProjection("omit", projection); + return query as FindOneAndUpdateQuery]>; } /** @@ -69,14 +109,20 @@ export class FindOneAndUpdateQuery< * @returns FindOneAndUpdateQuery instance */ public select>>(projection: TProjection) { - this._projection = makeProjection("select", projection); - return this as FindOneAndUpdateQuery]>; + const query = new FindOneAndUpdateQuery( + this.schema, + this.collection, + this.readyPromise, + this._filter, + this._update, + this._options, + ); + query._projection = makeProjection("select", projection); + return query as FindOneAndUpdateQuery]>; } protected async exec(): Promise | null> { - const update = Array.isArray(this._update) - ? this._update - : Schema.updateInput(this.schema, this._update, this._options.upsert ?? false); + const update = Schema.updateInput(this.schema, this._update, this._options.upsert ?? false); const extras = addExtraInputsToProjection(this._projection, Schema.options(this.schema).virtuals); const res = await this.collection.findOneAndUpdate( diff --git a/src/collection/query/find-one.ts b/src/collection/query/find-one.ts index bcb31cb..ab07c2a 100644 --- a/src/collection/query/find-one.ts +++ b/src/collection/query/find-one.ts @@ -1,4 +1,4 @@ -import type { FindOptions, Collection as MongoCollection, Filter as MongoFilter } from "mongodb"; +import type { Abortable, FindOptions, Collection as MongoCollection, Filter as MongoFilter } from "mongodb"; import type { AnyRelation } from "../../relations/relations"; import type { InferRelationObjectPopulation, Population } from "../../relations/type-helpers"; import { type AnySchema, Schema } from "../../schema/schema"; @@ -10,6 +10,8 @@ import type { PipelineStage } from "../types/pipeline-stage"; import type { BoolProjection, Projection } from "../types/query-options"; import { Query, type QueryOutput } from "./base"; +export type FindOneQueryOptions = Omit & Abortable; + /** * Collection.findOne(). */ @@ -38,12 +40,17 @@ export class FindOneQuery< /** * Adds find options. Options are merged into existing options. * - * @param options - FindOptions + * @param options - FindOneQueryOptions * @returns FindOneQuery instance */ - public options(options: FindOptions): this { - Object.assign(this._options, options); - return this; + public options(options: FindOneQueryOptions): this { + const query = new FindOneQuery(this.schema, this.collection, this.readyPromise, this._relations, this._filter, { + ...this._options, + ...options, + }); + query._projection = this._projection; + query._population = this._population; + return query as this; } /** @@ -53,8 +60,17 @@ export class FindOneQuery< * @returns FindOneQuery instance */ public omit>>(projection: TProjection) { - this._projection = makeProjection("omit", projection); - return this as FindOneQuery]>; + const query = new FindOneQuery( + this.schema, + this.collection, + this.readyPromise, + this._relations, + this._filter, + this._options, + ); + query._projection = makeProjection("omit", projection); + query._population = this._population; + return query as FindOneQuery]>; } /** @@ -64,8 +80,17 @@ export class FindOneQuery< * @returns FindOneQuery instance */ public select>>(projection: TProjection) { - this._projection = makeProjection("select", projection); - return this as FindOneQuery]>; + const query = new FindOneQuery( + this.schema, + this.collection, + this.readyPromise, + this._relations, + this._filter, + this._options, + ); + query._projection = makeProjection("select", projection); + query._population = this._population; + return query as FindOneQuery]>; } /** @@ -75,8 +100,17 @@ export class FindOneQuery< * @returns FindOneQuery instance */ public populate>(population: TPopulation) { - this._population = population; - return this as FindOneQuery< + const query = new FindOneQuery( + this.schema, + this.collection, + this.readyPromise, + this._relations, + this._filter, + this._options, + ); + query._projection = this._projection; + query._population = population; + return query as FindOneQuery< TSchema, TRelations, InferRelationObjectPopulation, @@ -126,7 +160,6 @@ export class FindOneQuery< }); addPipelineMetas(pipeline, { - limit: this._options.limit, skip: this._options.skip, sort: getSortDirection(this._options.sort), }); diff --git a/src/collection/query/find.ts b/src/collection/query/find.ts index f3df2c4..2457e9b 100644 --- a/src/collection/query/find.ts +++ b/src/collection/query/find.ts @@ -1,4 +1,5 @@ import type { + Abortable, AbstractCursor, FindOptions, Collection as MongoCollection, @@ -16,6 +17,8 @@ import type { PipelineStage } from "../types/pipeline-stage"; import type { BoolProjection, Projection, Sort } from "../types/query-options"; import { Query, type QueryOutput } from "./base"; +export type FindQueryOptions = Omit & Abortable; + /** * Collection.find(). */ @@ -44,12 +47,17 @@ export class FindQuery< /** * Adds find options. Options are merged into existing options. * - * @param options - FindOptions + * @param options - FindQueryOptions * @returns FindQuery instance */ - public options(options: FindOptions): this { - Object.assign(this._options, options); - return this; + public options(options: FindQueryOptions): this { + const query = new FindQuery(this.schema, this.collection, this.readyPromise, this._relations, this._filter, { + ...this._options, + ...options, + }); + query._projection = this._projection; + query._population = this._population; + return query as unknown as this; } /** @@ -58,9 +66,14 @@ export class FindQuery< * @param sort - Sort specification * @returns FindQuery instance */ - public sort(sort: Sort>): this { - this._options.sort = sort as MongoSort; - return this; + public sort(sort: Sort>): this { + const query = new FindQuery(this.schema, this.collection, this.readyPromise, this._relations, this._filter, { + ...this._options, + sort: sort as MongoSort, + }); + query._projection = this._projection; + query._population = this._population; + return query as unknown as this; } /** @@ -70,8 +83,13 @@ export class FindQuery< * @returns FindQuery instance */ public limit(limit: number): this { - this._options.limit = limit; - return this; + const query = new FindQuery(this.schema, this.collection, this.readyPromise, this._relations, this._filter, { + ...this._options, + limit, + }); + query._projection = this._projection; + query._population = this._population; + return query as unknown as this; } /** @@ -81,8 +99,13 @@ export class FindQuery< * @returns FindQuery instance */ public skip(skip: number): this { - this._options.skip = skip; - return this; + const query = new FindQuery(this.schema, this.collection, this.readyPromise, this._relations, this._filter, { + ...this._options, + skip, + }); + query._projection = this._projection; + query._population = this._population; + return query as unknown as this; } /** @@ -92,8 +115,17 @@ export class FindQuery< * @returns FindQuery instance */ public omit>>(projection: TProjection) { - this._projection = makeProjection("omit", projection); - return this as FindQuery]>; + const query = new FindQuery( + this.schema, + this.collection, + this.readyPromise, + this._relations, + this._filter, + this._options, + ); + query._projection = makeProjection("omit", projection); + query._population = this._population; + return query as unknown as FindQuery]>; } /** @@ -103,8 +135,17 @@ export class FindQuery< * @returns FindQuery instance */ public select>>(projection: TProjection) { - this._projection = makeProjection("select", projection); - return this as FindQuery]>; + const query = new FindQuery( + this.schema, + this.collection, + this.readyPromise, + this._relations, + this._filter, + this._options, + ); + query._projection = makeProjection("select", projection); + query._population = this._population; + return query as unknown as FindQuery]>; } /** @@ -114,8 +155,17 @@ export class FindQuery< * @returns FindQuery instance */ public populate>(population: TPopulation) { - this._population = population; - return this as FindQuery< + const query = new FindQuery( + this.schema, + this.collection, + this.readyPromise, + this._relations, + this._filter, + this._options, + ); + query._projection = this._projection; + query._population = population; + return query as unknown as FindQuery< TSchema, TRelations, InferRelationObjectPopulation, diff --git a/src/collection/query/insert-many.ts b/src/collection/query/insert-many.ts index 6fbe365..e0ebc99 100644 --- a/src/collection/query/insert-many.ts +++ b/src/collection/query/insert-many.ts @@ -8,6 +8,8 @@ import { type AnySchema, Schema } from "../../schema/schema"; import type { InferSchemaData, InferSchemaInput } from "../../schema/type-helpers"; import { Query } from "./base"; +export type InsertManyQueryOptions = BulkWriteOptions; + /** * Collection.insertMany(). */ @@ -20,7 +22,7 @@ export class InsertManyQuery extends Query< collection: MongoCollection>, readyPromise: Promise, private _data: InferSchemaInput[], - private _options: BulkWriteOptions = {}, + private _options: InsertManyQueryOptions = {}, ) { super(schema, collection, readyPromise); } @@ -28,12 +30,14 @@ export class InsertManyQuery extends Query< /** * Adds insert options. Options are merged into existing options. * - * @param options - BulkWriteOptions + * @param options - InsertManyQueryOptions * @returns InsertManyQuery instance */ - public options(options: BulkWriteOptions): this { - Object.assign(this._options, options); - return this; + public options(options: InsertManyQueryOptions): this { + return new InsertManyQuery(this.schema, this.collection, this.readyPromise, this._data, { + ...this._options, + ...options, + }) as this; } protected async exec(): Promise>> { diff --git a/src/collection/query/insert-one.ts b/src/collection/query/insert-one.ts index e06d84b..d6dbaaa 100644 --- a/src/collection/query/insert-one.ts +++ b/src/collection/query/insert-one.ts @@ -5,6 +5,8 @@ import { makeProjection } from "../projection"; import type { Projection } from "../types/query-options"; import { Query, type QueryOutput } from "./base"; +export type InsertOneQueryOptions = InsertOneOptions; + /** * Collection.insertOne(). */ @@ -20,7 +22,7 @@ export class InsertOneQuery< collection: MongoCollection>, readyPromise: Promise, private _data: InferSchemaInput, - private _options: InsertOneOptions = {}, + private _options: InsertOneQueryOptions = {}, ) { super(schema, collection, readyPromise); this._projection = makeProjection("omit", Schema.options(schema).omit ?? {}); @@ -29,12 +31,16 @@ export class InsertOneQuery< /** * Adds insert options. Options are merged into existing options. * - * @param options - InsertOneOptions + * @param options - InsertOneQueryOptions * @returns InsertOneQuery instance */ - public options(options: InsertOneOptions): this { - Object.assign(this._options, options); - return this; + public options(options: InsertOneQueryOptions): this { + const query = new InsertOneQuery(this.schema, this.collection, this.readyPromise, this._data, { + ...this._options, + ...options, + }); + query._projection = this._projection; + return query as this; } protected async exec(): Promise> { diff --git a/src/collection/query/replace-one.ts b/src/collection/query/replace-one.ts index 545ebc7..69a5825 100644 --- a/src/collection/query/replace-one.ts +++ b/src/collection/query/replace-one.ts @@ -9,6 +9,8 @@ import { Schema, type AnySchema } from "../../schema/schema"; import type { Filter, InferSchemaData, InferSchemaInput } from "../../schema/type-helpers"; import { Query } from "./base"; +export type ReplaceOneQueryOptions = ReplaceOptions; + /** * Collection.replaceOne(). */ @@ -19,7 +21,7 @@ export class ReplaceOneQuery extends Query, private _filter: Filter, private _replacement: WithoutId>, - private _options: ReplaceOptions = {}, + private _options: ReplaceOneQueryOptions = {}, ) { super(schema, collection, readyPromise); } @@ -27,12 +29,14 @@ export class ReplaceOneQuery extends Query>> { diff --git a/src/collection/query/update-many.ts b/src/collection/query/update-many.ts index 17fa03b..3fb76bd 100644 --- a/src/collection/query/update-many.ts +++ b/src/collection/query/update-many.ts @@ -1,5 +1,4 @@ import type { - Document, Collection as MongoCollection, Filter as MongoFilter, UpdateFilter as MongoUpdateFilter, @@ -10,6 +9,8 @@ import { type AnySchema, Schema } from "../../schema/schema"; import type { Filter, InferSchemaData, UpdateFilter } from "../../schema/type-helpers"; import { Query } from "./base"; +export type UpdateManyQueryOptions = UpdateOptions; + /** * Collection.updateMany(). */ @@ -19,8 +20,8 @@ export class UpdateManyQuery extends Query>, readyPromise: Promise, private _filter: Filter, - private _update: UpdateFilter | Document[], - private _options: UpdateOptions = {}, + private _update: UpdateFilter, + private _options: UpdateManyQueryOptions = {}, ) { super(schema, collection, readyPromise); } @@ -28,18 +29,18 @@ export class UpdateManyQuery extends Query>> { - const update = Array.isArray(this._update) - ? this._update - : Schema.updateInput(this.schema, this._update, this._options.upsert ?? false); + const update = Schema.updateInput(this.schema, this._update, this._options.upsert ?? false); const res = await this.collection.updateMany( this._filter as MongoFilter>, diff --git a/src/collection/query/update-one.ts b/src/collection/query/update-one.ts index 631d8f7..930ef27 100644 --- a/src/collection/query/update-one.ts +++ b/src/collection/query/update-one.ts @@ -1,15 +1,18 @@ import type { - Document, Collection as MongoCollection, Filter as MongoFilter, + Sort as MongoSort, UpdateFilter as MongoUpdateFilter, UpdateOptions, UpdateResult, } from "mongodb"; import { type AnySchema, Schema } from "../../schema/schema"; import type { Filter, InferSchemaData, UpdateFilter } from "../../schema/type-helpers"; +import type { Sort } from "../types/query-options"; import { Query } from "./base"; +export type UpdateOneQueryOptions = UpdateOptions & { sort?: MongoSort }; + /** * Collection.updateOne(). */ @@ -19,8 +22,8 @@ export class UpdateOneQuery extends Query>, readyPromise: Promise, private _filter: Filter, - private _update: UpdateFilter | Document[], - private _options: UpdateOptions = {}, + private _update: UpdateFilter, + private _options: UpdateOneQueryOptions = {}, ) { super(schema, collection, readyPromise); } @@ -28,18 +31,31 @@ export class UpdateOneQuery extends Query>): this { + return new UpdateOneQuery(this.schema, this.collection, this.readyPromise, this._filter, this._update, { + ...this._options, + sort: sort as MongoSort, + }) as this; } protected async exec(): Promise>> { - const update = Array.isArray(this._update) - ? this._update - : Schema.updateInput(this.schema, this._update, this._options.upsert ?? false); + const update = Schema.updateInput(this.schema, this._update, this._options.upsert ?? false); const res = await this.collection.updateOne( this._filter as MongoFilter>, diff --git a/src/collection/types/pipeline-stage.ts b/src/collection/types/pipeline-stage.ts index 0e242c8..4a874cc 100644 --- a/src/collection/types/pipeline-stage.ts +++ b/src/collection/types/pipeline-stage.ts @@ -5,6 +5,7 @@ // The following types and interfaces are derived from Mongoose's aggregate pipeline stages // with modifications to fit our project's needs. +import type { Document } from "mongodb"; import type { AccumulatorOperator, AnyExpression, @@ -16,7 +17,7 @@ import type { WindowOperator, } from "./expressions"; -export type PipelineStage = +export type PipelineStage = | AddFields | Bucket | BucketAuto diff --git a/src/index.ts b/src/index.ts index f4e4759..a30efce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,22 @@ import { uuid } from "./types/uuid"; export { ObjectId } from "mongodb"; export { Collection } from "./collection/collection"; +export type { AggregationPipelineOptions } from "./collection/pipeline/aggregation"; +export type { BulkWriteQueryOptions } from "./collection/query/bulk-write"; +export type { DeleteManyQueryOptions } from "./collection/query/delete-many"; +export type { DeleteOneQueryOptions } from "./collection/query/delete-one"; +export type { DistinctQueryOptions } from "./collection/query/distinct"; +export type { FindQueryOptions } from "./collection/query/find"; +export type { FindOneQueryOptions } from "./collection/query/find-one"; +export type { FindOneAndDeleteQueryOptions } from "./collection/query/find-one-and-delete"; +export type { FindOneAndReplaceQueryOptions } from "./collection/query/find-one-and-replace"; +export type { FindOneAndUpdateQueryOptions } from "./collection/query/find-one-and-update"; +export type { InsertManyQueryOptions } from "./collection/query/insert-many"; +export type { InsertOneQueryOptions } from "./collection/query/insert-one"; +export type { ReplaceOneQueryOptions } from "./collection/query/replace-one"; +export type { UpdateManyQueryOptions } from "./collection/query/update-many"; +export type { UpdateOneQueryOptions } from "./collection/query/update-one"; +export type { PipelineStage } from "./collection/types/pipeline-stage"; export { createClient, createDatabase, Database, type DatabaseOptions } from "./database"; export { MonarchError, MonarchParseError } from "./errors"; export { diff --git a/src/relations/relations.ts b/src/relations/relations.ts index edb81d5..74dc93d 100644 --- a/src/relations/relations.ts +++ b/src/relations/relations.ts @@ -1,6 +1,6 @@ import type { ObjectId } from "mongodb"; import { type AnySchema } from "../schema/schema"; -import type { InferSchemaData, InferSchemaInput } from "../schema/type-helpers"; +import type { InferSchemaData } from "../schema/type-helpers"; import type { ExtractIfArray, Index, Merge, MergeN1, OrArray } from "../utils/type-helpers"; import type { PopulationOptions, RelationPopulationOptions } from "./type-helpers"; @@ -27,9 +27,12 @@ export class Relation< ) {} public options>(options: TOptions) { - const relation = this as unknown as Relation; - relation._options = options; - return relation; + return new Relation( + this.relation, + this.schemaField, + this.targetField, + options, + ); } public static options(relation: T): PopulationOptions | undefined { @@ -115,13 +118,13 @@ type RelationsBuilder< >; type SchemaFields = { - [K in keyof InferSchemaInput as NonNullable[K]> extends + [K in keyof InferSchemaData as Exclude[K], null | undefined> extends | string | number | ObjectId | Array ? K - : never]-?: RelationField[K]>, T>; + : never]-?: RelationField[K], null | undefined>, T>; }; type SchemaOne< @@ -164,8 +167,14 @@ type RelationFn< // target field type must match schema field type // one does not accept array fields TRelation extends "many" - ? OrArray, TSchemaField["field"]>>>> - : ExtractIfArray, TSchemaField["field"]>>>, + ? OrArray< + ExtractIfArray< + Exclude, TSchemaField["field"]>, null | undefined> + > + > + : ExtractIfArray< + Exclude, TSchemaField["field"]>, null | undefined> + >, TTarget >, >(fields: { diff --git a/tests/query/aggregate.test.ts b/tests/query/aggregate.test.ts index 8f872cd..59be6d3 100644 --- a/tests/query/aggregate.test.ts +++ b/tests/query/aggregate.test.ts @@ -43,8 +43,54 @@ describe("Aggregation Operations", async () => { expect(result.length).toBeGreaterThanOrEqual(1); }); - it("executes raw MongoDB operations", async () => { - const result = await collections.users.raw().find().toArray(); + it("accepts an initial pipeline", async () => { + await collections.users.insertMany(mockUsers); + const verifiedCount = mockUsers.filter((u) => u.isVerified).length; + const result = await collections.users.aggregate([{ $match: { isVerified: true } }]); + expect(result.length).toBe(verifiedCount); + }); + + it("extends an initial pipeline with addStage()", async () => { + await collections.users.insertMany(mockUsers); + const result = await collections.users + .aggregate([{ $match: { isVerified: true } }]) + .addStage({ $group: { _id: "$isVerified", count: { $sum: 1 } } }); + expect(result.length).toBe(1); + }); + + it("executes raw MongoDB aggregate", async () => { + await collections.users.insertMany(mockUsers); + const result = await collections.users + .raw() + .aggregate([{ $match: { isVerified: true } }]) + .toArray(); expect(result).toBeInstanceOf(Array); + expect(result.length).toBe(mockUsers.filter((u) => u.isVerified).length); + }); + + describe("immutability", () => { + it("addStage() returns a new instance and does not affect base pipeline", async () => { + await collections.users.insertMany(mockUsers); + + const verifiedCount = mockUsers.filter((u) => u.isVerified).length; + const base = collections.users.aggregate(); + const withMatch = base.addStage({ $match: { isVerified: true } }); + + expect(withMatch).not.toBe(base); + expect((await base).length).toBe(mockUsers.length); + expect((await withMatch).length).toBe(verifiedCount); + }); + + it("chained addStage() calls are independent from base", async () => { + await collections.users.insertMany(mockUsers); + + const base = collections.users.aggregate(); + const withMatch = base.addStage({ $match: { isVerified: true } }); + const withLimit = base.addStage({ $limit: 1 }); + + expect((await base).length).toBe(mockUsers.length); + expect((await withMatch).length).toBe(mockUsers.filter((u) => u.isVerified).length); + expect((await withLimit).length).toBe(1); + }); }); }); diff --git a/tests/query/query-methods.test.ts b/tests/query/query-methods.test.ts index d17c274..37a420f 100644 --- a/tests/query/query-methods.test.ts +++ b/tests/query/query-methods.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { createDatabase, createSchema, defineSchemas } from "../../src"; -import { boolean, number, string } from "../../src/types"; +import { boolean, number, objectId, string } from "../../src/types"; import { createMockDatabase, mockUsers } from "../mock"; describe("Query Methods", async () => { @@ -104,4 +104,152 @@ describe("Query Methods", async () => { expect(users[1].email).toBe("anon2@gmail.com"); expect(users[2].email).toBe("anon@gmail.com"); }); + + it("projection passed to options() has no effect on find()", async () => { + await collections.users.insertMany(mockUsers); + + const users = await collections.users.find().options({ + // @ts-expect-error + projection: { name: 0, email: 0 }, + }); + expect(users[0].name).toBeDefined(); + expect(users[0].email).toBeDefined(); + }); + + it("projection passed to options() has no effect on findOne()", async () => { + await collections.users.insertMany(mockUsers); + + const user = await collections.users.findOne({ name: "anon" }).options({ + // @ts-expect-error + projection: { name: 0, email: 0 }, + }); + expect(user?.name).toBe("anon"); + }); + + describe("immutability", () => { + describe("find()", () => { + it("limit() returns a new instance and does not affect base query", async () => { + await collections.users.insertMany(mockUsers); + + const base = collections.users.find(); + const limited = base.limit(1); + + expect(limited).not.toBe(base); + expect((await base).length).toBe(mockUsers.length); + expect((await limited).length).toBe(1); + }); + + it("skip() returns a new instance and does not affect base query", async () => { + await collections.users.insertMany(mockUsers); + + const base = collections.users.find(); + const skipped = base.skip(2); + + expect(skipped).not.toBe(base); + expect((await base).length).toBe(mockUsers.length); + expect((await skipped).length).toBe(mockUsers.length - 2); + }); + + it("sort() returns a new instance and does not affect base query", async () => { + await collections.users.insertMany(mockUsers); + + const base = collections.users.find().limit(1); + const sorted = base.sort({ age: -1 }); + + expect(sorted).not.toBe(base); + expect((await sorted)[0].age).toBe(25); + // base has no sort — insertion-order first, not 25 + expect((await base)[0].age).not.toBe(25); + }); + + it("select() returns a new instance and does not affect base query", async () => { + await collections.users.insertMany(mockUsers); + + const base = collections.users.find(); + const selected = base.select({ name: true }); + + expect(selected).not.toBe(base); + expect((await base)[0].age).toBeDefined(); + // @ts-expect-error + expect((await selected)[0].age).toBeUndefined(); + }); + + it("omit() returns a new instance and does not affect base query", async () => { + await collections.users.insertMany(mockUsers); + + const base = collections.users.find(); + const omitted = base.omit({ age: true }); + + expect(omitted).not.toBe(base); + expect((await base)[0].age).toBeDefined(); + // @ts-expect-error + expect((await omitted)[0].age).toBeUndefined(); + }); + + it("chained branching preserves independence", async () => { + await collections.users.insertMany(mockUsers); + + const base = collections.users.find(); + const limited = base.limit(1); + const skipped = base.skip(1); + + expect((await limited).length).toBe(1); + expect((await skipped).length).toBe(mockUsers.length - 1); + expect((await base).length).toBe(mockUsers.length); + }); + }); + + describe("findOne()", () => { + it("select() returns a new instance and does not affect base query", async () => { + await collections.users.insertMany(mockUsers); + + const base = collections.users.findOne({ name: "anon" }); + const selected = base.select({ name: true }); + + expect(selected).not.toBe(base); + expect((await base)?.age).toBeDefined(); + // @ts-expect-error + expect((await selected)?.age).toBeUndefined(); + }); + + it("omit() returns a new instance and does not affect base query", async () => { + await collections.users.insertMany(mockUsers); + + const base = collections.users.findOne({ name: "anon" }); + const omitted = base.omit({ age: true }); + + expect(omitted).not.toBe(base); + expect((await base)?.age).toBeDefined(); + // @ts-expect-error + expect((await omitted)?.age).toBeUndefined(); + }); + }); + + describe("relation.options()", () => { + it("does not affect the base relation when options() is called after defining it", async () => { + const PostSchema = createSchema("posts", { + title: string(), + contents: string(), + authorId: objectId().optional(), + }); + + const schemas = defineSchemas({ users: UserSchema, posts: PostSchema }); + const relations = schemas.withRelations((r) => { + const baseRelation = r.many.posts({ from: r.users._id, to: r.posts.authorId }); + // Calling .options() must not mutate baseRelation + baseRelation.options({ select: { title: true } }); + return { users: { posts: baseRelation } }; + }); + const db = createDatabase(client.db(), relations); + + const user = await db.collections.users.insertOne({ name: "Alice", email: "alice@example.com" }); + await db.collections.posts.insertOne({ title: "Post 1", contents: "Hello world", authorId: user._id }); + + // The base relation has no options — both fields must be present + const populated = await db.collections.users.findById(user._id).populate({ posts: true }); + expect(populated?.posts[0].title).toBe("Post 1"); + expect(populated?.posts[0].contents).toBe("Hello world"); + }); + }); + }); });