From 31dccdee12c81ae3ea92c49195b118dffe24716f Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Tue, 30 Dec 2025 15:40:14 +0100 Subject: [PATCH 01/14] Fix bug where update object is mutated with schema field updates and reused across multiple calls --- src/collection/query/find-one-and-update.ts | 10 +- src/collection/query/update-many.ts | 10 +- src/collection/query/update-one.ts | 10 +- src/schema/schema.ts | 5 + src/types/type.ts | 7 ++ tests/update-mutation.test.ts | 115 ++++++++++++++++++++ 6 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 tests/update-mutation.test.ts diff --git a/src/collection/query/find-one-and-update.ts b/src/collection/query/find-one-and-update.ts index 8d75328..7e812ae 100644 --- a/src/collection/query/find-one-and-update.ts +++ b/src/collection/query/find-one-and-update.ts @@ -49,10 +49,16 @@ export class FindOneAndUpdateQuery< public async exec(): Promise | null> { await this._readyPromise; const fieldUpdates = Schema.getFieldUpdates(this._schema) as MatchKeysAndValues>; - this._update.$set = { ...fieldUpdates, ...this._update.$set }; + + // Create a new update object to avoid mutating the user's input + // User-provided $set values take precedence over schema field updates + const update = { + ...this._update, + $set: { ...fieldUpdates, ...this._update.$set }, + }; const extras = addExtraInputsToProjection(this._projection, this._schema.options.virtuals); - const res = await this._collection.findOneAndUpdate(this._filter, this._update, { + const res = await this._collection.findOneAndUpdate(this._filter, update, { ...this._options, projection: this._projection, }); diff --git a/src/collection/query/update-many.ts b/src/collection/query/update-many.ts index ade801f..fcaed3b 100644 --- a/src/collection/query/update-many.ts +++ b/src/collection/query/update-many.ts @@ -30,9 +30,15 @@ export class UpdateManyQuery extends Query>> { await this._readyPromise; const fieldUpdates = Schema.getFieldUpdates(this._schema) as MatchKeysAndValues>; - this._update.$set = { ...fieldUpdates, ...this._update.$set }; - const res = await this._collection.updateMany(this._filter, this._update, this._options); + // Create a new update object to avoid mutating the user's input + // User-provided $set values take precedence over schema field updates + const update = { + ...this._update, + $set: { ...fieldUpdates, ...this._update.$set }, + }; + + const res = await this._collection.updateMany(this._filter, update, this._options); return res; } } diff --git a/src/collection/query/update-one.ts b/src/collection/query/update-one.ts index 3964840..861a8ba 100644 --- a/src/collection/query/update-one.ts +++ b/src/collection/query/update-one.ts @@ -30,9 +30,15 @@ export class UpdateOneQuery extends Query>> { await this._readyPromise; const fieldUpdates = Schema.getFieldUpdates(this._schema) as MatchKeysAndValues>; - this._update.$set = { ...fieldUpdates, ...this._update.$set }; - const res = await this._collection.updateOne(this._filter, this._update, this._options); + // Create a new update object to avoid mutating the user's input + // User-provided $set values take precedence over schema field updates + const update = { + ...this._update, + $set: { ...fieldUpdates, ...this._update.$set }, + }; + + const res = await this._collection.updateOne(this._filter, update, this._options); return res; } } diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 463f524..69ead80 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -85,6 +85,11 @@ export class Schema< return output; } + /** + * Get field updates for all top-level schema fields that have onUpdate configured. + * + * NOTE: Only top-level schema fields are processed. Nested fields within objects or arrays are not included. + */ public static getFieldUpdates(schema: T) { return schema.getFieldUpdates(); } diff --git a/src/types/type.ts b/src/types/type.ts index 0398060..b889f0c 100644 --- a/src/types/type.ts +++ b/src/types/type.ts @@ -52,6 +52,13 @@ export class MonarchType { return defaulted(this, defaultInput as InferTypeInput | (() => InferTypeInput)); } + /** + * Auto-update field on every update operation. + * + * NOTE: onUpdate only works on top-level schema fields. It does not work on nested fields within objects or array elements. + * + * @param updateFn function that returns the new value for this field on update operations. + */ public onUpdate(updateFn: () => TInput) { return type(this._parser, pipeParser(updateFn, this._parser)); } diff --git a/tests/update-mutation.test.ts b/tests/update-mutation.test.ts new file mode 100644 index 0000000..f5d8bbf --- /dev/null +++ b/tests/update-mutation.test.ts @@ -0,0 +1,115 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, createSchema } from "../src"; +import { number, string } from "../src/types"; +import { createMockDatabase } from "./mock"; + +describe("Update mutation", async () => { + const { server, client } = await createMockDatabase(); + + beforeAll(async () => { + await client.connect(); + }); + + afterEach(async () => { + await client.db().dropDatabase(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + it("should not mutate reused update object in updateOne", async () => { + const schema = createSchema("users", { + name: string(), + age: number().onUpdate(() => 999), + }); + const db = createDatabase(client.db(), { users: schema }); + + const user1 = await db.collections.users.insertOne({ name: "Alice", age: 20 }).exec(); + const user2 = await db.collections.users.insertOne({ name: "Bob", age: 30 }).exec(); + + // Create a reusable update object + const updateObj = { $set: { name: "Updated" } }; + + // Use the same update object twice + await db.collections.users.updateOne({ _id: user1._id }, updateObj).exec(); + await db.collections.users.updateOne({ _id: user2._id }, updateObj).exec(); + + // Verify users were updated correctly with auto-update + const updatedUser1 = await db.collections.users.findOne({ _id: user1._id }).exec(); + expect(updatedUser1?.name).toBe("Updated"); + expect(updatedUser1?.age).toBe(999); + + const updatedUser2 = await db.collections.users.findOne({ _id: user2._id }).exec(); + expect(updatedUser2?.name).toBe("Updated"); + expect(updatedUser2?.age).toBe(999); + + // The key test: original object should not be mutated + expect(updateObj).toStrictEqual({ $set: { name: "Updated" } }); + }); + + it("should not mutate reused update object in updateMany", async () => { + const schema = createSchema("users", { + name: string(), + age: number().onUpdate(() => 888), + }); + const db = createDatabase(client.db(), { users: schema }); + + await db.collections.users.insertOne({ name: "Alice", age: 20 }).exec(); + await db.collections.users.insertOne({ name: "Bob", age: 30 }).exec(); + await db.collections.users.insertOne({ name: "Charlie", age: 40 }).exec(); + + const updateObj = { $set: { name: "Updated" } }; + + // Use the same update object twice for different filters + await db.collections.users.updateMany({ age: { $lt: 30 } }, updateObj).exec(); + await db.collections.users.updateMany({ age: { $gte: 30 } }, updateObj).exec(); + + // Verify all users were updated + const users = await db.collections.users.find({}).exec(); + expect(users).toHaveLength(3); + for (const user of users) { + expect(user.name).toBe("Updated"); + expect(user.age).toBe(888); + } + + // Original object should not be mutated + expect(updateObj).toStrictEqual({ $set: { name: "Updated" } }); + }); + + it("should not mutate reused update object in findOneAndUpdate", async () => { + const schema = createSchema("users", { + name: string(), + age: number().onUpdate(() => 777), + }); + const db = createDatabase(client.db(), { users: schema }); + + const user1 = await db.collections.users.insertOne({ name: "Alice", age: 20 }).exec(); + const user2 = await db.collections.users.insertOne({ name: "Bob", age: 30 }).exec(); + + const updateObj = { $set: { name: "Updated" } }; + + // Use the same update object twice + await db.collections.users + .findOneAndUpdate({ _id: user1._id }, updateObj) + .options({ returnDocument: "after" }) + .exec(); + await db.collections.users + .findOneAndUpdate({ _id: user2._id }, updateObj) + .options({ returnDocument: "after" }) + .exec(); + + // Verify users were updated + const updatedUser1 = await db.collections.users.findOne({ _id: user1._id }).exec(); + expect(updatedUser1?.name).toBe("Updated"); + expect(updatedUser1?.age).toBe(777); + + const updatedUser2 = await db.collections.users.findOne({ _id: user2._id }).exec(); + expect(updatedUser2?.name).toBe("Updated"); + expect(updatedUser2?.age).toBe(777); + + // Original object should not be mutated + expect(updateObj).toStrictEqual({ $set: { name: "Updated" } }); + }); +}); From c21a1c0ac85d255ff981753ca0d1eea350d05d01 Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Tue, 30 Dec 2025 15:58:59 +0100 Subject: [PATCH 02/14] Fix date before/after range and validate tuple length strictly --- src/types/date.ts | 16 ++++++++++------ src/types/tuple.ts | 2 +- tests/types.test.ts | 28 +++++++++++++++++++++++++--- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/types/date.ts b/src/types/date.ts index d49c249..58b9bd5 100644 --- a/src/types/date.ts +++ b/src/types/date.ts @@ -14,8 +14,10 @@ export class MonarchDate extends MonarchType { public after(afterDate: Date) { return date().extend(this, { preParse: (input) => { - if (input > afterDate) return input; - throw new MonarchParseError(`date must be after ${afterDate}`); + if (input <= afterDate) { + throw new MonarchParseError(`date must be after ${afterDate.toISOString()}`); + } + return input; }, }); } @@ -23,7 +25,7 @@ export class MonarchDate extends MonarchType { public before(targetDate: Date) { return date().extend(this, { preParse: (input) => { - if (input > targetDate) { + if (input >= targetDate) { throw new MonarchParseError(`date must be before ${targetDate.toISOString()}`); } return input; @@ -55,8 +57,10 @@ export class MonarchDateString extends MonarchType { return dateString().extend(this, { preParse: (input) => { const date = new Date(input); - if (date > afterDate) return input; - throw new MonarchParseError(`date must be after ${afterDate}`); + if (date <= afterDate) { + throw new MonarchParseError(`date must be after ${afterDate.toISOString()}`); + } + return input; }, }); } @@ -65,7 +69,7 @@ export class MonarchDateString extends MonarchType { return dateString().extend(this, { preParse: (input) => { const date = new Date(input); - if (date > targetDate) { + if (date >= targetDate) { throw new MonarchParseError(`date must be before ${targetDate.toISOString()}`); } return input; diff --git a/src/types/tuple.ts b/src/types/tuple.ts index 8adf029..16e9df1 100644 --- a/src/types/tuple.ts +++ b/src/types/tuple.ts @@ -13,7 +13,7 @@ export class MonarchTuple exten constructor(types: T) { super((input) => { if (Array.isArray(input)) { - if (input.length > types.length) { + if (input.length !== types.length) { throw new MonarchParseError(`expected array with ${types.length} elements received ${input.length} elements`); } const parsed = [] as InferTypeTupleOutput; diff --git a/tests/types.test.ts b/tests/types.test.ts index 7d4e965..f4c866a 100644 --- a/tests/types.test.ts +++ b/tests/types.test.ts @@ -232,7 +232,7 @@ describe("Types", () => { expect(() => Schema.toData(schema, {})).toThrowError("expected 'array' received 'undefined'"); // @ts-expect-error expect(() => Schema.toData(schema, { items: [] })).toThrowError( - "element at index '0' expected 'number' received 'undefined'", + "expected array with 2 elements received 0 elements", ); const data = Schema.toData(schema, { items: [0, "1"] }); expect(data).toStrictEqual({ items: [0, "1"] }); @@ -473,12 +473,20 @@ describe("Types", () => { }); expect(() => Schema.toData(schema, { afterDate: past, beforeDate: future })).toThrowError( - `date must be after ${now}`, + `date must be after ${now.toISOString()}`, ); expect(() => Schema.toData(schema, { afterDate: future, beforeDate: future })).toThrowError( `date must be before ${now.toISOString()}`, ); + // Edge case: date equal to boundary should fail + expect(() => Schema.toData(schema, { afterDate: now, beforeDate: past })).toThrowError( + `date must be after ${now.toISOString()}`, + ); + expect(() => Schema.toData(schema, { afterDate: future, beforeDate: now })).toThrowError( + `date must be before ${now.toISOString()}`, + ); + const data = Schema.toData(schema, { afterDate: future, beforeDate: past, @@ -497,7 +505,7 @@ describe("Types", () => { afterDate: past.toISOString(), beforeDate: future.toISOString(), }), - ).toThrowError(`date must be after ${now}`); + ).toThrowError(`date must be after ${now.toISOString()}`); expect(() => Schema.toData(schema, { @@ -506,6 +514,20 @@ describe("Types", () => { }), ).toThrowError(`date must be before ${now.toISOString()}`); + // Edge case: date equal to boundary should fail + expect(() => + Schema.toData(schema, { + afterDate: now.toISOString(), + beforeDate: past.toISOString(), + }), + ).toThrowError(`date must be after ${now.toISOString()}`); + expect(() => + Schema.toData(schema, { + afterDate: future.toISOString(), + beforeDate: now.toISOString(), + }), + ).toThrowError(`date must be before ${now.toISOString()}`); + const data = Schema.toData(schema, { afterDate: future.toISOString(), beforeDate: past.toISOString(), From 6177d01000d082949faf84ab975f12e1c2452644 Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Thu, 1 Jan 2026 14:04:38 +0100 Subject: [PATCH 03/14] Add bson types, error on duplicate relations, refactor internals --- .changeset/sunny-cycles-cross.md | 11 + .npmignore | 1 - src/collection/collection.ts | 4 +- src/collection/pipeline/aggregation.ts | 4 - src/collection/pipeline/base.ts | 1 - src/collection/query/base.ts | 1 - src/collection/query/find-one-and-delete.ts | 2 +- src/collection/query/find-one-and-replace.ts | 2 +- src/collection/query/find-one-and-update.ts | 2 +- src/collection/query/find-one.ts | 2 +- src/collection/query/find.ts | 2 +- src/collection/query/insert-many.ts | 2 +- src/collection/query/insert-one.ts | 4 +- src/collection/utils/population.ts | 6 +- src/database.ts | 11 +- src/index.ts | 12 +- src/schema/schema.ts | 115 ++- src/types/array.ts | 67 +- src/types/binary.ts | 15 + src/types/date.ts | 24 +- src/types/decimal128.ts | 15 + src/types/index.ts | 6 +- src/types/long.ts | 22 + src/types/number.ts | 18 +- src/types/string.ts | 30 +- src/types/tagged-union.ts | 46 -- src/types/type.ts | 36 +- src/types/union.ts | 50 +- tests/query.test.ts | 638 ----------------- tests/query/aggregate.test.ts | 48 ++ tests/query/delete.test.ts | 45 ++ tests/query/insert-find.test.ts | 264 +++++++ tests/query/query-methods.test.ts | 104 +++ tests/query/update-hooks.test.ts | 510 ++++++++++++++ tests/query/update.test.ts | 175 +++++ tests/refs.test.ts | 666 ------------------ tests/relations/many.test.ts | 198 ++++++ tests/relations/one.test.ts | 210 ++++++ tests/relations/population-options.test.ts | 241 +++++++ tests/relations/ref.test.ts | 253 +++++++ tests/relations/validation.test.ts | 88 +++ .../schema.test.ts} | 91 ++- tests/transformations.test.ts | 112 --- tests/types.test.ts | 612 ---------------- tests/types/array.test.ts | 108 +++ tests/types/binary.test.ts | 108 +++ tests/types/boolean.test.ts | 43 ++ tests/types/date.test.ts | 161 +++++ tests/types/decimal128.test.ts | 146 ++++ tests/types/literal.test.ts | 22 + tests/types/long.test.ts | 147 ++++ tests/types/mixed.test.ts | 29 + tests/types/number.test.ts | 74 ++ tests/types/object.test.ts | 40 ++ tests/types/objectid.test.ts | 118 ++++ tests/types/record.test.ts | 22 + tests/types/string.test.ts | 83 +++ tests/types/tuple.test.ts | 24 + tests/types/type.test.ts | 406 +++++++++++ tests/types/union.test.ts | 89 +++ tests/update-mutation.test.ts | 115 --- todo.md | 36 + 62 files changed, 4176 insertions(+), 2361 deletions(-) create mode 100644 .changeset/sunny-cycles-cross.md create mode 100644 src/types/binary.ts create mode 100644 src/types/decimal128.ts create mode 100644 src/types/long.ts delete mode 100644 src/types/tagged-union.ts delete mode 100644 tests/query.test.ts create mode 100644 tests/query/aggregate.test.ts create mode 100644 tests/query/delete.test.ts create mode 100644 tests/query/insert-find.test.ts create mode 100644 tests/query/query-methods.test.ts create mode 100644 tests/query/update-hooks.test.ts create mode 100644 tests/query/update.test.ts delete mode 100644 tests/refs.test.ts create mode 100644 tests/relations/many.test.ts create mode 100644 tests/relations/one.test.ts create mode 100644 tests/relations/population-options.test.ts create mode 100644 tests/relations/ref.test.ts create mode 100644 tests/relations/validation.test.ts rename tests/{schema-options.test.ts => schema/schema.test.ts} (71%) delete mode 100644 tests/transformations.test.ts delete mode 100644 tests/types.test.ts create mode 100644 tests/types/array.test.ts create mode 100644 tests/types/binary.test.ts create mode 100644 tests/types/boolean.test.ts create mode 100644 tests/types/date.test.ts create mode 100644 tests/types/decimal128.test.ts create mode 100644 tests/types/literal.test.ts create mode 100644 tests/types/long.test.ts create mode 100644 tests/types/mixed.test.ts create mode 100644 tests/types/number.test.ts create mode 100644 tests/types/object.test.ts create mode 100644 tests/types/objectid.test.ts create mode 100644 tests/types/record.test.ts create mode 100644 tests/types/string.test.ts create mode 100644 tests/types/tuple.test.ts create mode 100644 tests/types/type.test.ts create mode 100644 tests/types/union.test.ts delete mode 100644 tests/update-mutation.test.ts diff --git a/.changeset/sunny-cycles-cross.md b/.changeset/sunny-cycles-cross.md new file mode 100644 index 0000000..82cc57f --- /dev/null +++ b/.changeset/sunny-cycles-cross.md @@ -0,0 +1,11 @@ +--- +"monarch-orm": minor +--- + +Throw error when adding multiple relations/schema for a collection. + +Add BSON types `binary()`, `long()` and `decimal128()`. + +Replace aggregate cast method with generic param. + +Use prettier for formatting. \ No newline at end of file diff --git a/.npmignore b/.npmignore index dfa281a..3193283 100644 --- a/.npmignore +++ b/.npmignore @@ -6,7 +6,6 @@ tests coverage pnpm-lock.yaml tsconfig.json -biome.json vitest.config.mts tsup.config.ts CHANGELOG.md diff --git a/src/collection/collection.ts b/src/collection/collection.ts index 467b797..e078b37 100644 --- a/src/collection/collection.ts +++ b/src/collection/collection.ts @@ -142,8 +142,8 @@ export class Collection(this.schema, this._collection, this._readyPromise); + public aggregate() { + return new AggregationPipeline(this.schema, this._collection, this._readyPromise); } public async countDocuments(filter: Filter> = {}, options?: CountDocumentsOptions) { diff --git a/src/collection/pipeline/aggregation.ts b/src/collection/pipeline/aggregation.ts index 1c859cd..81e16d9 100644 --- a/src/collection/pipeline/aggregation.ts +++ b/src/collection/pipeline/aggregation.ts @@ -18,10 +18,6 @@ export class AggregationPipeline() { - return this as unknown as AggregationPipeline; - } - public async exec(): Promise { const res = await this._collection.aggregate(this._pipeline, this._options).toArray(); return res as TOutput; diff --git a/src/collection/pipeline/base.ts b/src/collection/pipeline/base.ts index 7f9c89c..27b0a3a 100644 --- a/src/collection/pipeline/base.ts +++ b/src/collection/pipeline/base.ts @@ -18,7 +18,6 @@ export abstract class Pipeline { public abstract exec(): Promise; - // biome-ignore lint/suspicious/noThenProperty: We need automatic promise resolution then( onfulfilled?: ((value: TOutput) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, diff --git a/src/collection/query/base.ts b/src/collection/query/base.ts index 626f27e..e23d8a9 100644 --- a/src/collection/query/base.ts +++ b/src/collection/query/base.ts @@ -13,7 +13,6 @@ export abstract class Query { public abstract exec(): Promise; - // biome-ignore lint/suspicious/noThenProperty: We need automatic promise resolution then( onfulfilled?: ((value: TOutput) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, diff --git a/src/collection/query/find-one-and-delete.ts b/src/collection/query/find-one-and-delete.ts index 10ade57..e5a5395 100644 --- a/src/collection/query/find-one-and-delete.ts +++ b/src/collection/query/find-one-and-delete.ts @@ -47,7 +47,7 @@ export class FindOneAndDeleteQuery< projection: this._projection, }); return res - ? (Schema.fromData(this._schema, res as InferSchemaData, this._projection, extras) as QueryOutput< + ? (Schema.decode(this._schema, res as InferSchemaData, this._projection, extras) as QueryOutput< TOutput, TOmit >) diff --git a/src/collection/query/find-one-and-replace.ts b/src/collection/query/find-one-and-replace.ts index c43652b..0471dc8 100644 --- a/src/collection/query/find-one-and-replace.ts +++ b/src/collection/query/find-one-and-replace.ts @@ -48,7 +48,7 @@ export class FindOneAndReplaceQuery< projection: this._projection, }); return res - ? (Schema.fromData(this._schema, res as InferSchemaData, this._projection, extras) as QueryOutput< + ? (Schema.decode(this._schema, res as InferSchemaData, this._projection, extras) as QueryOutput< TOutput, TOmit >) diff --git a/src/collection/query/find-one-and-update.ts b/src/collection/query/find-one-and-update.ts index 7e812ae..97195df 100644 --- a/src/collection/query/find-one-and-update.ts +++ b/src/collection/query/find-one-and-update.ts @@ -63,7 +63,7 @@ export class FindOneAndUpdateQuery< projection: this._projection, }); return res - ? (Schema.fromData(this._schema, res as InferSchemaData, this._projection, extras) as QueryOutput< + ? (Schema.decode(this._schema, res as InferSchemaData, this._projection, extras) as QueryOutput< TOutput, TOmit >) diff --git a/src/collection/query/find-one.ts b/src/collection/query/find-one.ts index a48e99f..0cdb5c8 100644 --- a/src/collection/query/find-one.ts +++ b/src/collection/query/find-one.ts @@ -73,7 +73,7 @@ export class FindOneQuery< projection: this._projection, }); return res - ? (Schema.fromData(this._schema, res as InferSchemaData, this._projection, extras) as QueryOutput< + ? (Schema.decode(this._schema, res as InferSchemaData, this._projection, extras) as QueryOutput< TOutput, TOmit, TPopulate diff --git a/src/collection/query/find.ts b/src/collection/query/find.ts index 52f6f08..14a607b 100644 --- a/src/collection/query/find.ts +++ b/src/collection/query/find.ts @@ -91,7 +91,7 @@ export class FindQuery< .find(this._filter, { ...this._options, projection: this._projection }) .map( (doc) => - Schema.fromData(this._schema, doc as InferSchemaData, this._projection, extras) as QueryOutput< + Schema.decode(this._schema, doc as InferSchemaData, this._projection, extras) as QueryOutput< TOutput, TOmit, TPopulate diff --git a/src/collection/query/insert-many.ts b/src/collection/query/insert-many.ts index ec2b26d..034d57d 100644 --- a/src/collection/query/insert-many.ts +++ b/src/collection/query/insert-many.ts @@ -29,7 +29,7 @@ export class InsertManyQuery extends Query< public async exec(): Promise>> { await this._readyPromise; - const data = this._data.map((data) => Schema.toData(this._schema, data)); + const data = this._data.map((data) => Schema.encode(this._schema, data)); const res = await this._collection.insertMany( data as OptionalUnlessRequiredId>[], this._options, diff --git a/src/collection/query/insert-one.ts b/src/collection/query/insert-one.ts index 40a5cb2..42fc142 100644 --- a/src/collection/query/insert-one.ts +++ b/src/collection/query/insert-one.ts @@ -30,12 +30,12 @@ export class InsertOneQuery< public async exec(): Promise> { await this._readyPromise; - const data = Schema.toData(this._schema, this._data); + const data = Schema.encode(this._schema, this._data); const res = await this._collection.insertOne( data as OptionalUnlessRequiredId>, this._options, ); - return Schema.fromData( + return Schema.decode( this._schema, { ...data, diff --git a/src/collection/utils/population.ts b/src/collection/utils/population.ts index 1a6bff1..8d61a12 100644 --- a/src/collection/utils/population.ts +++ b/src/collection/utils/population.ts @@ -128,7 +128,7 @@ export function expandPopulations(opts: { schema: AnySchema; doc: any; }) { - const populatedDoc = Schema.fromData(opts.schema, opts.doc, opts.projection, opts.extras); + const populatedDoc = Schema.decode(opts.schema, opts.doc, opts.projection, opts.extras); for (const [key, population] of Object.entries(opts.populations)) { populatedDoc[key] = mapOneOrArray(opts.doc[population.fieldVariable], (doc) => { if (population.populations) { @@ -140,7 +140,7 @@ export function expandPopulations(opts: { doc, }); } - return Schema.fromData(population.relation.target, doc, population.projection, population.extras); + return Schema.decode(population.relation.target, doc, population.projection, population.extras); }); delete populatedDoc[population.fieldVariable]; } @@ -177,7 +177,6 @@ function addPopulationPipeline( [fieldVariable]: { $cond: { if: { $isArray: `$${fieldVariable}` }, - // biome-ignore lint/suspicious/noThenProperty: this is MongoDB syntax then: `$${fieldVariable}`, else: [], }, @@ -237,7 +236,6 @@ function addPopulationPipeline( [fieldVariable]: { $cond: { if: { $gt: [{ $size: `$${fieldVariable}` }, 0] }, // Skip population if value is null - // biome-ignore lint/suspicious/noThenProperty: this is MongoDB syntax then: { $arrayElemAt: [`$${fieldVariable}`, 0] }, // Unwind the first populated result else: null, }, diff --git a/src/database.ts b/src/database.ts index 70a4066..5dfed6a 100644 --- a/src/database.ts +++ b/src/database.ts @@ -29,7 +29,12 @@ export class Database< relations: TRelations, ) { const _relations = {} as DbRelations; + const _seenRelations = new Set(); for (const relation of Object.values(relations)) { + if (_seenRelations.has(relation.name)) { + throw new MonarchError(`Relations for schema '${relation.name}' already exists.`); + } + _seenRelations.add(relation.name); _relations[relation.name as keyof typeof _relations] = { ..._relations[relation.name as keyof typeof _relations], ...relation.relations, @@ -38,12 +43,12 @@ export class Database< this.relations = _relations; const _collections = {} as DbCollections>; - const _collectionNames = new Set(); + const _seenCollection = new Set(); for (const [key, schema] of Object.entries(schemas)) { - if (_collectionNames.has(schema.name)) { + if (_seenCollection.has(schema.name)) { throw new MonarchError(`Schema with name '${schema.name}' already exists.`); } - _collectionNames.add(schema.name); + _seenCollection.add(schema.name); _collections[key as keyof typeof _collections] = new Collection( db, schema, diff --git a/src/index.ts b/src/index.ts index e78979c..a667d30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,10 @@ import { array } from "./types/array"; +import { binary } from "./types/binary"; import { boolean } from "./types/boolean"; import { createdAt, date, dateString, updatedAt } from "./types/date"; +import { decimal128 } from "./types/decimal128"; import { literal } from "./types/literal"; +import { long } from "./types/long"; import { mixed } from "./types/mixed"; import { number } from "./types/number"; import { object } from "./types/object"; @@ -9,10 +12,9 @@ import { objectId } from "./types/objectId"; import { pipe } from "./types/pipe"; import { record } from "./types/record"; import { string } from "./types/string"; -import { taggedUnion } from "./types/tagged-union"; import { tuple } from "./types/tuple"; -import { defaulted, nullable, optional } from "./types/type"; -import { union } from "./types/union"; +import { defaulted, nullable, optional, type } from "./types/type"; +import { taggedUnion, union } from "./types/union"; export { ObjectId } from "mongodb"; export { Collection } from "./collection/collection"; @@ -27,11 +29,14 @@ export { generateObjectId, isValidObjectId, objectIdToString, toObjectId } from export const m = { array, boolean, + binary, date, dateString, + decimal128, createdAt, updatedAt, literal, + long, mixed, number, object, @@ -40,6 +45,7 @@ export const m = { record, string, taggedUnion, + type, tuple, union, nullable, diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 69ead80..c268ef1 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -32,43 +32,70 @@ export class Schema< if (!_types._id) this._types._id = objectId().optional(); } + omit>(omit: TOmit) { + const schema = this as unknown as Schema; + schema.options.omit = omit; + return schema; + } + + virtuals, any, any>>>( + virtuals: SchemaVirtuals, + ) { + const schema = this as unknown as Schema; + schema.options.virtuals = virtuals; + return schema; + } + + /** + * Defines the indexes for the schema. + * + * This method allows you to specify indexes that should be created for the schema. + * + * @param indexes - A function that defines the indexes to be created. + * + * @returns The current schema instance for method chaining. + * + * @example + * const userSchema = createSchema("users", { + * name: string(), + * age: number(), + * }).indexes(({ createIndex, unique }) => ({ + * username: unique("username"), + * fullname: createIndex({ firstname: 1, surname: 1 }, { unique: true }), + * })); + */ + indexes(indexes: SchemaIndexes) { + this.options.indexes = indexes; + return this; + } + public static types(schema: T): InferSchemaTypes { return schema._types; } - public static toData(schema: T, data: InferSchemaInput) { - return schema.toData(data); - } - private toData(input: InferSchemaInput): InferSchemaData { - const data = {} as InferSchemaData; + public static encode(schema: T, input: InferSchemaInput) { + const data = {} as InferSchemaData; // parse fields - const types = Schema.types(this); + const types = Schema.types(schema); for (const [key, type] of Object.entries(types)) { - const parser = MonarchType.parser(type); - const parsed = parser(input[key as keyof InferSchemaInput]); + const parser = MonarchType.parser(type as AnyMonarchType); + const parsed = parser(input[key as keyof InferSchemaInput]); if (parsed === undefined) continue; data[key as keyof typeof data] = parsed; } return data; } - public static fromData( + public static decode( schema: T, data: InferSchemaData, projection: Projection>, forceOmit: string[] | null, ) { - return schema.fromData(data, projection, forceOmit); - } - private fromData( - data: InferSchemaData, - projection: Projection>, - forceOmit: string[] | null, - ): InferSchemaOutput { - const output = data as unknown as InferSchemaOutput; - if (this.options.virtuals) { + const output = data as unknown as InferSchemaOutput; + if (schema.options.virtuals) { const { isProjected } = detectProjection(projection); - for (const [key, virtual] of Object.entries(this.options.virtuals)) { + for (const [key, virtual] of Object.entries(schema.options.virtuals)) { // skip omitted virtual field if (isProjected(key)) { // @ts-ignore @@ -79,7 +106,7 @@ export class Schema< // delete other fields that might have been added as input to a virtual or returned during insert if (forceOmit) { for (const key of forceOmit) { - delete output[key as keyof InferSchemaOutput]; + delete output[key as keyof InferSchemaOutput]; } } return output; @@ -91,56 +118,16 @@ export class Schema< * NOTE: Only top-level schema fields are processed. Nested fields within objects or arrays are not included. */ public static getFieldUpdates(schema: T) { - return schema.getFieldUpdates(); - } - private getFieldUpdates(): Partial> { - const updates = {} as Partial>; + const updates = {} as Partial>; // omit fields - for (const [key, type] of Object.entries(Schema.types(this))) { - const updater = MonarchType.updater(type); + for (const [key, type] of Object.entries(Schema.types(schema))) { + const updater = MonarchType.updater(type as AnyMonarchType); if (updater) { - updates[key as keyof InferSchemaOutput] = updater(); + updates[key as keyof InferSchemaOutput] = updater(); } } return updates; } - - omit>(omit: TOmit) { - const schema = this as unknown as Schema; - schema.options.omit = omit; - return schema; - } - - virtuals, any, any>>>( - virtuals: SchemaVirtuals, - ) { - const schema = this as unknown as Schema; - schema.options.virtuals = virtuals; - return schema; - } - - /** - * Defines the indexes for the schema. - * - * This method allows you to specify indexes that should be created for the schema. - * - * @param indexes - A function that defines the indexes to be created. - * - * @returns The current schema instance for method chaining. - * - * @example - * const userSchema = createSchema("users", { - * name: string(), - * age: number(), - * }).indexes(({ createIndex, unique }) => ({ - * username: unique("username"), - * fullname: createIndex({ firstname: 1, surname: 1 }, { unique: true }), - * })); - */ - indexes(indexes: SchemaIndexes) { - this.options.indexes = indexes; - return this; - } } export function createSchema>( diff --git a/src/types/array.ts b/src/types/array.ts index a65dc04..b57ac3a 100644 --- a/src/types/array.ts +++ b/src/types/array.ts @@ -5,24 +5,65 @@ import type { InferTypeInput, InferTypeOutput } from "./type-helpers"; export const array = (type: T) => new MonarchArray(type); export class MonarchArray extends MonarchType[], InferTypeOutput[]> { + private elementType: T; + constructor(type: T) { super((input) => { - if (Array.isArray(input)) { - const parsed = [] as InferTypeOutput[]; - for (const [index, value] of input.entries()) { - try { - const parser = MonarchType.parser(type); - parsed[index] = parser(value); - } catch (error) { - if (error instanceof MonarchParseError) { - throw new MonarchParseError(`element at index '${index}' ${error.message}`); - } - throw error; + if (!Array.isArray(input)) { + throw new MonarchParseError(`expected 'array' received '${typeof input}'`); + } + + const parser = MonarchType.parser(type); + const parsed = new Array>(input.length); + for (const [index, value] of input.entries()) { + try { + parsed[index] = parser(value); + } catch (error) { + if (error instanceof MonarchParseError) { + throw new MonarchParseError(`element at index '${index}' ${error.message}`); } + throw error; } - return parsed; } - throw new MonarchParseError(`expected 'array' received '${typeof input}'`); + return parsed; }); + this.elementType = type; + } + + public min(length: number) { + return array(this.elementType).extend(this, { + parse: (input) => { + if (input.length < length) { + throw new MonarchParseError(`array must have at least ${length} elements`); + } + return input; + }, + }); + } + + public max(length: number) { + return array(this.elementType).extend(this, { + parse: (input) => { + if (input.length > length) { + throw new MonarchParseError(`array must have at most ${length} elements`); + } + return input; + }, + }); + } + + public length(length: number) { + return array(this.elementType).extend(this, { + parse: (input) => { + if (input.length !== length) { + throw new MonarchParseError(`array must have exactly ${length} elements`); + } + return input; + }, + }); + } + + public nonempty() { + return this.min(1); } } diff --git a/src/types/binary.ts b/src/types/binary.ts new file mode 100644 index 0000000..22d4bda --- /dev/null +++ b/src/types/binary.ts @@ -0,0 +1,15 @@ +import { Binary } from "mongodb"; +import { MonarchParseError } from "../errors"; +import { MonarchType } from "./type"; + +export const binary = () => new MonarchBinary(); + +export class MonarchBinary extends MonarchType { + constructor() { + super((input) => { + if (input instanceof Binary) return input; + if (Buffer.isBuffer(input)) return new Binary(input); + throw new MonarchParseError(`expected 'Buffer' or 'Binary' received '${typeof input}'`); + }); + } +} diff --git a/src/types/date.ts b/src/types/date.ts index 58b9bd5..274665c 100644 --- a/src/types/date.ts +++ b/src/types/date.ts @@ -11,11 +11,11 @@ export class MonarchDate extends MonarchType { }); } - public after(afterDate: Date) { + public after(targetDate: Date) { return date().extend(this, { - preParse: (input) => { - if (input <= afterDate) { - throw new MonarchParseError(`date must be after ${afterDate.toISOString()}`); + parse: (input) => { + if (input <= targetDate) { + throw new MonarchParseError(`date must be after ${targetDate.toISOString()}`); } return input; }, @@ -24,7 +24,7 @@ export class MonarchDate extends MonarchType { public before(targetDate: Date) { return date().extend(this, { - preParse: (input) => { + parse: (input) => { if (input >= targetDate) { throw new MonarchParseError(`date must be before ${targetDate.toISOString()}`); } @@ -53,12 +53,11 @@ export class MonarchDateString extends MonarchType { }); } - public after(afterDate: Date) { + public after(targetDate: Date) { return dateString().extend(this, { - preParse: (input) => { - const date = new Date(input); - if (date <= afterDate) { - throw new MonarchParseError(`date must be after ${afterDate.toISOString()}`); + parse: (input) => { + if (input <= targetDate) { + throw new MonarchParseError(`date must be after ${targetDate.toISOString()}`); } return input; }, @@ -67,9 +66,8 @@ export class MonarchDateString extends MonarchType { public before(targetDate: Date) { return dateString().extend(this, { - preParse: (input) => { - const date = new Date(input); - if (date >= targetDate) { + parse: (input) => { + if (input >= targetDate) { throw new MonarchParseError(`date must be before ${targetDate.toISOString()}`); } return input; diff --git a/src/types/decimal128.ts b/src/types/decimal128.ts new file mode 100644 index 0000000..9280fdc --- /dev/null +++ b/src/types/decimal128.ts @@ -0,0 +1,15 @@ +import { Decimal128 } from "mongodb"; +import { MonarchParseError } from "../errors"; +import { MonarchType } from "./type"; + +export const decimal128 = () => new MonarchDecimal128(); + +export class MonarchDecimal128 extends MonarchType { + constructor() { + super((input) => { + if (input instanceof Decimal128) return input; + if (typeof input === "string") return Decimal128.fromString(input); + throw new MonarchParseError(`expected 'Decimal128' or 'string' received '${typeof input}'`); + }); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index c1d6847..db8a236 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,7 +1,10 @@ export { MonarchArray, array } from "./array"; +export { MonarchBinary, binary } from "./binary"; export { MonarchBoolean, boolean } from "./boolean"; export { MonarchDate, MonarchDateString, createdAt, date, dateString, updatedAt } from "./date"; +export { MonarchDecimal128, decimal128 } from "./decimal128"; export { MonarchLiteral, literal } from "./literal"; +export { MonarchLong, long } from "./long"; export { MonarchMixed, mixed } from "./mixed"; export { MonarchNumber, number } from "./number"; export { MonarchObject, object } from "./object"; @@ -9,7 +12,6 @@ export { MonarchObjectId, objectId } from "./objectId"; export { MonarchPipe, pipe } from "./pipe"; export { MonarchRecord, record } from "./record"; export { MonarchString, string } from "./string"; -export { MonarchTaggedUnion, taggedUnion } from "./tagged-union"; export { MonarchTuple, tuple } from "./tuple"; export { MonarchDefaulted, @@ -25,4 +27,4 @@ export { type Parser, } from "./type"; export { type InferTypeInput, type InferTypeObjectInput } from "./type-helpers"; -export { MonarchUnion, union } from "./union"; +export { MonarchTaggedUnion, MonarchUnion, taggedUnion, union } from "./union"; diff --git a/src/types/long.ts b/src/types/long.ts new file mode 100644 index 0000000..01427d1 --- /dev/null +++ b/src/types/long.ts @@ -0,0 +1,22 @@ +import { Long } from "mongodb"; +import { MonarchParseError } from "../errors"; +import { MonarchType } from "./type"; + +export const long = () => new MonarchLong(); + +export class MonarchLong extends MonarchType { + constructor() { + super((input) => { + if (Long.isLong(input)) return input; + if (typeof input === "bigint") return Long.fromBigInt(input); + if (typeof input === "number") { + // Only convert to Long if outside safe integer range + if (Number.isSafeInteger(input)) { + return input; + } + return Long.fromNumber(input); + } + throw new MonarchParseError(`expected 'Long', 'number', or 'bigint' received '${typeof input}'`); + }); + } +} diff --git a/src/types/number.ts b/src/types/number.ts index e633920..511e420 100644 --- a/src/types/number.ts +++ b/src/types/number.ts @@ -13,7 +13,7 @@ export class MonarchNumber extends MonarchType { public min(value: number) { return number().extend(this, { - preParse: (input) => { + parse: (input) => { if (input < value) { throw new MonarchParseError(`number must be greater than or equal to ${value}`); } @@ -24,7 +24,7 @@ export class MonarchNumber extends MonarchType { public max(value: number) { return number().extend(this, { - preParse: (input) => { + parse: (input) => { if (input > value) { throw new MonarchParseError(`number must be less than or equal to ${value}`); } @@ -35,17 +35,9 @@ export class MonarchNumber extends MonarchType { public integer() { return number().extend(this, { - postParse: (input) => { - return Math.floor(input); - }, - }); - } - - public multipleOf(value: number) { - return number().extend(this, { - postParse: (input) => { - if (input % value !== 0) { - throw new MonarchParseError(`number must be a multiple of ${value}`); + parse: (input) => { + if (!Number.isInteger(input)) { + throw new MonarchParseError("number must be an integer"); } return input; }, diff --git a/src/types/string.ts b/src/types/string.ts index 8cf5a87..8f4f5bf 100644 --- a/src/types/string.ts +++ b/src/types/string.ts @@ -11,21 +11,27 @@ export class MonarchString extends MonarchType { }); } + public trim() { + return string().extend(this, { + parse: (input) => input.trim(), + }); + } + public lowercase() { return string().extend(this, { - postParse: (input) => input.toLowerCase(), + parse: (input) => input.toLowerCase(), }); } public uppercase() { return string().extend(this, { - postParse: (input) => input.toUpperCase(), + parse: (input) => input.toUpperCase(), }); } public minLength(length: number) { return string().extend(this, { - postParse: (input) => { + parse: (input) => { if (input.length < length) { throw new MonarchParseError(`string must be at least ${length} characters long`); } @@ -36,7 +42,7 @@ export class MonarchString extends MonarchType { public maxLength(length: number) { return string().extend(this, { - postParse: (input) => { + parse: (input) => { if (input.length > length) { throw new MonarchParseError(`string must be at most ${length} characters long`); } @@ -47,7 +53,7 @@ export class MonarchString extends MonarchType { public length(length: number) { return string().extend(this, { - postParse: (input) => { + parse: (input) => { if (input.length !== length) { throw new MonarchParseError(`string must be exactly ${length} characters long`); } @@ -58,7 +64,7 @@ export class MonarchString extends MonarchType { public pattern(regex: RegExp) { return string().extend(this, { - postParse: (input) => { + parse: (input) => { if (!regex.test(input)) { throw new MonarchParseError(`string must match pattern ${regex}`); } @@ -67,15 +73,9 @@ export class MonarchString extends MonarchType { }); } - public trim() { - return string().extend(this, { - postParse: (input) => input.trim(), - }); - } - - public nonEmpty() { + public nonempty() { return string().extend(this, { - preParse: (input) => { + preprocess: (input) => { if (input.length === 0) { throw new MonarchParseError("string must not be empty"); } @@ -86,7 +86,7 @@ export class MonarchString extends MonarchType { public includes(searchString: string) { return string().extend(this, { - preParse: (input) => { + preprocess: (input) => { if (!input.includes(searchString)) { throw new MonarchParseError(`string must include "${searchString}"`); } diff --git a/src/types/tagged-union.ts b/src/types/tagged-union.ts deleted file mode 100644 index d3dfb82..0000000 --- a/src/types/tagged-union.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { MonarchParseError } from "../errors"; -import { type AnyMonarchType, MonarchType } from "./type"; -import type { InferTypeTaggedUnionInput, InferTypeTaggedUnionOutput } from "./type-helpers"; - -export const taggedUnion = >(variants: T) => new MonarchTaggedUnion(variants); - -export class MonarchTaggedUnion> extends MonarchType< - InferTypeTaggedUnionInput, - InferTypeTaggedUnionOutput -> { - constructor(variants: T) { - super((input) => { - if (typeof input === "object" && input !== null) { - if (!("tag" in input)) { - throw new MonarchParseError("missing field 'tag' in tagged union"); - } - if (!("value" in input)) { - throw new MonarchParseError("missing field 'value' in tagged union"); - } - if (Object.keys(input).length > 2) { - for (const key of Object.keys(input)) { - if (key !== "tag" && key !== "value") { - throw new MonarchParseError( - `unknown field '${key}', tagged union may only specify 'tag' and 'value' fields`, - ); - } - } - } - const type = variants[input.tag]; - if (!type) { - throw new MonarchParseError(`unknown tag '${input.tag.toString()}'`); - } - try { - const parser = MonarchType.parser(type); - return { tag: input.tag, value: parser(input.value) }; - } catch (error) { - if (error instanceof MonarchParseError) { - throw new MonarchParseError(`invalid value for tag '${input.tag.toString()}' ${error.message}'`); - } - throw error; - } - } - throw new MonarchParseError(`expected 'object' received '${typeof input}'`); - }); - } -} diff --git a/src/types/type.ts b/src/types/type.ts index b889f0c..c883409 100644 --- a/src/types/type.ts +++ b/src/types/type.ts @@ -15,28 +15,28 @@ export const type = (parser: Parser, export type AnyMonarchType = MonarchType; -export class MonarchType { +export class MonarchType { constructor( - private _parser: Parser, - private _updater?: Parser, + protected parser: Parser, + protected updater?: Parser, ) {} public static parser(type: T): Parser, InferTypeOutput> { - return type._parser; + return type.parser; } public static updater(type: T): Parser> | undefined { - return type._updater; + return type.updater; } - public static isInstanceOf AnyMonarchType>( + public static isInstanceOf AnyMonarchType>( type: AnyMonarchType, target: T, ): type is InstanceType { return type.isInstanceOf(target); } - protected isInstanceOf(target: new (...args: any[]) => any) { + protected isInstanceOf(target: new (...args: any) => AnyMonarchType) { return this instanceof target; } @@ -60,7 +60,7 @@ export class MonarchType { * @param updateFn function that returns the new value for this field on update operations. */ public onUpdate(updateFn: () => TInput) { - return type(this._parser, pipeParser(updateFn, this._parser)); + return type(this.parser, pipeParser(updateFn, this.parser)); } /** @@ -70,7 +70,7 @@ export class MonarchType { * @param fn function that returns a transformed input. */ public transform(fn: (input: TOutput) => TTransformOutput) { - return type(pipeParser(this._parser, fn), this._updater && pipeParser(this._updater, fn)); + return type(pipeParser(this.parser, fn), this.updater && pipeParser(this.updater, fn)); } /** @@ -82,19 +82,19 @@ export class MonarchType { */ public validate(fn: (input: TOutput) => boolean, message: string) { return type( - pipeParser(this._parser, (input) => { + pipeParser(this.parser, (input) => { const valid = fn(input); if (!valid) throw new MonarchParseError(message); return input; }), - this._updater, + this.updater, ); } /** * Extends the parser and updater of this type from that of the base type. * - * Calling extend always mutates the type and changes it's parser and updater. + * Extends should be called on a new instance of the type as it always mutates the type. * * @param base type to copy parser and updater from. * @param options options to optionally modify the copied parser or replace the copied updater. @@ -103,15 +103,15 @@ export class MonarchType { public extend>( base: T, options: { - preParse?: Parser; - postParse?: Parser; + preprocess?: Parser; + parse?: Parser; onUpdate?: Parser; }, ) { - let parser = options.preParse ? pipeParser(options.preParse, base._parser) : base._parser; - if (options.postParse) parser = pipeParser(parser, options.postParse); - this._parser = parser; - this._updater = options.onUpdate ? pipeParser(options.onUpdate, parser) : base._updater; + let parser = options.preprocess ? pipeParser(options.preprocess, base.parser) : base.parser; + if (options.parse) parser = pipeParser(parser, options.parse); + this.parser = parser; + this.updater = options.onUpdate ? pipeParser(options.onUpdate, parser) : base.updater; return this; } } diff --git a/src/types/union.ts b/src/types/union.ts index ab04294..75444b9 100644 --- a/src/types/union.ts +++ b/src/types/union.ts @@ -1,6 +1,11 @@ import { MonarchParseError } from "../errors"; import { type AnyMonarchType, MonarchType } from "./type"; -import type { InferTypeUnionInput, InferTypeUnionOutput } from "./type-helpers"; +import type { + InferTypeTaggedUnionInput, + InferTypeTaggedUnionOutput, + InferTypeUnionInput, + InferTypeUnionOutput, +} from "./type-helpers"; export const union = (...variants: T) => new MonarchUnion(variants); @@ -28,3 +33,46 @@ export class MonarchUnion exten }); } } + +export const taggedUnion = >(variants: T) => new MonarchTaggedUnion(variants); + +export class MonarchTaggedUnion> extends MonarchType< + InferTypeTaggedUnionInput, + InferTypeTaggedUnionOutput +> { + constructor(variants: T) { + super((input) => { + if (typeof input === "object" && input !== null) { + if (!("tag" in input)) { + throw new MonarchParseError("missing field 'tag' in tagged union"); + } + if (!("value" in input)) { + throw new MonarchParseError("missing field 'value' in tagged union"); + } + if (Object.keys(input).length > 2) { + for (const key of Object.keys(input)) { + if (key !== "tag" && key !== "value") { + throw new MonarchParseError( + `unknown field '${key}', tagged union may only specify 'tag' and 'value' fields`, + ); + } + } + } + const type = variants[input.tag]; + if (!type) { + throw new MonarchParseError(`unknown tag '${input.tag.toString()}'`); + } + try { + const parser = MonarchType.parser(type); + return { tag: input.tag, value: parser(input.value) }; + } catch (error) { + if (error instanceof MonarchParseError) { + throw new MonarchParseError(`invalid value for tag '${input.tag.toString()}' ${error.message}'`); + } + throw error; + } + } + throw new MonarchParseError(`expected 'object' received '${typeof input}'`); + }); + } +} diff --git a/tests/query.test.ts b/tests/query.test.ts deleted file mode 100644 index 7ec307e..0000000 --- a/tests/query.test.ts +++ /dev/null @@ -1,638 +0,0 @@ -import { ObjectId } from "mongodb"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { createDatabase, createSchema } from "../src"; -import { boolean, number, objectId, pipe, string, type } from "../src/types"; -import { createMockDatabase, mockUsers } from "./mock"; - -describe("Query methods Tests", async () => { - const { server, client } = await createMockDatabase(); - - const UserSchema = createSchema("users", { - name: string().optional(), - email: string().lowercase().optional(), - age: number().optional().default(10), - isVerified: boolean().default(false), - }); - - const TodoSchema = createSchema("todos", { - _id: number(), - title: string(), - userId: objectId(), - }); - - const { collections } = createDatabase(client.db(), { - users: UserSchema, - todos: TodoSchema, - }); - - beforeAll(async () => { - await client.connect(); - }); - - afterEach(async () => { - await collections.users.raw().dropIndexes(); - await collections.users.deleteMany({}).exec(); - await collections.todos.raw().dropIndexes(); - await collections.todos.deleteMany({}).exec(); - }); - - afterAll(async () => { - await client.close(); - await server.stop(); - }); - - it("inserts one document", async () => { - const newUser1 = await collections.users.insertOne(mockUsers[0]).exec(); - expect(newUser1).toMatchObject(mockUsers[0]); - - const newUser2 = await collections.users.insertOne(mockUsers[0]).exec(); - expect(newUser2).toMatchObject(mockUsers[0]); - - // insert with existing id - const id3 = new ObjectId(); - const newUser3 = await collections.users.insertOne({ _id: id3, ...mockUsers[0] }).exec(); - expect(newUser3).toMatchObject(mockUsers[0]); - expect(newUser3._id).toStrictEqual(id3); - - // insert with existing string id - const id4 = new ObjectId(); - const newUser4 = await collections.users.insertOne({ _id: id4.toString(), ...mockUsers[0] }).exec(); - expect(newUser4).toMatchObject(mockUsers[0]); - expect(newUser4._id).toStrictEqual(id4); - - // Use promise resolution - const newUser5 = await collections.users.insertOne(mockUsers[0]); - expect(newUser5).toMatchObject(mockUsers[0]); - - // insert with invalid string id - await expect(async () => { - await collections.users.insertOne({ _id: "not_an_object_id", ...mockUsers[0] }).exec(); - }).rejects.toThrow("expected valid ObjectId received"); - - // Test edge case: Insert empty document - const emptyUser = await collections.users.insertOne({}).exec(); - - expect(emptyUser).not.toBe(null); - expect(emptyUser.age).toBe(10); // Should use default value - - // TODO: Write Test edge case: Insert document with null values - - // const nullUser = await collections.users - // .insertOne({ - // name: null, - // email: null, - // age: null, - // isVerified: false - // }) - // .exec(); - - // expect(nullUser).not.toBe(null); - // expect(nullUser.name).toBe(null); - // expect(nullUser.email).toBe(null); - // expect(nullUser.age).toBe(null); - // expect(nullUser.isVerified).toBe(false); - - // Test edge case: Insert document with invalid email (should be lowercase) - const invalidEmailUser = await collections.users - .insertOne({ - name: "Test", - email: "TEST@EXAMPLE.COM", - age: 30, - isVerified: true, - }) - .exec(); - - expect(invalidEmailUser).not.toBe(null); - expect(invalidEmailUser.email).toBe("test@example.com"); - - // Test edge case: Insert document with extra fields - const extraFieldsUser = await collections.users - .insertOne({ - name: "Extra", - email: "extra@example.com", - age: 40, - isVerified: true, - extraField: "This should be ignored", - } as any) - .exec(); - - expect(extraFieldsUser).not.toBe(null); - expect(extraFieldsUser).not.toHaveProperty("extraField"); - }); - - it("inserts many documents", async () => { - const newUsers = await collections.users.insertMany(mockUsers).exec(); - - expect(newUsers.insertedCount).toBe(mockUsers.length); - }); - - it("finds documents", async () => { - await collections.users.insertMany(mockUsers).exec(); - - const users = await collections.users.find().exec(); - expect(users.length).toBeGreaterThanOrEqual(3); - }); - - it("finds documents with cursor", async () => { - await collections.users.insertMany(mockUsers).exec(); - - const users1 = await collections.users.find().cursor(); - expect(await users1.next()).toMatchObject(mockUsers[0]); - expect(await users1.next()).toMatchObject(mockUsers[1]); - expect(await users1.next()).toMatchObject(mockUsers[2]); - expect(await users1.next()).toBe(null); - - const users2 = await collections.users.find().cursor(); - let i = 0; - for await (const user of users2) { - expect(user).toMatchObject(mockUsers[i++]); - } - }); - - it("finds one document", async () => { - await collections.users.insertOne(mockUsers[0]).exec(); - - const user = await collections.users.findOne({}).exec(); - expect(user).toStrictEqual(expect.objectContaining(mockUsers[0])); - - const userId = new ObjectId(); - const todoId = 1; - await collections.users.insertOne({ _id: userId, ...mockUsers[0] }).exec(); - await collections.todos.insertOne({ _id: todoId, title: "todo 1", userId }).exec(); - - // find with object id - const user1 = await collections.users.findOne({ _id: userId }).exec(); - expect(user1).toStrictEqual({ _id: userId, ...mockUsers[0] }); - - // find with string id - const user2 = await collections.users - //@ts-expect-error - .findOne({ _id: userId.toString() }) - .exec(); - expect(user2).toBe(null); - - // find with non object id - const todo = await collections.todos.findOne({ _id: todoId }).exec(); - expect(todo).toStrictEqual({ _id: todoId, title: "todo 1", userId }); - }); - - it("finds one document by id", async () => { - const userId = new ObjectId(); - const todoId = 1; - await collections.users.insertOne({ _id: userId, ...mockUsers[0] }).exec(); - await collections.todos.insertOne({ _id: todoId, title: "todo 1", userId }).exec(); - - // find with object id - const user1 = await collections.users.findById(userId).exec(); - expect(user1).toStrictEqual({ _id: userId, ...mockUsers[0] }); - - // find with string id - const user2 = await collections.users.findById(userId.toString()).exec(); - expect(user2).toStrictEqual({ _id: userId, ...mockUsers[0] }); - - // find with invalid object id - await expect(async () => { - await collections.users.findById("not_an_object_id").exec(); - }).rejects.toThrow(); - - // find with non object id - const todo1 = await collections.todos.findById(todoId).exec(); - expect(todo1).toStrictEqual({ _id: todoId, title: "todo 1", userId }); - - const todo2 = await collections.todos.findById(todoId + 1).exec(); - expect(todo2).toBe(null); - }); - - describe("Base Query methods", () => { - it("query where with single condition", async () => { - await collections.users.insertMany(mockUsers).exec(); - const users = await collections.users.find().exec(); - expect(users.length).toBeGreaterThanOrEqual(mockUsers.length); - - const firstUser = await collections.users.findOne({ name: "anon" }).exec(); - expect(firstUser?.name).toBe("anon"); - }); - - it("query where with multiple conditions", async () => { - await collections.users.insertMany(mockUsers).exec(); - - const users = await collections.users.find({ name: "anon", age: 17 }).exec(); - expect(users.length).toBe(1); - }); - - it("query select/omit", async () => { - await collections.users.insertMany(mockUsers).exec(); - - const users1 = await collections.users.find().select({ name: true, email: true }).exec(); - expect(users1[0].name).toBe("anon"); - expect(users1[0].email).toBe("anon@gmail.com"); - // @ts-expect-error - expect(users1[0].age).toBeUndefined(); - // @ts-expect-error - expect(users1[0].isVerified).toBeUndefined(); - - const users2 = await collections.users.find().omit({ name: true, email: true }).exec(); - // @ts-expect-error - expect(users2[0].name).toBeUndefined(); - // @ts-expect-error - expect(users2[0].email).toBeUndefined(); - expect(users2[0].age).toBe(17); - expect(users2[0].isVerified).toBe(true); - }); - - it("query limit", async () => { - await collections.users.insertMany(mockUsers).exec(); - const limit = 2; - const users = await collections.users.find().limit(limit).exec(); - expect(users.length).toBe(limit); - }); - - it("query skip", async () => { - await collections.users.insertMany(mockUsers).exec(); - const skip = 2; - const users = await collections.users.find().skip(skip).exec(); - expect(users.length).toBe(mockUsers.length - skip); - }); - - it("query sort", async () => { - await collections.users.insertMany(mockUsers).exec(); - const users = await collections.users.find().sort({ - age: -1, - }); - expect(users[0].age).toBe(25); - expect(users[1].age).toBe(20); - expect(users[2].age).toBe(17); - - const users2 = await collections.users - .find() - .sort({ - email: "asc", - }) - .exec(); - expect(users2[0].email).toBe("anon1@gmail.com"); - expect(users2[1].email).toBe("anon2@gmail.com"); - expect(users2[2].email).toBe("anon@gmail.com"); - }); - }); - - it("gets distinct values", async () => { - await collections.users.insertOne(mockUsers[0]).exec(); - - const distinctEmails = await collections.users.distinct("age").exec(); - // const distinctEmails = await collections.users.fakeDistinct("email"); - - expect(distinctEmails).not.toBe(null); - }); - - it("finds one and updates", async () => { - await collections.users.insertOne(mockUsers[0]).exec(); - - const updatedUser = await collections.users - .findOneAndUpdate( - { email: "anon@gmail.com" }, - { - $set: { - age: 30, - }, - }, - ) - .options({ - returnDocument: "after", - }) - .exec(); - - expect(updatedUser).not.toBe(null); - expect(updatedUser?.age).toBe(30); - }); - - it("finds one and deletes", async () => { - await collections.users.insertOne(mockUsers[0]).exec(); - - const deletedUser = await collections.users.findOneAndDelete({ email: "anon@gmail.com" }).exec(); - - expect(deletedUser).not.toBe(null); - expect(deletedUser?.email).toBe("anon@gmail.com"); - }); - - it("updates one document", async () => { - await collections.users.insertOne(mockUsers[1]).exec(); - const updated = await collections.users.updateOne({ email: "anon1@gmail.com" }, { $set: { age: 35 } }).exec(); - - expect(updated.acknowledged).toBe(true); - }); - - it("updates many documents", async () => { - await collections.users.insertMany(mockUsers).exec(); - const updated = await collections.users.updateMany({ isVerified: false }, { $set: { age: 40 } }).exec(); - - expect(updated.acknowledged).toBe(true); - }); - - it("replaces one document", async () => { - const original = await collections.users.insertOne(mockUsers[0]).exec(); - const replaced = await collections.users - .replaceOne( - { email: "anon@gmail.com" }, - { - ...original, - name: "New Name", - }, - ) - .exec(); - - expect(replaced.modifiedCount).toBe(1); - }); - - it("deletes one document", async () => { - await collections.users.insertOne(mockUsers[2]).exec(); - const deleted = await collections.users.deleteOne({ email: "anon2@gmail.com" }).exec(); - - expect(deleted.deletedCount).toBe(1); - }); - - it("countDocuments", async () => { - await collections.users.insertMany(mockUsers).exec(); - const count = await collections.users.countDocuments(); - expect(count).toBeGreaterThanOrEqual(2); - }); - - it("estimatedDocumentCount", async () => { - await collections.users.insertMany(mockUsers).exec(); - const estimatedCount = await collections.users.estimatedDocumentCount(); - - expect(estimatedCount).toBe(3); - }); - - it("bulk writes", async () => { - const bulkWriteResult = await collections.users - .bulkWrite([ - { - insertOne: { - document: { - name: "bulk1", - email: "bulk1@gmail.com", - age: 22, - isVerified: false, - }, - }, - }, - { - insertOne: { - document: { - name: "bulk2", - email: "bulk2@gmail.com", - age: 23, - isVerified: true, - }, - }, - }, - ]) - .exec(); - - expect(bulkWriteResult.insertedCount).toBe(2); - }); - - it("aggregates data", async () => { - await collections.users.insertMany(mockUsers).exec(); - const result = await collections.users - .aggregate() - .addStage({ $match: { isVerified: true } }) - .addStage({ $group: { _id: "$isVerified", count: { $sum: 1 } } }) - .exec(); - - expect(result).toBeInstanceOf(Array); - expect(result.length).toBeGreaterThanOrEqual(1); - }); - - it("executes raw MongoDB operations", async () => { - const result = await collections.users.raw().find().toArray(); - expect(result).toBeInstanceOf(Array); - }); - - it("updates after initial save", async () => { - const schema = createSchema("users", { - name: string(), - age: number().onUpdate(() => 100), - isAdmin: boolean(), - }); - const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - age: 0, - isAdmin: true, - }) - .exec(); - const doc = await db.collections.users.findOne({ _id: res._id }).exec(); - expect(doc).toStrictEqual({ - _id: res._id, - name: "tom", - age: 0, - isAdmin: true, - }); - const updatedDoc = await db.collections.users - .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) - .options({ - returnDocument: "after", - }) - .exec(); - expect(updatedDoc).toStrictEqual({ - _id: res._id, - name: "jerry", - age: 100, - isAdmin: true, - }); - }); - - it("updates with transform", async () => { - let nonce = 1; - const onUpdateTrap = vi.fn(() => nonce++); - const transformTrap = vi.fn((val: number) => String(val)); - const schema = createSchema("users", { - name: string(), - nonce: number().onUpdate(onUpdateTrap).transform(transformTrap), - }); - const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: 0, - }) - .exec(); - expect(onUpdateTrap).toBeCalledTimes(0); - expect(transformTrap).toBeCalledTimes(1); - expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: "0" }); - - const updatedDoc = await db.collections.users - .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) - .options({ - returnDocument: "after", - }) - .exec(); - expect(onUpdateTrap).toBeCalledTimes(1); - expect(transformTrap).toBeCalledTimes(2); - expect(updatedDoc).toStrictEqual({ - _id: res._id, - name: "jerry", - nonce: "1", - }); - }); - - it("updates with validate", async () => { - let nonce = 1; - const onUpdateTrap = vi.fn(() => nonce++); - const schema = createSchema("users", { - name: string(), - nonce: number() - .onUpdate(onUpdateTrap) - .validate(() => true, ""), - }); - const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: 0, - }) - .exec(); - expect(onUpdateTrap).toBeCalledTimes(0); - expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: 0 }); - - const updatedDoc = await db.collections.users - .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) - .options({ - returnDocument: "after", - }) - .exec(); - expect(onUpdateTrap).toBeCalledTimes(1); - expect(updatedDoc).toStrictEqual({ - _id: res._id, - name: "jerry", - nonce: 1, - }); - }); - - it("updates with optional", async () => { - let nonce = 1; - const onUpdateTrap = vi.fn(() => nonce++); - const schema = createSchema("users", { - name: string(), - nonce: number().onUpdate(onUpdateTrap).optional(), - }); - const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - }) - .exec(); - expect(onUpdateTrap).toBeCalledTimes(0); - expect(res).toStrictEqual({ _id: res._id, name: "tom" }); - - const updatedDoc = await db.collections.users - .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) - .options({ - returnDocument: "after", - }) - .exec(); - expect(onUpdateTrap).toBeCalledTimes(1); - expect(updatedDoc).toStrictEqual({ - _id: res._id, - name: "jerry", - nonce: 1, - }); - }); - - it("updates with nullable", async () => { - let nonce = 1; - const onUpdateTrap = vi.fn(() => nonce++); - const schema = createSchema("users", { - name: string(), - nonce: number().onUpdate(onUpdateTrap).nullable(), - }); - const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: null, - }) - .exec(); - expect(onUpdateTrap).toBeCalledTimes(0); - expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: null }); - - const updatedDoc = await db.collections.users - .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) - .options({ - returnDocument: "after", - }) - .exec(); - expect(onUpdateTrap).toBeCalledTimes(1); - expect(updatedDoc).toStrictEqual({ - _id: res._id, - name: "jerry", - nonce: 1, - }); - }); - - it("updates with defaulted", async () => { - let nonce = 1; - const onUpdateTrap = vi.fn(() => nonce++); - const schema = createSchema("users", { - name: string(), - nonce: number().onUpdate(onUpdateTrap).default(0), - }); - const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - }) - .exec(); - expect(onUpdateTrap).toBeCalledTimes(0); - expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: 0 }); - - const updatedDoc = await db.collections.users - .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) - .options({ - returnDocument: "after", - }) - .exec(); - expect(onUpdateTrap).toBeCalledTimes(1); - expect(updatedDoc).toStrictEqual({ - _id: res._id, - name: "jerry", - nonce: 1, - }); - }); - - it("updates with pipe", async () => { - let nonce = 1; - const onUpdateTrap = vi.fn(() => nonce++); - const schema = createSchema("users", { - name: string(), - nonce: pipe( - type((input: number) => String(input)), - string(), - ).onUpdate(onUpdateTrap), - }); - const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: 0, - }) - .exec(); - expect(onUpdateTrap).toBeCalledTimes(0); - expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: "0" }); - - const updatedDoc = await db.collections.users - .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) - .options({ - returnDocument: "after", - }) - .exec(); - expect(onUpdateTrap).toBeCalledTimes(1); - expect(updatedDoc).toStrictEqual({ - _id: res._id, - name: "jerry", - nonce: "1", - }); - }); -}); diff --git a/tests/query/aggregate.test.ts b/tests/query/aggregate.test.ts new file mode 100644 index 0000000..b6c7120 --- /dev/null +++ b/tests/query/aggregate.test.ts @@ -0,0 +1,48 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, createSchema } from "../../src"; +import { boolean, number, string } from "../../src/types"; +import { createMockDatabase, mockUsers } from "../mock"; + +describe("Aggregation Operations", async () => { + const { server, client } = await createMockDatabase(); + + const UserSchema = createSchema("users", { + name: string().optional(), + email: string().lowercase().optional(), + age: number().optional().default(10), + isVerified: boolean().default(false), + }); + + const { collections } = createDatabase(client.db(), { + users: UserSchema, + }); + + beforeAll(async () => { + await client.connect(); + }); + + afterEach(async () => { + await collections.users.deleteMany({}).exec(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + it("aggregates data", async () => { + await collections.users.insertMany(mockUsers).exec(); + const result = await collections.users + .aggregate() + .addStage({ $match: { isVerified: true } }) + .addStage({ $group: { _id: "$isVerified", count: { $sum: 1 } } }) + .exec(); + expect(result).toBeInstanceOf(Array); + expect(result.length).toBeGreaterThanOrEqual(1); + }); + + it("executes raw MongoDB operations", async () => { + const result = await collections.users.raw().find().toArray(); + expect(result).toBeInstanceOf(Array); + }); +}); diff --git a/tests/query/delete.test.ts b/tests/query/delete.test.ts new file mode 100644 index 0000000..af7060f --- /dev/null +++ b/tests/query/delete.test.ts @@ -0,0 +1,45 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, createSchema } from "../../src"; +import { boolean, number, string } from "../../src/types"; +import { createMockDatabase, mockUsers } from "../mock"; + +describe("Delete Operations", async () => { + const { server, client } = await createMockDatabase(); + + const UserSchema = createSchema("users", { + name: string().optional(), + email: string().lowercase().optional(), + age: number().optional().default(10), + isVerified: boolean().default(false), + }); + + const { collections } = createDatabase(client.db(), { + users: UserSchema, + }); + + beforeAll(async () => { + await client.connect(); + }); + + afterEach(async () => { + await collections.users.deleteMany({}).exec(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + it("finds one and deletes", async () => { + await collections.users.insertOne(mockUsers[0]).exec(); + const deletedUser = await collections.users.findOneAndDelete({ email: "anon@gmail.com" }).exec(); + expect(deletedUser).not.toBe(null); + expect(deletedUser?.email).toBe("anon@gmail.com"); + }); + + it("deletes one document", async () => { + await collections.users.insertOne(mockUsers[2]).exec(); + const deleted = await collections.users.deleteOne({ email: "anon2@gmail.com" }).exec(); + expect(deleted.deletedCount).toBe(1); + }); +}); diff --git a/tests/query/insert-find.test.ts b/tests/query/insert-find.test.ts new file mode 100644 index 0000000..2f86eb0 --- /dev/null +++ b/tests/query/insert-find.test.ts @@ -0,0 +1,264 @@ +import { ObjectId } from "mongodb"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, createSchema } from "../../src"; +import { boolean, number, objectId, string } from "../../src/types"; +import { createMockDatabase, mockUsers } from "../mock"; + +describe("Insert and Find Operations", async () => { + const { server, client } = await createMockDatabase(); + + const UserSchema = createSchema("users", { + name: string().optional(), + email: string().lowercase().optional(), + age: number().optional().default(10), + isVerified: boolean().default(false), + }); + + const TodoSchema = createSchema("todos", { + _id: number(), + title: string(), + userId: objectId(), + }); + + const { collections } = createDatabase(client.db(), { + users: UserSchema, + todos: TodoSchema, + }); + + beforeAll(async () => { + await client.connect(); + }); + + afterEach(async () => { + await collections.users.deleteMany({}).exec(); + await collections.todos.deleteMany({}).exec(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + describe("Insert Operations", () => { + it("inserts one document with auto-generated ObjectId", async () => { + const newUser1 = await collections.users.insertOne(mockUsers[0]).exec(); + expect(newUser1).toMatchObject(mockUsers[0]); + expect(newUser1._id).toBeDefined(); + expect(newUser1._id).toBeInstanceOf(ObjectId); + + const newUser2 = await collections.users.insertOne(mockUsers[0]).exec(); + expect(newUser2).toMatchObject(mockUsers[0]); + expect(newUser2._id).toBeDefined(); + expect(newUser2._id).toBeInstanceOf(ObjectId); + expect(newUser2._id).not.toStrictEqual(newUser1._id); + }); + + it("inserts one document with provided ObjectId", async () => { + const id = new ObjectId(); + const newUser = await collections.users.insertOne({ _id: id, ...mockUsers[0] }).exec(); + expect(newUser).toMatchObject(mockUsers[0]); + expect(newUser._id).toStrictEqual(id); + }); + + it("inserts one document with string ObjectId", async () => { + const id = new ObjectId(); + const newUser = await collections.users.insertOne({ _id: id.toString(), ...mockUsers[0] }).exec(); + expect(newUser).toMatchObject(mockUsers[0]); + expect(newUser._id).toStrictEqual(id); + }); + + it("supports promise resolution without exec", async () => { + const newUser = await collections.users.insertOne(mockUsers[0]); + expect(newUser).toMatchObject(mockUsers[0]); + }); + + it("rejects invalid ObjectId string", async () => { + await expect(async () => { + await collections.users.insertOne({ _id: "not_an_object_id", ...mockUsers[0] }).exec(); + }).rejects.toThrowError("expected valid ObjectId received"); + }); + + it("inserts empty document with default values", async () => { + const emptyUser = await collections.users.insertOne({}).exec(); + expect(emptyUser).not.toBe(null); + expect(emptyUser.age).toBe(10); + expect(emptyUser.isVerified).toBe(false); + }); + + it("applies transformations on insert", async () => { + const user = await collections.users + .insertOne({ + name: "Test", + email: "TEST@EXAMPLE.COM", + age: 30, + isVerified: true, + }) + .exec(); + expect(user).not.toBe(null); + expect(user.email).toBe("test@example.com"); + }); + + it("strips extra fields not in schema", async () => { + const user = await collections.users + .insertOne({ + name: "Extra", + email: "extra@example.com", + age: 40, + isVerified: true, + extraField: "This should be ignored", + } as any) + .exec(); + expect(user).not.toBe(null); + expect(user).not.toHaveProperty("extraField"); + }); + + it("inserts many documents", async () => { + const newUsers = await collections.users.insertMany(mockUsers).exec(); + expect(newUsers.insertedCount).toBe(mockUsers.length); + }); + + it("bulk writes", async () => { + const bulkWriteResult = await collections.users + .bulkWrite([ + { + insertOne: { + document: { + name: "bulk1", + email: "bulk1@gmail.com", + age: 22, + isVerified: false, + }, + }, + }, + { + insertOne: { + document: { + name: "bulk2", + email: "bulk2@gmail.com", + age: 23, + isVerified: true, + }, + }, + }, + ]) + .exec(); + expect(bulkWriteResult.insertedCount).toBe(2); + }); + }); + + describe("Find Operations", () => { + it("finds documents", async () => { + await collections.users.insertMany(mockUsers).exec(); + const users = await collections.users.find().exec(); + expect(users.length).toBeGreaterThanOrEqual(3); + }); + + it("finds documents with cursor", async () => { + await collections.users.insertMany(mockUsers).exec(); + + const users1 = await collections.users.find().cursor(); + expect(await users1.next()).toMatchObject(mockUsers[0]); + expect(await users1.next()).toMatchObject(mockUsers[1]); + expect(await users1.next()).toMatchObject(mockUsers[2]); + expect(await users1.next()).toBe(null); + + const users2 = await collections.users.find().cursor(); + let i = 0; + for await (const user of users2) { + expect(user).toMatchObject(mockUsers[i++]); + } + }); + + it("finds one document without filter", async () => { + await collections.users.insertOne(mockUsers[0]).exec(); + const user = await collections.users.findOne({}).exec(); + expect(user).toStrictEqual(expect.objectContaining(mockUsers[0])); + }); + + it("finds one document with ObjectId filter", async () => { + const userId = new ObjectId(); + await collections.users.insertOne({ _id: userId, ...mockUsers[0] }).exec(); + + const user = await collections.users.findOne({ _id: userId }).exec(); + expect(user).toStrictEqual({ _id: userId, ...mockUsers[0] }); + }); + + it("does not find document when using string instead of ObjectId in filter", async () => { + const userId = new ObjectId(); + await collections.users.insertOne({ _id: userId, ...mockUsers[0] }).exec(); + + const user = await collections.users + //@ts-expect-error + .findOne({ _id: userId.toString() }) + .exec(); + expect(user).toBe(null); + }); + + it("finds one document with non-ObjectId primary key", async () => { + const todoId = 1; + const userId = new ObjectId(); + await collections.todos.insertOne({ _id: todoId, title: "todo 1", userId }).exec(); + + const todo = await collections.todos.findOne({ _id: todoId }).exec(); + expect(todo).toStrictEqual({ _id: todoId, title: "todo 1", userId }); + }); + + it("finds one document by ObjectId", async () => { + const userId = new ObjectId(); + await collections.users.insertOne({ _id: userId, ...mockUsers[0] }).exec(); + + const user = await collections.users.findById(userId).exec(); + expect(user).toStrictEqual({ _id: userId, ...mockUsers[0] }); + }); + + it("finds one document by ObjectId string", async () => { + const userId = new ObjectId(); + await collections.users.insertOne({ _id: userId, ...mockUsers[0] }).exec(); + + const user = await collections.users.findById(userId.toString()).exec(); + expect(user).toStrictEqual({ _id: userId, ...mockUsers[0] }); + }); + + it("rejects invalid ObjectId string in findById", async () => { + await expect(async () => { + await collections.users.findById("not_an_object_id").exec(); + }).rejects.toThrowError(); + }); + + it("finds one document by non-ObjectId primary key", async () => { + const todoId = 1; + const userId = new ObjectId(); + await collections.todos.insertOne({ _id: todoId, title: "todo 1", userId }).exec(); + + const todo = await collections.todos.findById(todoId).exec(); + expect(todo).toStrictEqual({ _id: todoId, title: "todo 1", userId }); + }); + + it("returns null when document not found by id", async () => { + const todoId = 1; + const userId = new ObjectId(); + await collections.todos.insertOne({ _id: todoId, title: "todo 1", userId }).exec(); + + const todo = await collections.todos.findById(todoId + 1).exec(); + expect(todo).toBe(null); + }); + + it("gets distinct values", async () => { + await collections.users.insertOne(mockUsers[0]).exec(); + const distinctEmails = await collections.users.distinct("age").exec(); + expect(distinctEmails).not.toBe(null); + }); + + it("countDocuments", async () => { + await collections.users.insertMany(mockUsers).exec(); + const count = await collections.users.countDocuments(); + expect(count).toBeGreaterThanOrEqual(2); + }); + + it("estimatedDocumentCount", async () => { + await collections.users.insertMany(mockUsers).exec(); + const estimatedCount = await collections.users.estimatedDocumentCount(); + expect(estimatedCount).toBe(3); + }); + }); +}); diff --git a/tests/query/query-methods.test.ts b/tests/query/query-methods.test.ts new file mode 100644 index 0000000..09695ce --- /dev/null +++ b/tests/query/query-methods.test.ts @@ -0,0 +1,104 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, createSchema } from "../../src"; +import { boolean, number, string } from "../../src/types"; +import { createMockDatabase, mockUsers } from "../mock"; + +describe("Query Methods", async () => { + const { server, client } = await createMockDatabase(); + + const UserSchema = createSchema("users", { + name: string().optional(), + email: string().lowercase().optional(), + age: number().optional().default(10), + isVerified: boolean().default(false), + }); + + const { collections } = createDatabase(client.db(), { + users: UserSchema, + }); + + beforeAll(async () => { + await client.connect(); + }); + + afterEach(async () => { + await collections.users.deleteMany({}).exec(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + it("queries with single where condition", async () => { + await collections.users.insertMany(mockUsers).exec(); + + const firstUser = await collections.users.findOne({ name: "anon" }).exec(); + expect(firstUser?.name).toBe("anon"); + }); + + it("queries with multiple where conditions", async () => { + await collections.users.insertMany(mockUsers).exec(); + + const users = await collections.users.find({ name: "anon", age: 17 }).exec(); + expect(users.length).toBe(1); + }); + + it("selects specific fields", async () => { + await collections.users.insertMany(mockUsers).exec(); + + const users = await collections.users.find().select({ name: true, email: true }).exec(); + expect(users[0].name).toBe("anon"); + expect(users[0].email).toBe("anon@gmail.com"); + // @ts-expect-error + expect(users[0].age).toBeUndefined(); + // @ts-expect-error + expect(users[0].isVerified).toBeUndefined(); + }); + + it("omits specific fields", async () => { + await collections.users.insertMany(mockUsers).exec(); + + const users = await collections.users.find().omit({ name: true, email: true }).exec(); + // @ts-expect-error + expect(users[0].name).toBeUndefined(); + // @ts-expect-error + expect(users[0].email).toBeUndefined(); + expect(users[0].age).toBe(17); + expect(users[0].isVerified).toBe(true); + }); + + it("limits query results", async () => { + await collections.users.insertMany(mockUsers).exec(); + + const limit = 2; + const users = await collections.users.find().limit(limit).exec(); + expect(users.length).toBe(limit); + }); + + it("skips query results", async () => { + await collections.users.insertMany(mockUsers).exec(); + + const skip = 2; + const users = await collections.users.find().skip(skip).exec(); + expect(users.length).toBe(mockUsers.length - skip); + }); + + it("sorts by numeric field descending", async () => { + await collections.users.insertMany(mockUsers).exec(); + + const users = await collections.users.find().sort({ age: -1 }); + expect(users[0].age).toBe(25); + expect(users[1].age).toBe(20); + expect(users[2].age).toBe(17); + }); + + it("sorts by string field ascending", async () => { + await collections.users.insertMany(mockUsers).exec(); + + const users = await collections.users.find().sort({ email: "asc" }).exec(); + expect(users[0].email).toBe("anon1@gmail.com"); + expect(users[1].email).toBe("anon2@gmail.com"); + expect(users[2].email).toBe("anon@gmail.com"); + }); +}); diff --git a/tests/query/update-hooks.test.ts b/tests/query/update-hooks.test.ts new file mode 100644 index 0000000..b15a936 --- /dev/null +++ b/tests/query/update-hooks.test.ts @@ -0,0 +1,510 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { createDatabase, createSchema } from "../../src"; +import { boolean, number, pipe, string, type } from "../../src/types"; +import { createMockDatabase } from "../mock"; + +describe("Update Hooks", async () => { + const { server, client } = await createMockDatabase(); + + beforeAll(async () => { + await client.connect(); + }); + + afterEach(async () => { + await client.db().dropDatabase(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + it("updates after initial save", async () => { + const schema = createSchema("users", { + name: string(), + age: number().onUpdate(() => 100), + isAdmin: boolean(), + }); + const db = createDatabase(client.db(), { users: schema }); + const res = await db.collections.users + .insertOne({ + name: "tom", + age: 0, + isAdmin: true, + }) + .exec(); + const doc = await db.collections.users.findOne({ _id: res._id }).exec(); + expect(doc).toStrictEqual({ + _id: res._id, + name: "tom", + age: 0, + isAdmin: true, + }); + const updatedDoc = await db.collections.users + .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) + .options({ + returnDocument: "after", + }) + .exec(); + expect(updatedDoc).toStrictEqual({ + _id: res._id, + name: "jerry", + age: 100, + isAdmin: true, + }); + }); + + it("updates with transform", async () => { + let nonce = 1; + const onUpdateTrap = vi.fn(() => nonce++); + const transformTrap = vi.fn((val: number) => String(val)); + const schema = createSchema("users", { + name: string(), + nonce: number().onUpdate(onUpdateTrap).transform(transformTrap), + }); + const db = createDatabase(client.db(), { users: schema }); + const res = await db.collections.users + .insertOne({ + name: "tom", + nonce: 0, + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(0); + expect(transformTrap).toBeCalledTimes(1); + expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: "0" }); + + const updatedDoc = await db.collections.users + .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) + .options({ + returnDocument: "after", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(1); + expect(transformTrap).toBeCalledTimes(2); + expect(updatedDoc).toStrictEqual({ + _id: res._id, + name: "jerry", + nonce: "1", + }); + }); + + it("updates with validate", async () => { + let nonce = 1; + const onUpdateTrap = vi.fn(() => nonce++); + const schema = createSchema("users", { + name: string(), + nonce: number() + .onUpdate(onUpdateTrap) + .validate(() => true, ""), + }); + const db = createDatabase(client.db(), { users: schema }); + const res = await db.collections.users + .insertOne({ + name: "tom", + nonce: 0, + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(0); + expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: 0 }); + + const updatedDoc = await db.collections.users + .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) + .options({ + returnDocument: "after", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(1); + expect(updatedDoc).toStrictEqual({ + _id: res._id, + name: "jerry", + nonce: 1, + }); + }); + + it("updates with optional", async () => { + let nonce = 1; + const onUpdateTrap = vi.fn(() => nonce++); + const schema = createSchema("users", { + name: string(), + nonce: number().onUpdate(onUpdateTrap).optional(), + }); + const db = createDatabase(client.db(), { users: schema }); + const res = await db.collections.users + .insertOne({ + name: "tom", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(0); + expect(res).toStrictEqual({ _id: res._id, name: "tom" }); + + const updatedDoc = await db.collections.users + .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) + .options({ + returnDocument: "after", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(1); + expect(updatedDoc).toStrictEqual({ + _id: res._id, + name: "jerry", + nonce: 1, + }); + }); + + it("updates with nullable", async () => { + let nonce = 1; + const onUpdateTrap = vi.fn(() => nonce++); + const schema = createSchema("users", { + name: string(), + nonce: number().onUpdate(onUpdateTrap).nullable(), + }); + const db = createDatabase(client.db(), { users: schema }); + const res = await db.collections.users + .insertOne({ + name: "tom", + nonce: null, + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(0); + expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: null }); + + const updatedDoc = await db.collections.users + .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) + .options({ + returnDocument: "after", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(1); + expect(updatedDoc).toStrictEqual({ + _id: res._id, + name: "jerry", + nonce: 1, + }); + }); + + it("updates with defaulted", async () => { + let nonce = 1; + const onUpdateTrap = vi.fn(() => nonce++); + const schema = createSchema("users", { + name: string(), + nonce: number().onUpdate(onUpdateTrap).default(0), + }); + const db = createDatabase(client.db(), { users: schema }); + const res = await db.collections.users + .insertOne({ + name: "tom", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(0); + expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: 0 }); + + const updatedDoc = await db.collections.users + .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) + .options({ + returnDocument: "after", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(1); + expect(updatedDoc).toStrictEqual({ + _id: res._id, + name: "jerry", + nonce: 1, + }); + }); + + it("updates with pipe", async () => { + let nonce = 1; + const onUpdateTrap = vi.fn(() => nonce++); + const schema = createSchema("users", { + name: string(), + nonce: pipe( + type((input: number) => String(input)), + string(), + ).onUpdate(onUpdateTrap), + }); + const db = createDatabase(client.db(), { users: schema }); + const res = await db.collections.users + .insertOne({ + name: "tom", + nonce: 0, + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(0); + expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: "0" }); + + const updatedDoc = await db.collections.users + .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) + .options({ + returnDocument: "after", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(1); + expect(updatedDoc).toStrictEqual({ + _id: res._id, + name: "jerry", + nonce: "1", + }); + }); + + it("onUpdate chained before transform applies transform to updated value", async () => { + let nonce = 100; + const onUpdateTrap = vi.fn(() => nonce++); + const transformTrap = vi.fn((val: number) => String(val)); + const schema = createSchema("users", { + name: string(), + nonce: number().onUpdate(onUpdateTrap).transform(transformTrap), + }); + const db = createDatabase(client.db(), { users: schema }); + + // Insert initial document + const res = await db.collections.users + .insertOne({ + name: "tom", + nonce: 50, + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(0); + expect(transformTrap).toBeCalledTimes(1); + expect(transformTrap).toHaveBeenNthCalledWith(1, 50); + expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: "50" }); + + // Update document - onUpdate should trigger and transform should be applied to the updated value + const updatedDoc = await db.collections.users + .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) + .options({ + returnDocument: "after", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(1); + expect(onUpdateTrap).toHaveReturnedWith(100); + expect(transformTrap).toBeCalledTimes(2); + expect(transformTrap).toHaveBeenNthCalledWith(2, 100); + expect(updatedDoc).toStrictEqual({ + _id: res._id, + name: "jerry", + nonce: "100", + }); + }); + + it("onUpdate chained after transform still applies transform to updated value", async () => { + let nonce = 100; + const onUpdateTrap = vi.fn(() => nonce++); + const transformTrap = vi.fn((val: number) => String(val)); + const schema = createSchema("users", { + name: string(), + nonce: number().transform(transformTrap).onUpdate(onUpdateTrap), + }); + const db = createDatabase(client.db(), { users: schema }); + + // Insert initial document + const res = await db.collections.users + .insertOne({ + name: "tom", + nonce: 50, + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(0); + expect(transformTrap).toBeCalledTimes(1); + expect(transformTrap).toHaveBeenNthCalledWith(1, 50); + expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: "50" }); + + // Update document - onUpdate creates updater using transformed parser, so transform IS applied + const updatedDoc = await db.collections.users + .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) + .options({ + returnDocument: "after", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(1); + expect(onUpdateTrap).toHaveReturnedWith(100); + // Transform IS called because onUpdate uses the transformed parser + expect(transformTrap).toBeCalledTimes(2); + expect(transformTrap).toHaveBeenNthCalledWith(2, 100); + expect(updatedDoc).toStrictEqual({ + _id: res._id, + name: "jerry", + nonce: "100", // Transformed to string + }); + }); + + it("onUpdate chained before validate does NOT apply validate to updated value", async () => { + let nonce = 100; + const onUpdateTrap = vi.fn(() => nonce++); + const validateTrap = vi.fn((val: number) => val >= 0 && val <= 50); + const schema = createSchema("users", { + name: string(), + nonce: number().onUpdate(onUpdateTrap).validate(validateTrap, "nonce must be between 0 and 50"), + }); + const db = createDatabase(client.db(), { users: schema }); + + // Insert initial document with valid value + const res = await db.collections.users + .insertOne({ + name: "tom", + nonce: 25, + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(0); + expect(validateTrap).toBeCalledTimes(1); + expect(validateTrap).toHaveBeenNthCalledWith(1, 25); + expect(validateTrap).toHaveReturnedWith(true); + expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: 25 }); + + // Update document - onUpdate returns 100 which is > 50, but validation should NOT be applied + const updatedDoc = await db.collections.users + .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) + .options({ + returnDocument: "after", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(1); + expect(onUpdateTrap).toHaveReturnedWith(100); + // Validate should NOT be called on update value + expect(validateTrap).toBeCalledTimes(1); + expect(updatedDoc).toStrictEqual({ + _id: res._id, + name: "jerry", + nonce: 100, // Value that would fail validation if it were applied + }); + }); + + it("onUpdate chained after validate still applies validate to updated value", async () => { + let nonce = 10; + const onUpdateTrap = vi.fn(() => nonce++); + const validateTrap = vi.fn((val: number) => val >= 0 && val <= 50); + const schema = createSchema("users", { + name: string(), + nonce: number().validate(validateTrap, "nonce must be between 0 and 50").onUpdate(onUpdateTrap), + }); + const db = createDatabase(client.db(), { users: schema }); + + // Insert initial document with valid value + const res = await db.collections.users + .insertOne({ + name: "tom", + nonce: 25, + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(0); + expect(validateTrap).toBeCalledTimes(1); + expect(validateTrap).toHaveBeenNthCalledWith(1, 25); + expect(validateTrap).toHaveReturnedWith(true); + expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: 25 }); + + // Update document - onUpdate creates updater using validated parser, so validate IS applied + const updatedDoc = await db.collections.users + .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) + .options({ + returnDocument: "after", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(1); + expect(onUpdateTrap).toHaveReturnedWith(10); + // Validate IS called because onUpdate uses the validated parser + expect(validateTrap).toBeCalledTimes(2); + expect(validateTrap).toHaveBeenNthCalledWith(2, 10); + expect(validateTrap).toHaveReturnedWith(true); + expect(updatedDoc).toStrictEqual({ + _id: res._id, + name: "jerry", + nonce: 10, // Valid value that passed validation + }); + }); + + it("complex chaining: transform -> onUpdate -> validate applies transform but not validate to update", async () => { + let nonce = 5; + const onUpdateTrap = vi.fn(() => nonce++); + const transformTrap = vi.fn((val: number) => String(val)); + const validateTrap = vi.fn((val: string) => val.length <= 2); + const schema = createSchema("users", { + name: string(), + nonce: number() + .transform(transformTrap) + .onUpdate(onUpdateTrap) + .validate(validateTrap, "transformed value must have length <= 2"), + }); + const db = createDatabase(client.db(), { users: schema }); + + // Insert initial document + const res = await db.collections.users + .insertOne({ + name: "tom", + nonce: 7, + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(0); + expect(transformTrap).toBeCalledTimes(1); + expect(transformTrap).toHaveBeenNthCalledWith(1, 7); + expect(validateTrap).toBeCalledTimes(1); + expect(validateTrap).toHaveBeenNthCalledWith(1, "7"); + expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: "7" }); + + // Update document - transform IS applied (from before onUpdate), validate is NOT (after onUpdate) + const updatedDoc = await db.collections.users + .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) + .options({ + returnDocument: "after", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(1); + expect(onUpdateTrap).toHaveReturnedWith(5); + // Transform IS called (onUpdate uses transformed parser) + expect(transformTrap).toBeCalledTimes(2); + expect(transformTrap).toHaveBeenNthCalledWith(2, 5); + // Validate is NOT called (chained after onUpdate) + expect(validateTrap).toBeCalledTimes(1); + expect(updatedDoc).toStrictEqual({ + _id: res._id, + name: "jerry", + nonce: "5", // Transformed but not validated + }); + }); + + it("complex chaining: onUpdate -> transform -> validate applies both transform and validate to update", async () => { + let nonce = 5; + const onUpdateTrap = vi.fn(() => nonce++); + const transformTrap = vi.fn((val: number) => String(val)); + const validateTrap = vi.fn((val: string) => val.length <= 2); + const schema = createSchema("users", { + name: string(), + nonce: number() + .onUpdate(onUpdateTrap) + .transform(transformTrap) + .validate(validateTrap, "transformed value must have length <= 2"), + }); + const db = createDatabase(client.db(), { users: schema }); + + // Insert initial document + const res = await db.collections.users + .insertOne({ + name: "tom", + nonce: 7, + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(0); + expect(transformTrap).toBeCalledTimes(1); + expect(transformTrap).toHaveBeenNthCalledWith(1, 7); + expect(validateTrap).toBeCalledTimes(1); + expect(validateTrap).toHaveBeenNthCalledWith(1, "7"); + expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: "7" }); + + // Update document - transform IS in update chain, validate is NOT + const updatedDoc = await db.collections.users + .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) + .options({ + returnDocument: "after", + }) + .exec(); + expect(onUpdateTrap).toBeCalledTimes(1); + expect(onUpdateTrap).toHaveReturnedWith(5); + expect(transformTrap).toBeCalledTimes(2); + expect(transformTrap).toHaveBeenNthCalledWith(2, 5); + // Validate is NOT called on update value + expect(validateTrap).toBeCalledTimes(1); + expect(updatedDoc).toStrictEqual({ + _id: res._id, + name: "jerry", + nonce: "5", // Transformed but not validated + }); + }); +}); diff --git a/tests/query/update.test.ts b/tests/query/update.test.ts new file mode 100644 index 0000000..048a24c --- /dev/null +++ b/tests/query/update.test.ts @@ -0,0 +1,175 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, createSchema } from "../../src"; +import { boolean, number, string } from "../../src/types"; +import { createMockDatabase, mockUsers } from "../mock"; + +describe("Update Operations", async () => { + const { server, client } = await createMockDatabase(); + + const UserSchema = createSchema("users", { + name: string().optional(), + email: string().lowercase().optional(), + age: number().optional().default(10), + isVerified: boolean().default(false), + }); + + const { collections } = createDatabase(client.db(), { + users: UserSchema, + }); + + beforeAll(async () => { + await client.connect(); + }); + + afterEach(async () => { + await collections.users.deleteMany({}).exec(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + it("finds one and updates", async () => { + await collections.users.insertOne(mockUsers[0]).exec(); + + const updatedUser = await collections.users + .findOneAndUpdate( + { email: "anon@gmail.com" }, + { + $set: { + age: 30, + }, + }, + ) + .options({ + returnDocument: "after", + }) + .exec(); + + expect(updatedUser).not.toBe(null); + expect(updatedUser?.age).toBe(30); + }); + + it("updates one document", async () => { + await collections.users.insertOne(mockUsers[1]).exec(); + const updated = await collections.users.updateOne({ email: "anon1@gmail.com" }, { $set: { age: 35 } }).exec(); + expect(updated.acknowledged).toBe(true); + }); + + it("updates many documents", async () => { + await collections.users.insertMany(mockUsers).exec(); + const updated = await collections.users.updateMany({ isVerified: false }, { $set: { age: 40 } }).exec(); + expect(updated.acknowledged).toBe(true); + }); + + it("replaces one document", async () => { + const original = await collections.users.insertOne(mockUsers[0]).exec(); + const replaced = await collections.users + .replaceOne( + { email: "anon@gmail.com" }, + { + ...original, + name: "New Name", + }, + ) + .exec(); + expect(replaced.modifiedCount).toBe(1); + }); + + describe("edge cases", () => { + it("should not mutate reused update object in updateOne", async () => { + const schema = createSchema("users", { + name: string(), + age: number().onUpdate(() => 999), + }); + const db = createDatabase(client.db(), { users: schema }); + + const user1 = await db.collections.users.insertOne({ name: "Alice", age: 20 }).exec(); + const user2 = await db.collections.users.insertOne({ name: "Bob", age: 30 }).exec(); + + // Create a reusable update object + const updateObj = { $set: { name: "Updated" } }; + + // Use the same update object twice + await db.collections.users.updateOne({ _id: user1._id }, updateObj).exec(); + await db.collections.users.updateOne({ _id: user2._id }, updateObj).exec(); + + // Verify users were updated correctly with auto-update + const updatedUser1 = await db.collections.users.findOne({ _id: user1._id }).exec(); + expect(updatedUser1?.name).toBe("Updated"); + expect(updatedUser1?.age).toBe(999); + + const updatedUser2 = await db.collections.users.findOne({ _id: user2._id }).exec(); + expect(updatedUser2?.name).toBe("Updated"); + expect(updatedUser2?.age).toBe(999); + + // The key test: original object should not be mutated + expect(updateObj).toStrictEqual({ $set: { name: "Updated" } }); + }); + + it("should not mutate reused update object in updateMany", async () => { + const schema = createSchema("users", { + name: string(), + age: number().onUpdate(() => 888), + }); + const db = createDatabase(client.db(), { users: schema }); + + await db.collections.users.insertOne({ name: "Alice", age: 20 }).exec(); + await db.collections.users.insertOne({ name: "Bob", age: 30 }).exec(); + await db.collections.users.insertOne({ name: "Charlie", age: 40 }).exec(); + + const updateObj = { $set: { name: "Updated" } }; + + // Use the same update object twice for different filters + await db.collections.users.updateMany({ age: { $lt: 30 } }, updateObj).exec(); + await db.collections.users.updateMany({ age: { $gte: 30 } }, updateObj).exec(); + + // Verify all users were updated + const users = await db.collections.users.find({}).exec(); + expect(users).toHaveLength(3); + for (const user of users) { + expect(user.name).toBe("Updated"); + expect(user.age).toBe(888); + } + + // Original object should not be mutated + expect(updateObj).toStrictEqual({ $set: { name: "Updated" } }); + }); + + it("should not mutate reused update object in findOneAndUpdate", async () => { + const schema = createSchema("users", { + name: string(), + age: number().onUpdate(() => 777), + }); + const db = createDatabase(client.db(), { users: schema }); + + const user1 = await db.collections.users.insertOne({ name: "Alice", age: 20 }).exec(); + const user2 = await db.collections.users.insertOne({ name: "Bob", age: 30 }).exec(); + + const updateObj = { $set: { name: "Updated" } }; + + // Use the same update object twice + await db.collections.users + .findOneAndUpdate({ _id: user1._id }, updateObj) + .options({ returnDocument: "after" }) + .exec(); + await db.collections.users + .findOneAndUpdate({ _id: user2._id }, updateObj) + .options({ returnDocument: "after" }) + .exec(); + + // Verify users were updated + const updatedUser1 = await db.collections.users.findOne({ _id: user1._id }).exec(); + expect(updatedUser1?.name).toBe("Updated"); + expect(updatedUser1?.age).toBe(777); + + const updatedUser2 = await db.collections.users.findOne({ _id: user2._id }).exec(); + expect(updatedUser2?.name).toBe("Updated"); + expect(updatedUser2?.age).toBe(777); + + // Original object should not be mutated + expect(updateObj).toStrictEqual({ $set: { name: "Updated" } }); + }); + }); +}); diff --git a/tests/refs.test.ts b/tests/refs.test.ts deleted file mode 100644 index ace72e7..0000000 --- a/tests/refs.test.ts +++ /dev/null @@ -1,666 +0,0 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { createDatabase, createRelations, createSchema, virtual } from "../src"; -import { array, boolean, date, objectId, string } from "../src/types"; -import { createMockDatabase } from "./mock"; - -describe("Tests for refs population", async () => { - const { server, client } = await createMockDatabase(); - - beforeAll(async () => { - await client.connect(); - }); - - afterEach(async () => { - // Drop the database after each test to ensure isolation - await client.db().dropDatabase(); - }); - - afterAll(async () => { - await client.close(); - await server.stop(); - }); - - const setupSchemasAndCollections = () => { - // Define schemas - const UserSchema = createSchema("users", { - name: string(), - isAdmin: boolean(), - createdAt: date(), - tutor: objectId().optional(), - maybe: string().optional(), - }); - const PostSchema = createSchema("posts", { - title: string(), - contents: string(), - author: objectId().optional(), - editor: objectId().optional(), - contributors: array(objectId()).optional().default([]), - secret: string().default(() => "secret"), - }) - .omit({ secret: true }) - .virtuals({ - contributorsCount: virtual("contributors", ({ contributors }) => contributors?.length ?? 0), - secretSize: virtual("secret", ({ secret }) => secret?.length), - }); - - const BookSchema = createSchema("books", { - title: string(), - author: objectId().optional(), - }); - - const UserSchemaRelations = createRelations(UserSchema, ({ one, ref }) => ({ - tutor: one(UserSchema, { field: "tutor", references: "_id" }), - posts: ref(PostSchema, { field: "_id", references: "author" }), - books: ref(BookSchema, { field: "_id", references: "author" }), - })); - const PostSchemaRelations = createRelations(PostSchema, ({ one, many }) => ({ - author: one(UserSchema, { field: "author", references: "_id" }), - editor: one(UserSchema, { field: "editor", references: "_id" }), - contributors: many(UserSchema, { - field: "contributors", - references: "_id", - }), - })); - const BookSchemaRelations = createRelations(BookSchema, ({ one }) => ({ - author: one(UserSchema, { field: "author", references: "_id" }), - })); - - // Create database collections - return createDatabase(client.db(), { - users: UserSchema, - posts: PostSchema, - books: BookSchema, - UserSchemaRelations, - PostSchemaRelations, - BookSchemaRelations, - }); - }; - - it("should populate relation", async () => { - const { collections } = setupSchemasAndCollections(); - - const user = await collections.users - .insertOne({ - name: "Bob", - isAdmin: false, - createdAt: new Date(), - }) - .exec(); - - const user2 = await collections.users - .insertOne({ - name: "Alex", - isAdmin: false, - tutor: user._id, - createdAt: new Date(), - }) - .exec(); - - const populatedUser2 = await collections.users.findById(user2._id).populate({ tutor: true }).exec(); - - expect(populatedUser2).toStrictEqual({ - ...user2, - tutor: user, - }); - }); - - it("should populate 'author' and 'contributors' in findOne", async () => { - const { collections } = setupSchemasAndCollections(); - - const user = await collections.users - .insertOne({ - name: "Bob", - isAdmin: false, - createdAt: new Date(), - }) - .exec(); - - const user2 = await collections.users - .insertOne({ - name: "Alex", - isAdmin: false, - createdAt: new Date(), - }) - .exec(); - - await collections.posts - .insertOne({ - title: "Pilot", - contents: "Lorem", - author: user._id, - editor: user._id, - contributors: [user2._id], - }) - .exec(); - - // Fetch and populate post's author using findOne - const populatedPost = await collections.posts - .findOne({ - title: "Pilot", - }) - .populate({ contributors: true, author: true }) - .exec(); - - expect(populatedPost?.author).toStrictEqual(user); - expect(populatedPost?.contributors).toBeDefined(); - expect(populatedPost?.contributors).toHaveLength(1); - expect(populatedPost?.contributors[0]).toStrictEqual(user2); - }); - - it("should populate 'posts' in find for multiple users", async () => { - const { collections } = setupSchemasAndCollections(); - - // Create users - const user = await collections.users - .insertOne({ - name: "Bob", - isAdmin: false, - createdAt: new Date(), - tutor: undefined, - }) - .exec(); - - const tutoredUser = await collections.users - .insertOne({ - name: "Alexa", - isAdmin: false, - createdAt: new Date(), - tutor: user._id, - }) - .exec(); - - // Create posts and assign to users - await collections.posts - .insertOne({ - title: "Pilot", - contents: "Lorem", - author: user._id, - editor: user._id, - contributors: [tutoredUser._id], - }) - .exec(); - - await collections.posts - .insertOne({ - title: "Pilot 2", - contents: "Lorem2", - author: user._id, - editor: user._id, - contributors: [], - }) - .exec(); - - // Test case for optional author - await collections.posts - .insertOne({ - title: "No Author", - contents: "Lorem", - editor: user._id, - contributors: [], - }) - .exec(); - - // Fetch and populate posts for all users using find - const populatedUsers = await collections.users.find().populate({ posts: true, tutor: true }).exec(); - - expect(populatedUsers.length).toBe(2); - expect(populatedUsers[0].posts.length).toBe(2); - expect(populatedUsers[1].posts.length).toBe(0); - expect(populatedUsers[1].tutor).toStrictEqual(user); - }); - - it("should support nested population", async () => { - const { collections } = setupSchemasAndCollections(); - - // Create users with tutor relationship - const tutor = await collections.users - .insertOne({ - name: "Master Tutor", - isAdmin: true, - createdAt: new Date(), - }) - .exec(); - - const author = await collections.users - .insertOne({ - name: "Student Author", - isAdmin: false, - createdAt: new Date(), - tutor: tutor._id, - }) - .exec(); - - // Create posts for both users - await collections.posts - .insertOne({ - title: "Tutor's Post", - contents: "Wisdom", - author: tutor._id, - }) - .exec(); - - const studentPost = await collections.posts - .insertOne({ - title: "Student's Post", - contents: "Learning", - author: author._id, - }) - .exec(); - - // Test nested population - const populatedPost = await collections.posts - .findById(studentPost._id) - .select({ contents: true }) - .populate({ - author: { - omit: { - tutor: true, - isAdmin: true, - }, - populate: { - tutor: true, - posts: true, - }, - }, - }) - .exec(); - - // Verify the nested population results - expect(populatedPost).toBeTruthy(); - expect(populatedPost?.author).toBeTruthy(); - expect(populatedPost?.author?.name).toBe("Student Author"); - // @ts-ignore - expect(populatedPost?.author?.isAdmin).toBe(undefined); - expect(populatedPost?.author?.tutor).toBeTruthy(); - expect(populatedPost?.author?.tutor?.name).toBe("Master Tutor"); - expect(populatedPost?.author?.posts).toHaveLength(1); - expect(populatedPost?.author?.posts[0].title).toBe("Student's Post"); - }); - - it("It should handle multiple population with same field", async () => { - const { collections } = setupSchemasAndCollections(); - - const user = await collections.users - .insertOne({ - name: "Test User", - isAdmin: false, - createdAt: new Date(), - }) - .exec(); - - await collections.posts - .insertOne({ - title: "Post 1", - contents: "Content 1", - author: user._id, - editor: user._id, - }) - .exec(); - await collections.books - .insertOne({ - title: "Book 1", - author: user._id, - }) - .exec(); - - const populatedUser = await collections.users.findById(user._id).populate({ posts: true, books: true }).exec(); - - expect(populatedUser).toBeTruthy(); - expect(populatedUser?.posts).toHaveLength(1); - expect(populatedUser?.books).toHaveLength(1); - expect(populatedUser?.posts?.[0]?.title).toBe("Post 1"); - expect(populatedUser?.books?.[0]?.title).toBe("Book 1"); - }); - - it("It should handle deep nested populations with same relation fields", async () => { - const { collections } = setupSchemasAndCollections(); - - const user = await collections.users - .insertOne({ - name: "Test User", - isAdmin: false, - createdAt: new Date(), - }) - .exec(); - - const user2 = await collections.users - .insertOne({ - name: "Test User 2", - isAdmin: false, - createdAt: new Date(), - }) - .exec(); - - await collections.posts - .insertOne({ - title: "Post 1", - contents: "Content 1", - author: user._id, - editor: user2._id, - }) - .exec(); - - await collections.posts - .insertOne({ - title: "Post 2", - contents: "Content 2", - author: user2._id, - editor: user2._id, - }) - .exec(); - - await collections.books - .insertOne({ - title: "Book 1", - author: user._id, - }) - .exec(); - - const populatedUser = await collections.users - .findById(user._id) - .populate({ - posts: { - populate: { - editor: { - populate: { - posts: true, - }, - }, - }, - }, - books: true, - }) - .exec(); - - expect(populatedUser).toBeTruthy(); - expect(populatedUser?.posts).toHaveLength(1); - expect(populatedUser?.books).toHaveLength(1); - expect(populatedUser?.posts?.[0]?.title).toBe("Post 1"); - expect(populatedUser?.posts?.[0]?.editor?.posts).toHaveLength(1); - expect(populatedUser?.books?.[0]?.title).toBe("Book 1"); - }); - - describe("Monarch Population Options", () => { - it("should populate with limit and skip options", async () => { - const { collections } = setupSchemasAndCollections(); - - // Create a user and posts - const user = await collections.users - .insertOne({ - name: "Test User", - isAdmin: false, - createdAt: new Date(), - }) - .exec(); - - await collections.posts - .insertOne({ - title: "Post 1", - contents: "Content 1", - author: user._id, - }) - .exec(); - - await collections.posts - .insertOne({ - title: "Post 2", - contents: "Content 2", - author: user._id, - }) - .exec(); - - // Fetch and populate posts with limit and skip - const populatedUser = await collections.users - .find() - .populate({ posts: { limit: 1, skip: 0 } }) - .exec(); - - expect(populatedUser.length).toBe(1); - expect(populatedUser[0].posts.length).toBe(1); - expect(populatedUser[0].posts[0].title).toBe("Post 1"); - }); - - it("should populate with default omit option", async () => { - const { collections } = setupSchemasAndCollections(); - // Create a user and posts - const user = await collections.users - .insertOne({ - name: "Test User 2", - isAdmin: false, - createdAt: new Date(), - }) - .exec(); - - await collections.posts - .insertOne({ - title: "Post 3", - contents: "Content 3", - author: user._id, - }) - .exec(); - - // Fetch and populate posts with select and omit options - const populatedUser = await collections.users - .find() - .populate({ - posts: true, - }) - .exec(); - - expect(populatedUser.length).toBe(1); - expect(populatedUser[0].posts.length).toBe(1); - expect(populatedUser[0].posts[0]).toHaveProperty("contents"); - expect(populatedUser[0].posts[0]).not.toHaveProperty("secret"); - }); - - it("should populate with omit option", async () => { - const { collections } = setupSchemasAndCollections(); - // Create a user and posts - const user = await collections.users - .insertOne({ - name: "Test User 2", - isAdmin: false, - createdAt: new Date(), - }) - .exec(); - - await collections.posts - .insertOne({ - title: "Post 3", - contents: "Content 3", - author: user._id, - }) - .exec(); - - // Fetch and populate posts with select and omit options - const populatedUser = await collections.users - .find() - .populate({ - posts: { - omit: { title: true }, - }, - }) - .exec(); - - expect(populatedUser.length).toBe(1); - expect(populatedUser[0].posts.length).toBe(1); - expect(populatedUser[0].posts[0]).toHaveProperty("secret"); - expect(populatedUser[0].posts[0]).not.toHaveProperty("title"); - }); - - it("should populate with select option", async () => { - const { collections } = setupSchemasAndCollections(); - // Create a user and posts - const user = await collections.users - .insertOne({ - name: "Test User 2", - isAdmin: false, - createdAt: new Date(), - }) - .exec(); - - await collections.posts - .insertOne({ - title: "Post 3", - contents: "Content 3", - author: user._id, - }) - .exec(); - - // Fetch and populate posts with select and omit options - const populatedUser = await collections.users - .find() - .populate({ - posts: { - select: { title: true }, - }, - }) - .exec(); - - expect(populatedUser.length).toBe(1); - expect(populatedUser[0].posts.length).toBe(1); - expect(populatedUser[0].posts[0]).toHaveProperty("title"); - expect(populatedUser[0].posts[0]).not.toHaveProperty("contents"); - expect(populatedUser[0].posts[0]).not.toHaveProperty("secret"); - }); - - it("should populate with sort option", async () => { - const { collections } = setupSchemasAndCollections(); - // Create a user and posts - const user = await collections.users - .insertOne({ - name: "Test User 5", - isAdmin: false, - createdAt: new Date(), - }) - .exec(); - - await collections.posts - .insertOne({ - title: "Post 6", - contents: "Content 6", - author: user._id, - }) - .exec(); - - await collections.posts - .insertOne({ - title: "Post 7", - contents: "Content 7", - author: user._id, - }) - .exec(); - - // Fetch and populate posts with sort option - const populatedUser = await collections.users - .find() - .populate({ - posts: { - sort: { title: -1 }, - }, - }) - .exec(); - - expect(populatedUser.length).toBe(1); - expect(populatedUser[0].posts.length).toBe(2); - expect(populatedUser[0].posts[0]).toHaveProperty("title", "Post 7"); - expect(populatedUser[0].posts[1]).toHaveProperty("title", "Post 6"); - }); - - it("should access original population fields in virtuals", async () => { - const { collections } = setupSchemasAndCollections(); - // Create a user and posts - const user1 = await collections.users - .insertOne({ - name: "Test User 1", - isAdmin: false, - createdAt: new Date(), - }) - .exec(); - - const user2 = await collections.users - .insertOne({ - name: "Test User 2", - isAdmin: false, - createdAt: new Date(), - }) - .exec(); - - await collections.posts - .insertOne({ - title: "Post 6", - contents: "Content 6", - contributors: [user1._id, user2._id], - secret: "12345", - }) - .exec(); - - // Fetch and populate posts with sort option - const populatedPost = await collections.posts - .find() - .populate({ - contributors: { - select: { name: true }, - }, - }) - .exec(); - - expect(populatedPost.length).toBe(1); - expect(populatedPost[0].contributorsCount).toBe(2); - expect(populatedPost[0].contributors.length).toBe(2); - expect(populatedPost[0].contributors[0]).toStrictEqual({ - _id: user1._id, - name: user1.name, - }); - expect(populatedPost[0].contributors[1]).toStrictEqual({ - _id: user2._id, - name: user2.name, - }); - expect(populatedPost[0].secretSize).toBe(5); - // should remove extra inputs for virtuals - expect(populatedPost[0]).not.toHaveProperty("secret"); - }); - }); - - describe("Schema Relation Validations", () => { - it("should throw error when relation target schema is not initialized", async () => { - const UserSchema = createSchema("users", { - name: string(), - isAdmin: boolean(), - createdAt: date(), - }); - - // Create relations before PostSchema is defined - const UserSchemaRelations = createRelations(UserSchema, ({ ref }) => ({ - posts: ref(undefined as any, { field: "_id", references: "author" }), - })); - - const db = createDatabase(client.db(), { - users: UserSchema, - UserSchemaRelations, - }); - - // Attempt to populate undefined relation - await expect(async () => { - await db.collections.users.find().populate({ posts: true }).exec(); - }).rejects.toThrow("Target schema not found for relation 'posts' in schema 'users'"); - }); - - it("should throw error when schema has no relations defined", async () => { - const UserSchema = createSchema("users", { - name: string(), - isAdmin: boolean(), - createdAt: date(), - }); - - const db = createDatabase(client.db(), { - users: UserSchema, - }); - - // Attempt to populate non-existent relation - await expect(async () => { - await db.collections.users.find().populate({ posts: true }).exec(); - }).rejects.toThrow("No relations found for schema 'users'"); - }); - }); -}); diff --git a/tests/relations/many.test.ts b/tests/relations/many.test.ts new file mode 100644 index 0000000..1c821fa --- /dev/null +++ b/tests/relations/many.test.ts @@ -0,0 +1,198 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, createRelations, createSchema, virtual } from "../../src"; +import { array, boolean, date, objectId, string } from "../../src/types"; +import { createMockDatabase } from "../mock"; + +describe("many() relation tests", async () => { + const { server, client } = await createMockDatabase(); + + beforeAll(async () => { + await client.connect(); + }); + + afterEach(async () => { + await client.db().dropDatabase(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + const setupSchemasAndCollections = () => { + const UserSchema = createSchema("users", { + name: string(), + isAdmin: boolean(), + createdAt: date(), + }); + + const PostSchema = createSchema("posts", { + title: string(), + contents: string(), + author: objectId().optional(), + contributors: array(objectId()).optional().default([]), + secret: string().default(() => "secret"), + }) + .omit({ secret: true }) + .virtuals({ + contributorsCount: virtual("contributors", ({ contributors }) => contributors?.length ?? 0), + secretSize: virtual("secret", ({ secret }) => secret?.length), + }); + + const PostSchemaRelations = createRelations(PostSchema, ({ one, many }) => ({ + author: one(UserSchema, { field: "author", references: "_id" }), + contributors: many(UserSchema, { + field: "contributors", + references: "_id", + }), + })); + + return createDatabase(client.db(), { + users: UserSchema, + posts: PostSchema, + PostSchemaRelations, + }); + }; + + it("should populate many() relation (contributors)", async () => { + const { collections } = setupSchemasAndCollections(); + + const user = await collections.users + .insertOne({ + name: "Bob", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + const user2 = await collections.users + .insertOne({ + name: "Alex", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Pilot", + contents: "Lorem", + author: user._id, + contributors: [user2._id], + }) + .exec(); + + const populatedPost = await collections.posts + .findOne({ + title: "Pilot", + }) + .populate({ contributors: true }) + .exec(); + + expect(populatedPost?.contributors).toBeDefined(); + expect(populatedPost?.contributors).toHaveLength(1); + expect(populatedPost?.contributors[0]).toStrictEqual(user2); + }); + + it("should populate many() relation with multiple contributors", async () => { + const { collections } = setupSchemasAndCollections(); + + const user1 = await collections.users + .insertOne({ + name: "Bob", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + const user2 = await collections.users + .insertOne({ + name: "Alex", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + const user3 = await collections.users + .insertOne({ + name: "Charlie", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Multi Author Post", + contents: "Content", + author: user1._id, + contributors: [user2._id, user3._id], + }) + .exec(); + + const populatedPost = await collections.posts + .findOne({ + title: "Multi Author Post", + }) + .populate({ contributors: true, author: true }) + .exec(); + + expect(populatedPost?.author).toStrictEqual(user1); + expect(populatedPost?.contributors).toBeDefined(); + expect(populatedPost?.contributors).toHaveLength(2); + expect(populatedPost?.contributors[0]).toStrictEqual(user2); + expect(populatedPost?.contributors[1]).toStrictEqual(user3); + }); + + it("should access original many() field in virtuals", async () => { + const { collections } = setupSchemasAndCollections(); + + const user1 = await collections.users + .insertOne({ + name: "Test User 1", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + const user2 = await collections.users + .insertOne({ + name: "Test User 2", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Post 6", + contents: "Content 6", + contributors: [user1._id, user2._id], + secret: "12345", + }) + .exec(); + + const populatedPost = await collections.posts + .find() + .populate({ + contributors: { + select: { name: true }, + }, + }) + .exec(); + + expect(populatedPost.length).toBe(1); + expect(populatedPost[0].contributorsCount).toBe(2); + expect(populatedPost[0].contributors.length).toBe(2); + expect(populatedPost[0].contributors[0]).toStrictEqual({ + _id: user1._id, + name: user1.name, + }); + expect(populatedPost[0].contributors[1]).toStrictEqual({ + _id: user2._id, + name: user2.name, + }); + expect(populatedPost[0].secretSize).toBe(5); + expect(populatedPost[0]).not.toHaveProperty("secret"); + }); +}); diff --git a/tests/relations/one.test.ts b/tests/relations/one.test.ts new file mode 100644 index 0000000..1349dd5 --- /dev/null +++ b/tests/relations/one.test.ts @@ -0,0 +1,210 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, createRelations, createSchema } from "../../src"; +import { array, boolean, date, objectId, string } from "../../src/types"; +import { createMockDatabase } from "../mock"; + +describe("one() relation tests", async () => { + const { server, client } = await createMockDatabase(); + + beforeAll(async () => { + await client.connect(); + }); + + afterEach(async () => { + await client.db().dropDatabase(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + const setupSchemasAndCollections = () => { + const UserSchema = createSchema("users", { + name: string(), + isAdmin: boolean(), + createdAt: date(), + tutor: objectId().optional(), + }); + + const PostSchema = createSchema("posts", { + title: string(), + contents: string(), + author: objectId().optional(), + editor: objectId().optional(), + contributors: array(objectId()).optional().default([]), + }); + + const UserSchemaRelations = createRelations(UserSchema, ({ one }) => ({ + tutor: one(UserSchema, { field: "tutor", references: "_id" }), + })); + + const PostSchemaRelations = createRelations(PostSchema, ({ one, many }) => ({ + author: one(UserSchema, { field: "author", references: "_id" }), + editor: one(UserSchema, { field: "editor", references: "_id" }), + contributors: many(UserSchema, { + field: "contributors", + references: "_id", + }), + })); + + return createDatabase(client.db(), { + users: UserSchema, + posts: PostSchema, + UserSchemaRelations, + PostSchemaRelations, + }); + }; + + it("should populate one() relation (tutor)", async () => { + const { collections } = setupSchemasAndCollections(); + + const user = await collections.users + .insertOne({ + name: "Bob", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + const user2 = await collections.users + .insertOne({ + name: "Alex", + isAdmin: false, + tutor: user._id, + createdAt: new Date(), + }) + .exec(); + + const populatedUser2 = await collections.users.findById(user2._id).populate({ tutor: true }).exec(); + + expect(populatedUser2).toStrictEqual({ + ...user2, + tutor: user, + }); + }); + + it("should populate one() relation (author)", async () => { + const { collections } = setupSchemasAndCollections(); + + const user = await collections.users + .insertOne({ + name: "Bob", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Pilot", + contents: "Lorem", + author: user._id, + }) + .exec(); + + const populatedPost = await collections.posts + .findOne({ + title: "Pilot", + }) + .populate({ author: true }) + .exec(); + + expect(populatedPost?.author).toStrictEqual(user); + }); + + it("should support nested one() relation population", async () => { + const UserSchemaWithRefs = createSchema("users", { + name: string(), + isAdmin: boolean(), + createdAt: date(), + tutor: objectId().optional(), + }); + + const PostSchemaWithRefs = createSchema("posts", { + title: string(), + contents: string(), + author: objectId().optional(), + }); + + const UserRelations = createRelations(UserSchemaWithRefs, ({ one, ref }) => ({ + tutor: one(UserSchemaWithRefs, { field: "tutor", references: "_id" }), + posts: ref(PostSchemaWithRefs, { field: "_id", references: "author" }), + })); + + const PostRelations = createRelations(PostSchemaWithRefs, ({ one }) => ({ + author: one(UserSchemaWithRefs, { field: "author", references: "_id" }), + })); + + const db = createDatabase(client.db(), { + users: UserSchemaWithRefs, + posts: PostSchemaWithRefs, + UserRelations, + PostRelations, + }); + + // Create users with tutor relationship + const tutor = await db.collections.users + .insertOne({ + name: "Master Tutor", + isAdmin: true, + createdAt: new Date(), + }) + .exec(); + + const author = await db.collections.users + .insertOne({ + name: "Student Author", + isAdmin: false, + createdAt: new Date(), + tutor: tutor._id, + }) + .exec(); + + // Create posts for both users + await db.collections.posts + .insertOne({ + title: "Tutor's Post", + contents: "Wisdom", + author: tutor._id, + }) + .exec(); + + const studentPost = await db.collections.posts + .insertOne({ + title: "Student's Post", + contents: "Learning", + author: author._id, + }) + .exec(); + + // Test nested population + const populatedPost = await db.collections.posts + .findById(studentPost._id) + .select({ contents: true }) + .populate({ + author: { + omit: { + tutor: true, + isAdmin: true, + }, + populate: { + tutor: true, + posts: true, + }, + }, + }) + .exec(); + + // Verify the nested population results + expect(populatedPost).toBeTruthy(); + expect(populatedPost?.author).toBeTruthy(); + expect(populatedPost?.author?.name).toBe("Student Author"); + // @ts-ignore + expect(populatedPost?.author?.isAdmin).toBe(undefined); + expect(populatedPost?.author?.tutor).toBeTruthy(); + expect(populatedPost?.author?.tutor?.name).toBe("Master Tutor"); + expect(populatedPost?.author?.posts).toHaveLength(1); + expect(populatedPost?.author?.posts[0].title).toBe("Student's Post"); + }); +}); diff --git a/tests/relations/population-options.test.ts b/tests/relations/population-options.test.ts new file mode 100644 index 0000000..e1d1754 --- /dev/null +++ b/tests/relations/population-options.test.ts @@ -0,0 +1,241 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, createRelations, createSchema, virtual } from "../../src"; +import { array, boolean, date, objectId, string } from "../../src/types"; +import { createMockDatabase } from "../mock"; + +describe("Population Options", async () => { + const { server, client } = await createMockDatabase(); + + beforeAll(async () => { + await client.connect(); + }); + + afterEach(async () => { + await client.db().dropDatabase(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + const setupSchemasAndCollections = () => { + const UserSchema = createSchema("users", { + name: string(), + isAdmin: boolean(), + createdAt: date(), + }); + + const PostSchema = createSchema("posts", { + title: string(), + contents: string(), + author: objectId().optional(), + contributors: array(objectId()).optional().default([]), + secret: string().default(() => "secret"), + }) + .omit({ secret: true }) + .virtuals({ + contributorsCount: virtual("contributors", ({ contributors }) => contributors?.length ?? 0), + secretSize: virtual("secret", ({ secret }) => secret?.length), + }); + + const UserSchemaRelations = createRelations(UserSchema, ({ ref }) => ({ + posts: ref(PostSchema, { field: "_id", references: "author" }), + })); + + const PostSchemaRelations = createRelations(PostSchema, ({ one, many }) => ({ + author: one(UserSchema, { field: "author", references: "_id" }), + contributors: many(UserSchema, { + field: "contributors", + references: "_id", + }), + })); + + return createDatabase(client.db(), { + users: UserSchema, + posts: PostSchema, + UserSchemaRelations, + PostSchemaRelations, + }); + }; + + it("should populate with limit and skip options", async () => { + const { collections } = setupSchemasAndCollections(); + + const user = await collections.users + .insertOne({ + name: "Test User", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Post 1", + contents: "Content 1", + author: user._id, + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Post 2", + contents: "Content 2", + author: user._id, + }) + .exec(); + + const populatedUser = await collections.users + .find() + .populate({ posts: { limit: 1, skip: 0 } }) + .exec(); + + expect(populatedUser.length).toBe(1); + expect(populatedUser[0].posts.length).toBe(1); + expect(populatedUser[0].posts[0].title).toBe("Post 1"); + }); + + it("should populate with default omit option", async () => { + const { collections } = setupSchemasAndCollections(); + + const user = await collections.users + .insertOne({ + name: "Test User 2", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Post 3", + contents: "Content 3", + author: user._id, + }) + .exec(); + + const populatedUser = await collections.users + .find() + .populate({ + posts: true, + }) + .exec(); + + expect(populatedUser.length).toBe(1); + expect(populatedUser[0].posts.length).toBe(1); + expect(populatedUser[0].posts[0]).toHaveProperty("contents"); + expect(populatedUser[0].posts[0]).not.toHaveProperty("secret"); + }); + + it("should populate with omit option", async () => { + const { collections } = setupSchemasAndCollections(); + + const user = await collections.users + .insertOne({ + name: "Test User 2", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Post 3", + contents: "Content 3", + author: user._id, + }) + .exec(); + + const populatedUser = await collections.users + .find() + .populate({ + posts: { + omit: { title: true }, + }, + }) + .exec(); + + expect(populatedUser.length).toBe(1); + expect(populatedUser[0].posts.length).toBe(1); + expect(populatedUser[0].posts[0]).toHaveProperty("secret"); + expect(populatedUser[0].posts[0]).not.toHaveProperty("title"); + }); + + it("should populate with select option", async () => { + const { collections } = setupSchemasAndCollections(); + + const user = await collections.users + .insertOne({ + name: "Test User 2", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Post 3", + contents: "Content 3", + author: user._id, + }) + .exec(); + + const populatedUser = await collections.users + .find() + .populate({ + posts: { + select: { title: true }, + }, + }) + .exec(); + + expect(populatedUser.length).toBe(1); + expect(populatedUser[0].posts.length).toBe(1); + expect(populatedUser[0].posts[0]).toHaveProperty("title"); + expect(populatedUser[0].posts[0]).not.toHaveProperty("contents"); + expect(populatedUser[0].posts[0]).not.toHaveProperty("secret"); + }); + + it("should populate with sort option", async () => { + const { collections } = setupSchemasAndCollections(); + + const user = await collections.users + .insertOne({ + name: "Test User 5", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Post 6", + contents: "Content 6", + author: user._id, + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Post 7", + contents: "Content 7", + author: user._id, + }) + .exec(); + + const populatedUser = await collections.users + .find() + .populate({ + posts: { + sort: { title: -1 }, + }, + }) + .exec(); + + expect(populatedUser.length).toBe(1); + expect(populatedUser[0].posts.length).toBe(2); + expect(populatedUser[0].posts[0]).toHaveProperty("title", "Post 7"); + expect(populatedUser[0].posts[1]).toHaveProperty("title", "Post 6"); + }); +}); diff --git a/tests/relations/ref.test.ts b/tests/relations/ref.test.ts new file mode 100644 index 0000000..a6aa455 --- /dev/null +++ b/tests/relations/ref.test.ts @@ -0,0 +1,253 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, createRelations, createSchema } from "../../src"; +import { boolean, date, objectId, string } from "../../src/types"; +import { createMockDatabase } from "../mock"; + +describe("ref() relation tests", async () => { + const { server, client } = await createMockDatabase(); + + beforeAll(async () => { + await client.connect(); + }); + + afterEach(async () => { + await client.db().dropDatabase(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + const setupSchemasAndCollections = () => { + const UserSchema = createSchema("users", { + name: string(), + isAdmin: boolean(), + createdAt: date(), + tutor: objectId().optional(), + }); + + const PostSchema = createSchema("posts", { + title: string(), + contents: string(), + author: objectId().optional(), + }); + + const BookSchema = createSchema("books", { + title: string(), + author: objectId().optional(), + }); + + const UserSchemaRelations = createRelations(UserSchema, ({ one, ref }) => ({ + tutor: one(UserSchema, { field: "tutor", references: "_id" }), + posts: ref(PostSchema, { field: "_id", references: "author" }), + books: ref(BookSchema, { field: "_id", references: "author" }), + })); + + const PostSchemaRelations = createRelations(PostSchema, ({ one }) => ({ + author: one(UserSchema, { field: "author", references: "_id" }), + })); + + const BookSchemaRelations = createRelations(BookSchema, ({ one }) => ({ + author: one(UserSchema, { field: "author", references: "_id" }), + })); + + return createDatabase(client.db(), { + users: UserSchema, + posts: PostSchema, + books: BookSchema, + UserSchemaRelations, + PostSchemaRelations, + BookSchemaRelations, + }); + }; + + it("should populate ref() relation (posts)", async () => { + const { collections } = setupSchemasAndCollections(); + + const user = await collections.users + .insertOne({ + name: "Bob", + isAdmin: false, + createdAt: new Date(), + tutor: undefined, + }) + .exec(); + + const tutoredUser = await collections.users + .insertOne({ + name: "Alexa", + isAdmin: false, + createdAt: new Date(), + tutor: user._id, + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Pilot", + contents: "Lorem", + author: user._id, + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Pilot 2", + contents: "Lorem2", + author: user._id, + }) + .exec(); + + await collections.posts + .insertOne({ + title: "No Author", + contents: "Lorem", + }) + .exec(); + + const populatedUsers = await collections.users.find().populate({ posts: true, tutor: true }).exec(); + + expect(populatedUsers.length).toBe(2); + expect(populatedUsers[0].posts.length).toBe(2); + expect(populatedUsers[1].posts.length).toBe(0); + expect(populatedUsers[1].tutor).toStrictEqual(user); + }); + + it("should handle multiple ref() relations with same field", async () => { + const { collections } = setupSchemasAndCollections(); + + const user = await collections.users + .insertOne({ + name: "Test User", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + await collections.posts + .insertOne({ + title: "Post 1", + contents: "Content 1", + author: user._id, + }) + .exec(); + + await collections.books + .insertOne({ + title: "Book 1", + author: user._id, + }) + .exec(); + + const populatedUser = await collections.users.findById(user._id).populate({ posts: true, books: true }).exec(); + + expect(populatedUser).toBeTruthy(); + expect(populatedUser?.posts).toHaveLength(1); + expect(populatedUser?.books).toHaveLength(1); + expect(populatedUser?.posts?.[0]?.title).toBe("Post 1"); + expect(populatedUser?.books?.[0]?.title).toBe("Book 1"); + }); + + it("should handle deep nested populations with ref() relations", async () => { + const PostSchemaWithEditor = createSchema("posts_deep", { + title: string(), + contents: string(), + author: objectId().optional(), + editor: objectId().optional(), + }); + + const UserSchemaForEditor = createSchema("users_deep", { + name: string(), + isAdmin: boolean(), + createdAt: date(), + }); + + const BookSchemaDeep = createSchema("books_deep", { + title: string(), + author: objectId().optional(), + }); + + const UserRelationsEditor = createRelations(UserSchemaForEditor, ({ ref }) => ({ + posts: ref(PostSchemaWithEditor, { field: "_id", references: "author" }), + books: ref(BookSchemaDeep, { field: "_id", references: "author" }), + })); + + const PostRelationsEditor = createRelations(PostSchemaWithEditor, ({ one }) => ({ + author: one(UserSchemaForEditor, { field: "author", references: "_id" }), + editor: one(UserSchemaForEditor, { field: "editor", references: "_id" }), + })); + + const db = createDatabase(client.db(), { + users: UserSchemaForEditor, + posts: PostSchemaWithEditor, + books: BookSchemaDeep, + UserRelationsEditor, + PostRelationsEditor, + }); + + const user = await db.collections.users + .insertOne({ + name: "Test User", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + const user2 = await db.collections.users + .insertOne({ + name: "Test User 2", + isAdmin: false, + createdAt: new Date(), + }) + .exec(); + + await db.collections.posts + .insertOne({ + title: "Post 1", + contents: "Content 1", + author: user._id, + editor: user2._id, + }) + .exec(); + + await db.collections.posts + .insertOne({ + title: "Post 2", + contents: "Content 2", + author: user2._id, + editor: user2._id, + }) + .exec(); + + await db.collections.books + .insertOne({ + title: "Book 1", + author: user._id, + }) + .exec(); + + const populatedUser = await db.collections.users + .findById(user._id) + .populate({ + posts: { + populate: { + editor: { + populate: { + posts: true, + }, + }, + }, + }, + books: true, + }) + .exec(); + + expect(populatedUser).toBeTruthy(); + expect(populatedUser?.posts).toHaveLength(1); + expect(populatedUser?.books).toHaveLength(1); + expect(populatedUser?.posts?.[0]?.title).toBe("Post 1"); + expect(populatedUser?.posts?.[0]?.editor?.posts).toHaveLength(1); + expect(populatedUser?.books?.[0]?.title).toBe("Book 1"); + }); +}); diff --git a/tests/relations/validation.test.ts b/tests/relations/validation.test.ts new file mode 100644 index 0000000..e80f1b6 --- /dev/null +++ b/tests/relations/validation.test.ts @@ -0,0 +1,88 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDatabase, createRelations, createSchema } from "../../src"; +import { boolean, date, objectId, string } from "../../src/types"; +import { createMockDatabase } from "../mock"; + +describe("Relation Validations", async () => { + const { server, client } = await createMockDatabase(); + + beforeAll(async () => { + await client.connect(); + }); + + afterEach(async () => { + await client.db().dropDatabase(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + it("should throw error when relation target schema is not initialized", async () => { + const UserSchema = createSchema("users", { + name: string(), + isAdmin: boolean(), + createdAt: date(), + }); + + const UserSchemaRelations = createRelations(UserSchema, ({ ref }) => ({ + posts: ref(undefined as any, { field: "_id", references: "author" }), + })); + + const db = createDatabase(client.db(), { + users: UserSchema, + UserSchemaRelations, + }); + + await expect(async () => { + await db.collections.users.find().populate({ posts: true }).exec(); + }).rejects.toThrowError("Target schema not found for relation 'posts' in schema 'users'"); + }); + + it("should throw error when schema has no relations defined", async () => { + const UserSchema = createSchema("users", { + name: string(), + isAdmin: boolean(), + createdAt: date(), + }); + + const db = createDatabase(client.db(), { + users: UserSchema, + }); + + await expect(async () => { + await db.collections.users.find().populate({ posts: true }).exec(); + }).rejects.toThrowError("No relations found for schema 'users'"); + }); + + it("throws error when defining relations for the same schema multiple times", () => { + const UserSchema = createSchema("users", { + name: string(), + isAdmin: boolean(), + createdAt: date(), + }); + + const PostSchema = createSchema("posts", { + title: string(), + author: objectId().optional(), + }); + + const UserRelations1 = createRelations(UserSchema, ({ ref }) => ({ + posts: ref(PostSchema, { field: "_id", references: "author" }), + })); + + const UserRelations2 = createRelations(UserSchema, ({ ref }) => ({ + books: ref(PostSchema, { field: "_id", references: "author" }), + })); + + expect(() => { + createDatabase(client.db(), { + users: UserSchema, + posts: PostSchema, + UserRelations1, + UserRelations2, + }); + }).toThrowError("Relations for schema 'users' already exists."); + }); +}); diff --git a/tests/schema-options.test.ts b/tests/schema/schema.test.ts similarity index 71% rename from tests/schema-options.test.ts rename to tests/schema/schema.test.ts index bbab9f8..306f138 100644 --- a/tests/schema-options.test.ts +++ b/tests/schema/schema.test.ts @@ -1,9 +1,9 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { createDatabase, createSchema, virtual } from "../src"; -import { boolean, number, string } from "../src/types"; -import { createMockDatabase } from "./mock"; +import { createDatabase, createSchema, virtual } from "../../src"; +import { boolean, number, string } from "../../src/types"; +import { createMockDatabase } from "../mock"; -describe("Schema options", async () => { +describe("Schema", async () => { const { server, client } = await createMockDatabase(); beforeAll(async () => { @@ -200,7 +200,7 @@ describe("Schema options", async () => { age: 0, }) .exec(); - }).rejects.toThrow("E11000 duplicate key error"); + }).rejects.toThrowError("E11000 duplicate key error"); // duplicate firstname and lastname pair await db.collections.users @@ -220,6 +220,85 @@ describe("Schema options", async () => { age: 0, }) .exec(); - }).rejects.toThrow("E11000 duplicate key error"); + }).rejects.toThrowError("E11000 duplicate key error"); + }); + + it("supports custom _id type with string", async () => { + const schema = createSchema("products", { + _id: string(), + name: string(), + price: number(), + }); + const db = createDatabase(client.db(), { products: schema }); + + const product = await db.collections.products + .insertOne({ + _id: "product-123", + name: "Laptop", + price: 999, + }) + .exec(); + + expect(product).toStrictEqual({ + _id: "product-123", + name: "Laptop", + price: 999, + }); + + const foundProduct = await db.collections.products.findById("product-123").exec(); + expect(foundProduct).toStrictEqual({ + _id: "product-123", + name: "Laptop", + price: 999, + }); + }); + + it("supports custom _id type with number", async () => { + const schema = createSchema("orders", { + _id: number(), + customerId: string(), + total: number(), + }); + const db = createDatabase(client.db(), { orders: schema }); + + const order = await db.collections.orders + .insertOne({ + _id: 12345, + customerId: "cust-001", + total: 150.5, + }) + .exec(); + + expect(order).toStrictEqual({ + _id: 12345, + customerId: "cust-001", + total: 150.5, + }); + + const foundOrder = await db.collections.orders.findById(12345).exec(); + expect(foundOrder).toStrictEqual({ + _id: 12345, + customerId: "cust-001", + total: 150.5, + }); + }); + + it("throws error when multiple collections use the same schema name", () => { + const UserSchema = createSchema("users", { + name: string(), + age: number(), + }); + + const AnotherUserSchema = createSchema("users", { + name: string(), + email: string(), + }); + + expect(() => { + createDatabase(client.db(), { + users: UserSchema, + users2: AnotherUserSchema, + }); + }).toThrowError("Schema with name 'users' already exists."); }); }); diff --git a/tests/transformations.test.ts b/tests/transformations.test.ts deleted file mode 100644 index 7556790..0000000 --- a/tests/transformations.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { createDatabase, createSchema } from "../src"; -import { string } from "../src/types"; -import { createMockDatabase } from "./mock"; - -describe("test for transformations", async () => { - const { server, client } = await createMockDatabase(); - - beforeAll(async () => { - await client.connect(); - }); - - afterAll(async () => { - await client.close(); - await server.stop(); - }); - - it("returns value in lowercase", async () => { - const UserSchema = createSchema("users", { - name: string().lowercase(), - }); - - const { collections } = createDatabase(client.db(), { - users: UserSchema, - }); - - const newUser = await collections.users - .insertOne({ - name: "PRINCE", - }) - .exec(); - expect(newUser).not.toBe(null); - expect(newUser).toStrictEqual( - expect.objectContaining({ - name: "prince", - }), - ); - - const users = await collections.users.find({ _id: newUser?._id }).exec(); - expect(users.length).toBeGreaterThanOrEqual(1); - - const existingUser = users[0]; - expect(existingUser).toStrictEqual( - expect.objectContaining({ - name: "prince", - }), - ); - }); - - it("returns value in uppercase", async () => { - const UserSchema = createSchema("userUpper", { - name: string().uppercase(), - }); - - const { collections } = createDatabase(client.db(), { - users: UserSchema, - }); - - const newUser = await collections.users - .insertOne({ - name: "EriiC", - }) - .exec(); - expect(newUser).not.toBe(null); - expect(newUser).toStrictEqual( - expect.objectContaining({ - name: "ERIIC", - }), - ); - - const users = await collections.users.find({ _id: newUser?._id }).exec(); - expect(users.length).toBeGreaterThanOrEqual(1); - - const existingUser = users[0]; - expect(existingUser).toStrictEqual( - expect.objectContaining({ - name: "ERIIC", - }), - ); - }); - - it("returns value with '-go' at the end", async () => { - const UserSchema = createSchema("userWithGo", { - name: string().transform((value) => `${value}-go`), - }); - const { collections } = createDatabase(client.db(), { - users: UserSchema, - }); - const newUser = await collections.users - .insertOne({ - name: "mon", - }) - .exec(); - - expect(newUser).not.toBe(null); - expect(newUser).toStrictEqual( - expect.objectContaining({ - name: "mon-go", - }), - ); - - const users = await collections.users.find({}).exec(); - expect(users.length).toBeGreaterThanOrEqual(1); - - const existingUser = users[0]; - expect(existingUser).toStrictEqual( - expect.objectContaining({ - name: "mon-go", - }), - ); - }); -}); diff --git a/tests/types.test.ts b/tests/types.test.ts deleted file mode 100644 index f4c866a..0000000 --- a/tests/types.test.ts +++ /dev/null @@ -1,612 +0,0 @@ -import { describe, expect, it, test, vi } from "vitest"; -import { Schema, createSchema } from "../src"; -import { - array, - boolean, - date, - dateString, - literal, - mixed, - number, - object, - pipe, - record, - string, - taggedUnion, - tuple, - type, - union, -} from "../src/types"; - -const numberString = () => type((input) => `${input}`); - -describe("Types", () => { - it("validates and transforms input", () => { - const schema = createSchema("users", { - name: string(), - upperName: string().transform((input) => input.toUpperCase()), - age: numberString(), - }); - const data = Schema.toData(schema, { - name: "tom", - upperName: "tom", - age: 0, - }); - expect(data).toStrictEqual({ name: "tom", upperName: "TOM", age: "0" }); - }); - - it("validate and transform order", () => { - const schema = createSchema("test", { - name: string() - .uppercase() // should have made everything uppercase - .validate((input) => input.toUpperCase() === input, "String is not in all caps"), - }); - expect(() => Schema.toData(schema, { name: "somename" })).not.toThrowError(); - }); - - test("nullable", () => { - // transform is skipped when value is null - const schema1 = createSchema("users", { - age: numberString().nullable(), - }); - const data1 = Schema.toData(schema1, { age: null }); - expect(data1).toStrictEqual({ age: null }); - - // transform is applied when value is not null - const schema2 = createSchema("users", { - age: numberString().nullable(), - }); - const data2 = Schema.toData(schema2, { age: 0 }); - expect(data2).toStrictEqual({ age: "0" }); - }); - - test("optional", () => { - // transform is skipped when value is undefined - const schema1 = createSchema("users", { - age: numberString().optional(), - }); - const data1 = Schema.toData(schema1, {}); - expect(data1).toStrictEqual({}); - - // transform is applied when value is not undefined - const schema2 = createSchema("users", { - age: numberString().optional(), - }); - const data2 = Schema.toData(schema2, { age: 0 }); - expect(data2).toStrictEqual({ age: "0" }); - }); - - test("default", () => { - // default value is used when value is ommited - const defaultFnTrap1 = vi.fn(() => 11); - const schema1 = createSchema("users", { - age: numberString().default(10), - ageLazy: numberString().default(defaultFnTrap1), - }); - const data1 = Schema.toData(schema1, {}); - expect(data1).toStrictEqual({ age: "10", ageLazy: "11" }); - expect(defaultFnTrap1).toHaveBeenCalledTimes(1); - - // default value is ignored when value is not null or undefined - const defaultFnTrap2 = vi.fn(() => 11); - const schema2 = createSchema("users", { - age: numberString().default(10), - ageLazy: numberString().default(defaultFnTrap2), - }); - const data2 = Schema.toData(schema2, { age: 1, ageLazy: 2 }); - expect(data2).toStrictEqual({ age: "1", ageLazy: "2" }); - expect(defaultFnTrap2).toHaveBeenCalledTimes(0); - }); - - test("pipe", () => { - const outerValidateFnTrap = vi.fn(() => true); - const innerValidateFnTrap = vi.fn(() => true); - - const schema1 = createSchema("test", { - count: pipe( - string().validate(outerValidateFnTrap, "invalid count string").transform(Number.parseInt), - number() - .validate(innerValidateFnTrap, "invalid count number") - .transform((num) => num * 1000) - .transform((str) => `count-${str}`), - ), - }); - const data1 = Schema.toData(schema1, { count: "1" }); - expect(data1).toStrictEqual({ count: "count-1000" }); - expect(outerValidateFnTrap).toHaveBeenCalledTimes(1); - expect(innerValidateFnTrap).toHaveBeenCalledTimes(1); - outerValidateFnTrap.mockClear(); - innerValidateFnTrap.mockClear(); - - // pipe in default - const schema2 = createSchema("test", { - count: pipe( - string().validate(outerValidateFnTrap, "invalid count string").transform(Number.parseInt).default("2"), - number() - .validate(innerValidateFnTrap, "invalid count number") - .transform((num) => num * 1000) - .transform((str) => `count-${str}`), - ), - }); - const data2 = Schema.toData(schema2, {}); - expect(data2).toStrictEqual({ count: "count-2000" }); - expect(outerValidateFnTrap).toHaveBeenCalledTimes(1); - expect(innerValidateFnTrap).toHaveBeenCalledTimes(1); - outerValidateFnTrap.mockClear(); - innerValidateFnTrap.mockClear(); - - // pipe default - const schema3 = createSchema("test", { - count: pipe( - string().validate(outerValidateFnTrap, "invalid count string").transform(Number.parseInt), - number() - .validate(innerValidateFnTrap, "invalid count number") - .transform((num) => num * 1000) - .transform((str) => `count-${str}`), - ).default("3"), - }); - const data3 = Schema.toData(schema3, {}); - expect(data3).toStrictEqual({ count: "count-3000" }); - expect(outerValidateFnTrap).toHaveBeenCalledTimes(1); - expect(innerValidateFnTrap).toHaveBeenCalledTimes(1); - outerValidateFnTrap.mockClear(); - innerValidateFnTrap.mockClear(); - - // pipe out default - const schema4 = createSchema("test", { - count: pipe( - string().validate(outerValidateFnTrap, "invalid count string").transform(Number.parseInt).optional(), - number() - .validate(innerValidateFnTrap, "invalid count number") - .transform((num) => num * 1000) - .transform((str) => `count-${str}`) - .default(4), - ), - }); - const data4 = Schema.toData(schema4, {}); - expect(data4).toStrictEqual({ count: "count-4000" }); - expect(outerValidateFnTrap).toHaveBeenCalledTimes(0); - expect(innerValidateFnTrap).toHaveBeenCalledTimes(1); - outerValidateFnTrap.mockClear(); - innerValidateFnTrap.mockClear(); - }); - - test("object", () => { - const schema = createSchema("test", { - permissions: object({ - canUpdate: boolean(), - canDelete: boolean().default(false), - role: literal("admin", "moderator", "customer"), - }), - }); - - // @ts-expect-error - expect(() => Schema.toData(schema, {})).toThrowError("expected 'object' received 'undefined'"); - expect(() => - // @ts-expect-error - Schema.toData(schema, { permissions: { canUpdate: "yes" } }), - ).toThrowError("field 'canUpdate' expected 'boolean' received 'string'"); - // fields are validates in the order they are registered in type - expect(() => - // @ts-expect-error - Schema.toData(schema, { permissions: { role: false } }), - ).toThrowError("field 'canUpdate' expected 'boolean' received 'undefined'"); - // unknwon fields are rejected - expect(() => - Schema.toData(schema, { - // @ts-expect-error - permissions: { canUpdate: true, role: "admin", canCreate: true }, - }), - ).toThrowError("unknown field 'canCreate', object may only specify known fields"); - const data = Schema.toData(schema, { - permissions: { canUpdate: true, role: "moderator" }, - }); - expect(data).toStrictEqual({ - permissions: { canUpdate: true, canDelete: false, role: "moderator" }, - }); - }); - - test("record", () => { - const schema = createSchema("test", { - grades: record(number()), - }); - - // @ts-expect-error - expect(() => Schema.toData(schema, {})).toThrowError("expected 'object' received 'undefined'"); - // empty object is ok - expect(() => Schema.toData(schema, { grades: {} })).not.toThrowError(); - expect(() => - // @ts-expect-error - Schema.toData(schema, { grades: { math: "50" } }), - ).toThrowError("field 'math' expected 'number' received 'string'"); - const data = Schema.toData(schema, { grades: { math: 50 } }); - expect(data).toStrictEqual({ grades: { math: 50 } }); - }); - - test("tuple", () => { - const schema = createSchema("test", { - items: tuple([number(), string()]), - }); - - // @ts-expect-error - expect(() => Schema.toData(schema, {})).toThrowError("expected 'array' received 'undefined'"); - // @ts-expect-error - expect(() => Schema.toData(schema, { items: [] })).toThrowError( - "expected array with 2 elements received 0 elements", - ); - const data = Schema.toData(schema, { items: [0, "1"] }); - expect(data).toStrictEqual({ items: [0, "1"] }); - // @ts-expect-error - expect(() => Schema.toData(schema, { items: [1, "1", 2] })).toThrowError( - "expected array with 2 elements received 3 elements", - ); - }); - - test("array", () => { - const schema = createSchema("test", { - items: array(number()), - }); - - // @ts-expect-error - expect(() => Schema.toData(schema, {})).toThrowError("expected 'array' received 'undefined'"); - // empty array is ok - expect(() => Schema.toData(schema, { items: [] })).not.toThrowError(); - // @ts-expect-error - expect(() => Schema.toData(schema, { items: [0, "1"] })).toThrowError( - "element at index '1' expected 'number' received 'string'", - ); - const data = Schema.toData(schema, { items: [0, 1] }); - expect(data).toStrictEqual({ items: [0, 1] }); - }); - - test("literal", () => { - const schema = createSchema("test", { - role: literal("admin", "moderator"), - }); - - // @ts-expect-error - expect(() => Schema.toData(schema, {})).toThrowError( - "unknown value 'undefined', literal may only specify known values", - ); - // @ts-expect-error - expect(() => Schema.toData(schema, { role: "user" })).toThrowError( - "unknown value 'user', literal may only specify known values", - ); - const data = Schema.toData(schema, { role: "admin" }); - expect(data).toStrictEqual({ role: "admin" }); - }); - - test("taggedUnion", () => { - const schema = createSchema("test", { - color: taggedUnion({ - rgba: object({ r: number(), g: number(), b: number(), a: string() }), - hex: string(), - hsl: tuple([string(), string(), string()]).transform(([f, s, t]) => f + s + t), - }), - }); - - // @ts-expect-error - expect(() => Schema.toData(schema, {})).toThrowError("expected 'object' received 'undefined'"); - // @ts-expect-error - expect(() => Schema.toData(schema, { color: {} })).toThrowError("missing field"); - // @ts-expect-error - expect(() => Schema.toData(schema, { color: { tag: "hex" } })).toThrowError( - "missing field 'value' in tagged union", - ); - expect(() => - // @ts-expect-error - Schema.toData(schema, { color: { value: "#fff" } }), - ).toThrowError("missing field 'tag' in tagged union"); - expect(() => - Schema.toData(schema, { - // @ts-expect-error - color: { tag: "hex", value: "#fff", extra: "user" }, - }), - ).toThrowError("unknown field 'extra', tagged union may only specify 'tag' and 'value' fields"); - expect(() => - // @ts-expect-error - Schema.toData(schema, { color: { tag: "hwb", value: "#fff" } }), - ).toThrowError("unknown tag 'hwb'"); - expect(() => - // @ts-expect-error - Schema.toData(schema, { color: { tag: "hsl", value: "#fff" } }), - ).toThrowError("invalid value for tag 'hsl'"); - const data1 = Schema.toData(schema, { - color: { tag: "rgba", value: { r: 0, g: 0, b: 0, a: "100%" } }, - }); - expect(data1).toStrictEqual({ - color: { tag: "rgba", value: { r: 0, g: 0, b: 0, a: "100%" } }, - }); - const data2 = Schema.toData(schema, { - color: { tag: "hex", value: "#fff" }, - }); - expect(data2).toStrictEqual({ - color: { tag: "hex", value: "#fff" }, - }); - const data3 = Schema.toData(schema, { - color: { tag: "hsl", value: ["0", "0", "0"] }, - }); - expect(data3).toStrictEqual({ - color: { tag: "hsl", value: "000" }, - }); - }); - - test("union", () => { - const schema = createSchema("milf", { - emailOrPhone: union(string(), number()), - }); - - // @ts-expect-error - expect(() => Schema.toData(schema, { emailOrPhone: {} })).toThrowError("no matching variant found for union type"); - // @ts-expect-error - expect(() => Schema.toData(schema, { emailOrPhone: [] })).toThrowError("no matching variant found for union type"); - // @ts-expect-error - expect(() => Schema.toData(schema, { emailOrPhone: null })).toThrowError( - "no matching variant found for union type", - ); - - const data1 = Schema.toData(schema, { emailOrPhone: "test" }); - expect(data1).toStrictEqual({ emailOrPhone: "test" }); - - const data2 = Schema.toData(schema, { emailOrPhone: 42 }); - expect(data2).toStrictEqual({ emailOrPhone: 42 }); - }); - describe("string", () => { - test("lowercase and uppercase", () => { - const schema = createSchema("test", { - lower: string().lowercase(), - upper: string().uppercase(), - }); - const data = Schema.toData(schema, { lower: "HELLO", upper: "hello" }); - expect(data).toStrictEqual({ lower: "hello", upper: "HELLO" }); - }); - - test("length validations", () => { - const schema = createSchema("test", { - min: string().minLength(3), - max: string().maxLength(5), - exact: string().length(4), - }); - - expect(() => Schema.toData(schema, { min: "ab", max: "test", exact: "test" })).toThrowError( - "string must be at least 3 characters long", - ); - expect(() => Schema.toData(schema, { min: "test", max: "toolong", exact: "test" })).toThrowError( - "string must be at most 5 characters long", - ); - expect(() => Schema.toData(schema, { min: "test", max: "test", exact: "toolong" })).toThrowError( - "string must be exactly 4 characters long", - ); - - const data = Schema.toData(schema, { - min: "abc", - max: "test", - exact: "test", - }); - expect(data).toStrictEqual({ min: "abc", max: "test", exact: "test" }); - }); - - test("pattern", () => { - const schema = createSchema("test", { - email: string().pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/), - }); - - expect(() => Schema.toData(schema, { email: "invalid" })).toThrowError( - "string must match pattern /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/", - ); - - const data = Schema.toData(schema, { email: "test@example.com" }); - expect(data).toStrictEqual({ email: "test@example.com" }); - }); - - test("trim", () => { - const schema = createSchema("test", { - trimmed: string().trim(), - }); - - const data = Schema.toData(schema, { trimmed: " hello " }); - expect(data).toStrictEqual({ trimmed: "hello" }); - }); - - test("nonEmpty", () => { - const schema = createSchema("test", { - required: string().nonEmpty(), - }); - - expect(() => Schema.toData(schema, { required: "" })).toThrowError("string must not be empty"); - - const data = Schema.toData(schema, { required: "hello" }); - expect(data).toStrictEqual({ required: "hello" }); - }); - - test("includes", () => { - const schema = createSchema("test", { - contains: string().includes("world"), - }); - - expect(() => Schema.toData(schema, { contains: "hello" })).toThrowError('string must include "world"'); - - const data = Schema.toData(schema, { contains: "hello world" }); - expect(data).toStrictEqual({ contains: "hello world" }); - }); - }); - - describe("date", () => { - const now = new Date(); - const past = new Date(now.getTime() - 1000 * 60 * 60 * 24 * 2); // 2 days ago - const future = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 2); // 2 days later - - test("MonarchDate", () => { - const schema = createSchema("test", { - date: date(), - }); - - const validData = Schema.toData(schema, { date: now }); - expect(validData).toStrictEqual({ date: now }); - - // @ts-expect-error - expect(() => Schema.toData(schema, { date: "not a date" })).toThrowError("expected 'Date' received 'string'"); - // @ts-expect-error - expect(() => Schema.toData(schema, { date: 123 })).toThrowError("expected 'Date' received 'number'"); - }); - - test("MonarchDateString", () => { - const schema = createSchema("test", { - date: dateString(), - }); - - // Valid ISO date string should pass - const validData = Schema.toData(schema, { date: now.toISOString() }); - expect(validData).toStrictEqual({ date: now }); - - expect(() => Schema.toData(schema, { date: "invalid date" })).toThrowError( - "expected 'ISO Date string' received 'string'", - ); - // @ts-expect-error - expect(() => Schema.toData(schema, { date: 123 })).toThrowError("expected 'ISO Date string' received 'number'"); - }); - - test("MonarchDate after() and before()", () => { - const schema = createSchema("test", { - afterDate: date().after(now), - beforeDate: date().before(now), - }); - - expect(() => Schema.toData(schema, { afterDate: past, beforeDate: future })).toThrowError( - `date must be after ${now.toISOString()}`, - ); - expect(() => Schema.toData(schema, { afterDate: future, beforeDate: future })).toThrowError( - `date must be before ${now.toISOString()}`, - ); - - // Edge case: date equal to boundary should fail - expect(() => Schema.toData(schema, { afterDate: now, beforeDate: past })).toThrowError( - `date must be after ${now.toISOString()}`, - ); - expect(() => Schema.toData(schema, { afterDate: future, beforeDate: now })).toThrowError( - `date must be before ${now.toISOString()}`, - ); - - const data = Schema.toData(schema, { - afterDate: future, - beforeDate: past, - }); - expect(data).toStrictEqual({ afterDate: future, beforeDate: past }); - }); - - test("MonarchDateString after() and before()", () => { - const schema = createSchema("test", { - afterDate: dateString().after(now), - beforeDate: dateString().before(now), - }); - - expect(() => - Schema.toData(schema, { - afterDate: past.toISOString(), - beforeDate: future.toISOString(), - }), - ).toThrowError(`date must be after ${now.toISOString()}`); - - expect(() => - Schema.toData(schema, { - afterDate: future.toISOString(), - beforeDate: future.toISOString(), - }), - ).toThrowError(`date must be before ${now.toISOString()}`); - - // Edge case: date equal to boundary should fail - expect(() => - Schema.toData(schema, { - afterDate: now.toISOString(), - beforeDate: past.toISOString(), - }), - ).toThrowError(`date must be after ${now.toISOString()}`); - expect(() => - Schema.toData(schema, { - afterDate: future.toISOString(), - beforeDate: now.toISOString(), - }), - ).toThrowError(`date must be before ${now.toISOString()}`); - - const data = Schema.toData(schema, { - afterDate: future.toISOString(), - beforeDate: past.toISOString(), - }); - expect(data).toStrictEqual({ - afterDate: future, - beforeDate: past, - }); - }); - }); - - test("mixed", () => { - const schema = createSchema("test", { - anything: mixed(), - }); - - const data1 = Schema.toData(schema, { anything: "string" }); - expect(data1).toStrictEqual({ anything: "string" }); - - const data2 = Schema.toData(schema, { anything: 42 }); - expect(data2).toStrictEqual({ anything: 42 }); - - const data3 = Schema.toData(schema, { anything: true }); - expect(data3).toStrictEqual({ anything: true }); - - const data4 = Schema.toData(schema, { anything: { nested: "object" } }); - expect(data4).toStrictEqual({ anything: { nested: "object" } }); - - const data5 = Schema.toData(schema, { anything: [1, "2", false] }); - expect(data5).toStrictEqual({ anything: [1, "2", false] }); - - const data6 = Schema.toData(schema, { anything: null }); - expect(data6).toStrictEqual({ anything: null }); - }); - - describe("number", () => { - test("number validation", () => { - const schema = createSchema("test", { - value: number(), - }); - - const data = Schema.toData(schema, { value: 42 }); - expect(data).toStrictEqual({ value: 42 }); - - // @ts-expect-error - expect(() => Schema.toData(schema, { value: "42" })).toThrowError("expected 'number' received 'string'"); - }); - - test("min and max constraints", () => { - const schema = createSchema("test", { - min: number().min(5), - max: number().max(10), - }); - - expect(() => Schema.toData(schema, { min: 3, max: 8 })).toThrowError("number must be greater than or equal to 5"); - expect(() => Schema.toData(schema, { min: 6, max: 12 })).toThrowError("number must be less than or equal to 10"); - - const data = Schema.toData(schema, { min: 7, max: 8 }); - expect(data).toStrictEqual({ min: 7, max: 8 }); - }); - - test("integer conversion", () => { - const schema = createSchema("test", { - value: number().integer(), - }); - - const data = Schema.toData(schema, { value: 5.7 }); - expect(data).toStrictEqual({ value: 5 }); - }); - - test("multipleOf validation", () => { - const schema = createSchema("test", { - value: number().multipleOf(3), - }); - - expect(() => Schema.toData(schema, { value: 7 })).toThrowError("number must be a multiple of 3"); - - const data = Schema.toData(schema, { value: 9 }); - expect(data).toStrictEqual({ value: 9 }); - }); - }); -}); diff --git a/tests/types/array.test.ts b/tests/types/array.test.ts new file mode 100644 index 0000000..abee365 --- /dev/null +++ b/tests/types/array.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "vitest"; +import { Schema, createSchema } from "../../src"; +import { array, number, string } from "../../src/types"; + +describe("array()", () => { + test("validates array type", () => { + const schema = createSchema("test", { + items: array(number()), + }); + + // @ts-expect-error + expect(() => Schema.encode(schema, {})).toThrowError("expected 'array' received 'undefined'"); + // empty array is ok + expect(() => Schema.encode(schema, { items: [] })).not.toThrowError(); + // @ts-expect-error + expect(() => Schema.encode(schema, { items: [0, "1"] })).toThrowError( + "element at index '1' expected 'number' received 'string'", + ); + const data = Schema.encode(schema, { items: [0, 1] }); + expect(data).toStrictEqual({ items: [0, 1] }); + }); + + test("min - validates minimum array length", () => { + const schema = createSchema("test", { + items: array(string()).min(2), + }); + + const data = Schema.encode(schema, { items: ["a", "b"] }); + expect(data).toStrictEqual({ items: ["a", "b"] }); + + const data2 = Schema.encode(schema, { items: ["a", "b", "c"] }); + expect(data2).toStrictEqual({ items: ["a", "b", "c"] }); + + expect(() => Schema.encode(schema, { items: ["a"] })).toThrowError("array must have at least 2 elements"); + expect(() => Schema.encode(schema, { items: [] })).toThrowError("array must have at least 2 elements"); + }); + + test("max - validates maximum array length", () => { + const schema = createSchema("test", { + items: array(string()).max(3), + }); + + const data = Schema.encode(schema, { items: ["a", "b", "c"] }); + expect(data).toStrictEqual({ items: ["a", "b", "c"] }); + + const data2 = Schema.encode(schema, { items: ["a"] }); + expect(data2).toStrictEqual({ items: ["a"] }); + + expect(() => Schema.encode(schema, { items: ["a", "b", "c", "d"] })).toThrowError( + "array must have at most 3 elements", + ); + }); + + test("length - validates exact array length", () => { + const schema = createSchema("test", { + coordinates: array(number()).length(2), + }); + + const data = Schema.encode(schema, { coordinates: [10.5, 20.3] }); + expect(data).toStrictEqual({ coordinates: [10.5, 20.3] }); + + expect(() => Schema.encode(schema, { coordinates: [10.5] })).toThrowError("array must have exactly 2 elements"); + expect(() => Schema.encode(schema, { coordinates: [10.5, 20.3, 30.1] })).toThrowError( + "array must have exactly 2 elements", + ); + }); + + test("nonempty - validates array is not empty", () => { + const schema = createSchema("test", { + items: array(string()).nonempty(), + }); + + const data = Schema.encode(schema, { items: ["a"] }); + expect(data).toStrictEqual({ items: ["a"] }); + + expect(() => Schema.encode(schema, { items: [] })).toThrowError("array must have at least 1 elements"); + }); + + test("array methods can be chained", () => { + const schema = createSchema("test", { + items: array(string()).min(2).max(5), + }); + + const data = Schema.encode(schema, { items: ["a", "b", "c"] }); + expect(data).toStrictEqual({ items: ["a", "b", "c"] }); + + expect(() => Schema.encode(schema, { items: ["a"] })).toThrowError("array must have at least 2 elements"); + expect(() => Schema.encode(schema, { items: ["a", "b", "c", "d", "e", "f"] })).toThrowError( + "array must have at most 5 elements", + ); + }); + + test("array methods work with nullable and optional", () => { + const schema = createSchema("test", { + items: array(string()).min(1).nullable(), + optionalItems: array(number()).max(3).optional(), + }); + + const data = Schema.encode(schema, { items: ["a"], optionalItems: [1, 2] }); + expect(data).toStrictEqual({ items: ["a"], optionalItems: [1, 2] }); + + const nullData = Schema.encode(schema, { items: null }); + expect(nullData).toStrictEqual({ items: null }); + + const undefinedData = Schema.encode(schema, { items: ["a"] }); + expect(undefinedData).toStrictEqual({ items: ["a"] }); + }); +}); diff --git a/tests/types/binary.test.ts b/tests/types/binary.test.ts new file mode 100644 index 0000000..bd22bae --- /dev/null +++ b/tests/types/binary.test.ts @@ -0,0 +1,108 @@ +import { Binary } from "mongodb"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { createDatabase, createSchema, Schema } from "../../src"; +import { binary } from "../../src/types"; +import { createMockDatabase } from "../mock"; + +describe("binary()", () => { + test("validates Binary type", () => { + const schema = createSchema("test", { + data: binary(), + }); + + const testBuffer = Buffer.from("hello world"); + const data = Schema.encode(schema, { data: testBuffer }); + expect(data.data).toBeInstanceOf(Binary); + }); + + test("rejects non-Binary values", () => { + const schema = createSchema("test", { + data: binary(), + }); + + // @ts-expect-error + expect(() => Schema.encode(schema, { data: "not a buffer" })).toThrowError( + "expected 'Buffer' or 'Binary' received 'string'", + ); + // @ts-expect-error + expect(() => Schema.encode(schema, { data: 123 })).toThrowError("expected 'Buffer' or 'Binary' received 'number'"); + // @ts-expect-error + expect(() => Schema.encode(schema, { data: {} })).toThrowError("expected 'Buffer' or 'Binary' received 'object'"); + }); + + test("works with nullable and optional", () => { + const schema = createSchema("test", { + nullableBinary: binary().nullable(), + optionalBinary: binary().optional(), + }); + + const nullData = Schema.encode(schema, { nullableBinary: null }); + expect(nullData).toStrictEqual({ nullableBinary: null }); + + const undefinedData = Schema.encode(schema, { nullableBinary: Buffer.from("test") }); + expect(undefinedData.nullableBinary).toBeInstanceOf(Binary); + }); + + describe("Database Integration", async () => { + const { server, client } = await createMockDatabase(); + + beforeAll(async () => { + await client.connect(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + const BsonDataSchema = createSchema("bson_data_binary", { + binaryField: binary().optional(), + }); + + const { collections } = createDatabase(client.db(), { + bsonData: BsonDataSchema, + }); + + afterAll(async () => { + await collections.bsonData.deleteMany({}).exec(); + }); + + test("accepts Buffer and returns Binary on insert", async () => { + const testBuffer = Buffer.from("hello world"); + + const inserted = await collections.bsonData + .insertOne({ + binaryField: testBuffer, + }) + .exec(); + + expect(inserted.binaryField).toBeInstanceOf(Binary); + expect(inserted.binaryField!.buffer.toString()).toBe("hello world"); + + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + expect(retrieved).not.toBeNull(); + expect(retrieved!.binaryField).toBeInstanceOf(Binary); + expect(retrieved!.binaryField!.buffer.toString()).toBe("hello world"); + expect(retrieved!.binaryField).toEqual(inserted.binaryField); + }); + + test("accepts Binary and returns Binary on insert", async () => { + const testBinary = new Binary(Buffer.from("binary data")); + + const inserted = await collections.bsonData + .insertOne({ + binaryField: testBinary, + }) + .exec(); + + expect(inserted.binaryField).toBeInstanceOf(Binary); + expect(inserted.binaryField!.buffer.toString()).toBe("binary data"); + + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + expect(retrieved).not.toBeNull(); + expect(retrieved!.binaryField).toBeInstanceOf(Binary); + expect(retrieved!.binaryField!.buffer.toString()).toBe("binary data"); + expect(retrieved!.binaryField).toEqual(inserted.binaryField); + }); + }); +}); diff --git a/tests/types/boolean.test.ts b/tests/types/boolean.test.ts new file mode 100644 index 0000000..f61f99a --- /dev/null +++ b/tests/types/boolean.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest"; +import { Schema, createSchema } from "../../src"; +import { boolean } from "../../src/types"; + +describe("boolean()", () => { + test("validates boolean type", () => { + const schema = createSchema("test", { + isActive: boolean(), + }); + + const trueData = Schema.encode(schema, { isActive: true }); + expect(trueData).toStrictEqual({ isActive: true }); + + const falseData = Schema.encode(schema, { isActive: false }); + expect(falseData).toStrictEqual({ isActive: false }); + }); + + test("rejects non-boolean values", () => { + const schema = createSchema("test", { + isActive: boolean(), + }); + + // @ts-expect-error + expect(() => Schema.encode(schema, { isActive: "true" })).toThrowError("expected 'boolean' received 'string'"); + // @ts-expect-error + expect(() => Schema.encode(schema, { isActive: 1 })).toThrowError("expected 'boolean' received 'number'"); + // @ts-expect-error + expect(() => Schema.encode(schema, { isActive: {} })).toThrowError("expected 'boolean' received 'object'"); + }); + + test("works with nullable and optional", () => { + const schema = createSchema("test", { + nullableBoolean: boolean().nullable(), + optionalBoolean: boolean().optional(), + }); + + const nullData = Schema.encode(schema, { nullableBoolean: null }); + expect(nullData).toStrictEqual({ nullableBoolean: null }); + + const undefinedData = Schema.encode(schema, { nullableBoolean: true }); + expect(undefinedData).toStrictEqual({ nullableBoolean: true }); + }); +}); diff --git a/tests/types/date.test.ts b/tests/types/date.test.ts new file mode 100644 index 0000000..21aceef --- /dev/null +++ b/tests/types/date.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "vitest"; +import { Schema, createSchema } from "../../src"; +import { date, dateString } from "../../src/types"; + +describe("date", () => { + const now = new Date(); + const past = new Date(now.getTime() - 1000 * 60 * 60 * 24 * 2); // 2 days ago + const future = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 2); // 2 days later + + test("MonarchDate", () => { + const schema = createSchema("test", { + date: date(), + }); + + const validData = Schema.encode(schema, { date: now }); + expect(validData).toStrictEqual({ date: now }); + + // @ts-expect-error + expect(() => Schema.encode(schema, { date: "not a date" })).toThrowError("expected 'Date' received 'string'"); + // @ts-expect-error + expect(() => Schema.encode(schema, { date: 123 })).toThrowError("expected 'Date' received 'number'"); + }); + + test("MonarchDateString", () => { + const schema = createSchema("test", { + date: dateString(), + }); + + // Valid ISO date string should pass + const validData = Schema.encode(schema, { date: now.toISOString() }); + expect(validData).toStrictEqual({ date: now }); + + expect(() => Schema.encode(schema, { date: "invalid date" })).toThrowError( + "expected 'ISO Date string' received 'string'", + ); + // @ts-expect-error + expect(() => Schema.encode(schema, { date: 123 })).toThrowError("expected 'ISO Date string' received 'number'"); + }); + + test("MonarchDate after() and before()", () => { + const schema = createSchema("test", { + afterDate: date().after(now), + beforeDate: date().before(now), + }); + + expect(() => Schema.encode(schema, { afterDate: past, beforeDate: future })).toThrowError( + `date must be after ${now.toISOString()}`, + ); + expect(() => Schema.encode(schema, { afterDate: future, beforeDate: future })).toThrowError( + `date must be before ${now.toISOString()}`, + ); + + // Edge case: date equal to boundary should fail + expect(() => Schema.encode(schema, { afterDate: now, beforeDate: past })).toThrowError( + `date must be after ${now.toISOString()}`, + ); + expect(() => Schema.encode(schema, { afterDate: future, beforeDate: now })).toThrowError( + `date must be before ${now.toISOString()}`, + ); + + const data = Schema.encode(schema, { + afterDate: future, + beforeDate: past, + }); + expect(data).toStrictEqual({ afterDate: future, beforeDate: past }); + }); + + test("MonarchDateString after() and before()", () => { + const schema = createSchema("test", { + afterDate: dateString().after(now), + beforeDate: dateString().before(now), + }); + + expect(() => + Schema.encode(schema, { + afterDate: past.toISOString(), + beforeDate: future.toISOString(), + }), + ).toThrowError(`date must be after ${now.toISOString()}`); + + expect(() => + Schema.encode(schema, { + afterDate: future.toISOString(), + beforeDate: future.toISOString(), + }), + ).toThrowError(`date must be before ${now.toISOString()}`); + + // Edge case: date equal to boundary should fail + expect(() => + Schema.encode(schema, { + afterDate: now.toISOString(), + beforeDate: past.toISOString(), + }), + ).toThrowError(`date must be after ${now.toISOString()}`); + expect(() => + Schema.encode(schema, { + afterDate: future.toISOString(), + beforeDate: now.toISOString(), + }), + ).toThrowError(`date must be before ${now.toISOString()}`); + + const data = Schema.encode(schema, { + afterDate: future.toISOString(), + beforeDate: past.toISOString(), + }); + expect(data).toStrictEqual({ + afterDate: future, + beforeDate: past, + }); + }); + + test("validation error order - type error before value validation", () => { + // Test that type validation errors occur before chained validation method errors + const schema = createSchema("test", { + dateWithAfter: date().after(now), + dateWithBefore: date().before(now), + dateStringWithAfter: dateString().after(now), + dateStringWithBefore: dateString().before(now), + }); + + // Invalid type for date().after() should throw type error first + expect(() => + Schema.encode(schema, { + dateWithAfter: "not a date" as any, + dateWithBefore: past, + dateStringWithAfter: future.toISOString(), + dateStringWithBefore: past.toISOString(), + }), + ).toThrowError("expected 'Date' received 'string'"); + + // Invalid type for date().before() should throw type error first + expect(() => + Schema.encode(schema, { + dateWithAfter: future, + dateWithBefore: 123 as any, + dateStringWithAfter: future.toISOString(), + dateStringWithBefore: past.toISOString(), + }), + ).toThrowError("expected 'Date' received 'number'"); + + // Invalid date string for dateString().after() should throw parsing error first + expect(() => + Schema.encode(schema, { + dateWithAfter: future, + dateWithBefore: past, + dateStringWithAfter: "invalid date string", + dateStringWithBefore: past.toISOString(), + }), + ).toThrowError("expected 'ISO Date string' received 'string'"); + + // Invalid type (non-string) for dateString().before() should throw type error first + expect(() => + Schema.encode(schema, { + dateWithAfter: future, + dateWithBefore: past, + dateStringWithAfter: future.toISOString(), + dateStringWithBefore: now as any, + }), + ).toThrowError("expected 'ISO Date string' received 'object'"); + }); +}); diff --git a/tests/types/decimal128.test.ts b/tests/types/decimal128.test.ts new file mode 100644 index 0000000..aa0133b --- /dev/null +++ b/tests/types/decimal128.test.ts @@ -0,0 +1,146 @@ +import { Decimal128 } from "mongodb"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { createDatabase, createSchema, Schema } from "../../src"; +import { decimal128 } from "../../src/types"; +import { createMockDatabase } from "../mock"; + +describe("decimal128()", () => { + test("validates Decimal128 type", () => { + const schema = createSchema("test", { + value: decimal128(), + }); + + const testDecimal = Decimal128.fromString("123.456"); + const data = Schema.encode(schema, { value: testDecimal }); + expect(data).toStrictEqual({ value: testDecimal }); + expect(data.value).toBeInstanceOf(Decimal128); + }); + + test("converts string to Decimal128", () => { + const schema = createSchema("test", { + value: decimal128(), + }); + + const data = Schema.encode(schema, { value: "123.456" }); + expect(data.value).toBeInstanceOf(Decimal128); + expect(data.value.toString()).toBe("123.456"); + }); + + test("handles high-precision decimals", () => { + const schema = createSchema("test", { + value: decimal128(), + }); + + const highPrecision = "123456789.123456789123456789"; + const data = Schema.encode(schema, { value: highPrecision }); + expect(data.value).toBeInstanceOf(Decimal128); + expect(data.value.toString()).toBe(highPrecision); + }); + + test("rejects invalid values", () => { + const schema = createSchema("test", { + value: decimal128(), + }); + + // @ts-expect-error + expect(() => Schema.encode(schema, { value: 123 })).toThrowError( + "expected 'Decimal128' or 'string' received 'number'", + ); + // @ts-expect-error + expect(() => Schema.encode(schema, { value: {} })).toThrowError( + "expected 'Decimal128' or 'string' received 'object'", + ); + }); + + test("works with nullable and optional", () => { + const schema = createSchema("test", { + nullableDecimal: decimal128().nullable(), + optionalDecimal: decimal128().optional(), + }); + + const nullData = Schema.encode(schema, { nullableDecimal: null }); + expect(nullData).toStrictEqual({ nullableDecimal: null }); + + const undefinedData = Schema.encode(schema, { nullableDecimal: Decimal128.fromString("99.99") }); + expect(undefinedData.nullableDecimal?.toString()).toBe("99.99"); + }); + + describe("Database Integration", async () => { + const { server, client } = await createMockDatabase(); + + beforeAll(async () => { + await client.connect(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + const BsonDataSchema = createSchema("bson_data_decimal", { + decimalField: decimal128().optional(), + }); + + const { collections } = createDatabase(client.db(), { + bsonData: BsonDataSchema, + }); + + afterAll(async () => { + await collections.bsonData.deleteMany({}).exec(); + }); + + test("accepts Decimal128 and returns Decimal128", async () => { + const testDecimal = Decimal128.fromString("123456789.123456789123456789"); + + const inserted = await collections.bsonData + .insertOne({ + decimalField: testDecimal, + }) + .exec(); + + expect(inserted.decimalField).toBeInstanceOf(Decimal128); + expect(inserted.decimalField!.toString()).toBe("123456789.123456789123456789"); + + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + expect(retrieved).not.toBeNull(); + expect(retrieved!.decimalField).toBeInstanceOf(Decimal128); + expect(retrieved!.decimalField!.toString()).toBe("123456789.123456789123456789"); + expect(retrieved!.decimalField).toEqual(inserted.decimalField); + }); + + test("accepts string and returns Decimal128", async () => { + const inserted = await collections.bsonData + .insertOne({ + decimalField: "999.999999", + }) + .exec(); + + expect(inserted.decimalField).toBeInstanceOf(Decimal128); + expect(inserted.decimalField!.toString()).toBe("999.999999"); + + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + expect(retrieved).not.toBeNull(); + expect(retrieved!.decimalField).toBeInstanceOf(Decimal128); + expect(retrieved!.decimalField!.toString()).toBe("999.999999"); + expect(retrieved!.decimalField).toEqual(inserted.decimalField); + }); + + test("handles high precision decimals", async () => { + const highPrecision = "99999999999999.999999999999999999"; + const inserted = await collections.bsonData + .insertOne({ + decimalField: highPrecision, + }) + .exec(); + + expect(inserted.decimalField).toBeInstanceOf(Decimal128); + expect(inserted.decimalField!.toString()).toBe(highPrecision); + + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + expect(retrieved).not.toBeNull(); + expect(retrieved!.decimalField).toBeInstanceOf(Decimal128); + expect(retrieved!.decimalField!.toString()).toBe(highPrecision); + expect(retrieved!.decimalField).toEqual(inserted.decimalField); + }); + }); +}); diff --git a/tests/types/literal.test.ts b/tests/types/literal.test.ts new file mode 100644 index 0000000..157d482 --- /dev/null +++ b/tests/types/literal.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; +import { Schema, createSchema } from "../../src"; +import { literal } from "../../src/types"; + +describe("literal", () => { + test("literal", () => { + const schema = createSchema("test", { + role: literal("admin", "moderator"), + }); + + // @ts-expect-error + expect(() => Schema.encode(schema, {})).toThrowError( + "unknown value 'undefined', literal may only specify known values", + ); + // @ts-expect-error + expect(() => Schema.encode(schema, { role: "user" })).toThrowError( + "unknown value 'user', literal may only specify known values", + ); + const data = Schema.encode(schema, { role: "admin" }); + expect(data).toStrictEqual({ role: "admin" }); + }); +}); diff --git a/tests/types/long.test.ts b/tests/types/long.test.ts new file mode 100644 index 0000000..b2c3f51 --- /dev/null +++ b/tests/types/long.test.ts @@ -0,0 +1,147 @@ +import { Long } from "mongodb"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { createDatabase, createSchema, Schema } from "../../src"; +import { long } from "../../src/types"; +import { createMockDatabase } from "../mock"; + +describe("long()", () => { + test("validates Long type", () => { + const schema = createSchema("test", { + value: long(), + }); + + const testLong = Long.fromNumber(123456789); + const data = Schema.encode(schema, { value: testLong }); + expect(data).toStrictEqual({ value: testLong }); + expect(Long.isLong(data.value)).toBe(true); + }); + + test("keeps safe integers as numbers", () => { + const schema = createSchema("test", { + value: long(), + }); + + const data = Schema.encode(schema, { value: 123456789 }); + expect(typeof data.value).toBe("number"); + expect(data.value).toBe(123456789); + }); + + test("converts unsafe numbers to Long", () => { + const schema = createSchema("test", { + value: long(), + }); + + const unsafeNumber = Number.MAX_SAFE_INTEGER + 1; + const data = Schema.encode(schema, { value: unsafeNumber }); + expect(Long.isLong(data.value)).toBe(true); + }); + + test("rejects invalid values", () => { + const schema = createSchema("test", { + value: long(), + }); + + // @ts-expect-error + expect(() => Schema.encode(schema, { value: "not a long" })).toThrowError( + "expected 'Long', 'number', or 'bigint' received 'string'", + ); + // @ts-expect-error + expect(() => Schema.encode(schema, { value: {} })).toThrowError( + "expected 'Long', 'number', or 'bigint' received 'object'", + ); + }); + + test("works with nullable and optional", () => { + const schema = createSchema("test", { + nullableLong: long().nullable(), + optionalLong: long().optional(), + }); + + const nullData = Schema.encode(schema, { nullableLong: null }); + expect(nullData).toStrictEqual({ nullableLong: null }); + + const undefinedData = Schema.encode(schema, { nullableLong: Long.fromNumber(100) }); + expect((undefinedData.nullableLong as Long).toNumber()).toBe(100); + }); + + describe("Database Integration", async () => { + const { server, client } = await createMockDatabase(); + + beforeAll(async () => { + await client.connect(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + const BsonDataSchema = createSchema("bson_data_long", { + longField: long().optional(), + }); + + const { collections } = createDatabase(client.db(), { + bsonData: BsonDataSchema, + }); + + afterAll(async () => { + await collections.bsonData.deleteMany({}).exec(); + }); + + test("accepts Long (large value) and returns Long", async () => { + const testLong = Long.fromString("9223372036854775807"); + + const inserted = await collections.bsonData + .insertOne({ + longField: testLong, + }) + .exec(); + + expect(Long.isLong(inserted.longField)).toBe(true); + expect((inserted.longField as Long).toString()).toBe("9223372036854775807"); + + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + expect(retrieved).not.toBeNull(); + expect(retrieved!.longField).toBeDefined(); + expect(Long.isLong(retrieved!.longField)).toBe(true); + expect((retrieved!.longField as Long).toString()).toBe("9223372036854775807"); + expect(retrieved!.longField).toEqual(inserted.longField); + }); + + test("accepts number (safe integer) and returns number", async () => { + const inserted = await collections.bsonData + .insertOne({ + longField: 123456789, + }) + .exec(); + + expect(typeof inserted.longField).toBe("number"); + expect(inserted.longField).toBe(123456789); + + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + expect(retrieved).not.toBeNull(); + expect(retrieved!.longField).toBeDefined(); + expect(typeof retrieved!.longField).toBe("number"); + expect(retrieved!.longField).toBe(123456789); + expect(retrieved!.longField).toEqual(inserted.longField); + }); + + test("accepts bigint (outside safe range) and returns Long", async () => { + const inserted = await collections.bsonData + .insertOne({ + longField: BigInt("9223372036854775807"), + }) + .exec(); + + expect(Long.isLong(inserted.longField)).toBe(true); + expect((inserted.longField as Long).toString()).toBe("9223372036854775807"); + + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + expect(retrieved).not.toBeNull(); + expect(retrieved!.longField).toBeDefined(); + expect(Long.isLong(retrieved!.longField)).toBe(true); + expect((retrieved!.longField as Long).toString()).toBe("9223372036854775807"); + expect(retrieved!.longField).toEqual(inserted.longField); + }); + }); +}); diff --git a/tests/types/mixed.test.ts b/tests/types/mixed.test.ts new file mode 100644 index 0000000..8a16395 --- /dev/null +++ b/tests/types/mixed.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "vitest"; +import { Schema, createSchema } from "../../src"; +import { mixed } from "../../src/types"; + +describe("mixed", () => { + test("mixed", () => { + const schema = createSchema("test", { + anything: mixed(), + }); + + const data1 = Schema.encode(schema, { anything: "string" }); + expect(data1).toStrictEqual({ anything: "string" }); + + const data2 = Schema.encode(schema, { anything: 42 }); + expect(data2).toStrictEqual({ anything: 42 }); + + const data3 = Schema.encode(schema, { anything: true }); + expect(data3).toStrictEqual({ anything: true }); + + const data4 = Schema.encode(schema, { anything: { nested: "object" } }); + expect(data4).toStrictEqual({ anything: { nested: "object" } }); + + const data5 = Schema.encode(schema, { anything: [1, "2", false] }); + expect(data5).toStrictEqual({ anything: [1, "2", false] }); + + const data6 = Schema.encode(schema, { anything: null }); + expect(data6).toStrictEqual({ anything: null }); + }); +}); diff --git a/tests/types/number.test.ts b/tests/types/number.test.ts new file mode 100644 index 0000000..ea26f49 --- /dev/null +++ b/tests/types/number.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; +import { Schema, createSchema } from "../../src"; +import { number } from "../../src/types"; + +describe("number", () => { + test("number validation", () => { + const schema = createSchema("test", { + value: number(), + }); + + const data = Schema.encode(schema, { value: 42 }); + expect(data).toStrictEqual({ value: 42 }); + + // @ts-expect-error + expect(() => Schema.encode(schema, { value: "42" })).toThrowError("expected 'number' received 'string'"); + }); + + test("min and max constraints", () => { + const schema = createSchema("test", { + min: number().min(5), + max: number().max(10), + }); + + expect(() => Schema.encode(schema, { min: 3, max: 8 })).toThrowError("number must be greater than or equal to 5"); + expect(() => Schema.encode(schema, { min: 6, max: 12 })).toThrowError("number must be less than or equal to 10"); + + const data = Schema.encode(schema, { min: 7, max: 8 }); + expect(data).toStrictEqual({ min: 7, max: 8 }); + }); + + test("integer conversion", () => { + const schema = createSchema("test", { + value: number().integer(), + }); + + expect(() => Schema.encode(schema, { value: 5.7 })).toThrowError("number must be an integer"); + }); + + test("validation error order - type error before value validation", () => { + // Test that type validation errors occur before chained validation method errors + const schema = createSchema("test", { + minValue: number().min(5), + maxValue: number().max(10), + integerValue: number().integer(), + }); + + // Invalid type for number().min() should throw type error first + expect(() => + Schema.encode(schema, { + minValue: "not a number" as any, + maxValue: 8, + integerValue: 6, + }), + ).toThrowError("expected 'number' received 'string'"); + + // Invalid type for number().max() should throw type error first + expect(() => + Schema.encode(schema, { + minValue: 7, + maxValue: true as any, + integerValue: 6, + }), + ).toThrowError("expected 'number' received 'boolean'"); + + // Invalid type for number().integer() should throw type error first + expect(() => + Schema.encode(schema, { + minValue: 7, + maxValue: 8, + integerValue: { value: 6 } as any, + }), + ).toThrowError("expected 'number' received 'object'"); + }); +}); diff --git a/tests/types/object.test.ts b/tests/types/object.test.ts new file mode 100644 index 0000000..fa0023a --- /dev/null +++ b/tests/types/object.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "vitest"; +import { Schema, createSchema } from "../../src"; +import { boolean, literal, object } from "../../src/types"; + +describe("object", () => { + test("object", () => { + const schema = createSchema("test", { + permissions: object({ + canUpdate: boolean(), + canDelete: boolean().default(false), + role: literal("admin", "moderator", "customer"), + }), + }); + + // @ts-expect-error + expect(() => Schema.encode(schema, {})).toThrowError("expected 'object' received 'undefined'"); + expect(() => + // @ts-expect-error + Schema.encode(schema, { permissions: { canUpdate: "yes" } }), + ).toThrowError("field 'canUpdate' expected 'boolean' received 'string'"); + // fields are validates in the order they are registered in type + expect(() => + // @ts-expect-error + Schema.encode(schema, { permissions: { role: false } }), + ).toThrowError("field 'canUpdate' expected 'boolean' received 'undefined'"); + // unknwon fields are rejected + expect(() => + Schema.encode(schema, { + // @ts-expect-error + permissions: { canUpdate: true, role: "admin", canCreate: true }, + }), + ).toThrowError("unknown field 'canCreate', object may only specify known fields"); + const data = Schema.encode(schema, { + permissions: { canUpdate: true, role: "moderator" }, + }); + expect(data).toStrictEqual({ + permissions: { canUpdate: true, canDelete: false, role: "moderator" }, + }); + }); +}); diff --git a/tests/types/objectid.test.ts b/tests/types/objectid.test.ts new file mode 100644 index 0000000..066a76b --- /dev/null +++ b/tests/types/objectid.test.ts @@ -0,0 +1,118 @@ +import { ObjectId } from "mongodb"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { createDatabase, createSchema, Schema } from "../../src"; +import { objectId } from "../../src/types"; +import { createMockDatabase } from "../mock"; + +describe("objectId()", () => { + test("validates ObjectId type", () => { + const schema = createSchema("test", { + id: objectId(), + }); + + const testId = new ObjectId(); + const data = Schema.encode(schema, { id: testId }); + expect(data.id).toBeInstanceOf(ObjectId); + expect(data.id.toString()).toBe(testId.toString()); + }); + + test("converts valid string to ObjectId", () => { + const schema = createSchema("test", { + id: objectId(), + }); + + const validId = "507f1f77bcf86cd799439011"; + const data = Schema.encode(schema, { id: validId }); + expect(data.id).toBeInstanceOf(ObjectId); + expect(data.id.toString()).toBe(validId); + }); + + test("rejects invalid ObjectId strings", () => { + const schema = createSchema("test", { + id: objectId(), + }); + + // @ts-expect-error + expect(() => Schema.encode(schema, { id: "invalid" })).toThrowError("expected valid ObjectId"); + // @ts-expect-error + expect(() => Schema.encode(schema, { id: {} })).toThrowError("expected valid ObjectId"); + }); + + test("works with nullable and optional", () => { + const schema = createSchema("test", { + nullableId: objectId().nullable(), + optionalId: objectId().optional(), + }); + + const nullData = Schema.encode(schema, { nullableId: null }); + expect(nullData).toStrictEqual({ nullableId: null }); + + const testId = new ObjectId(); + const undefinedData = Schema.encode(schema, { nullableId: testId }); + expect(undefinedData.nullableId).toBeInstanceOf(ObjectId); + expect(undefinedData.nullableId?.toString()).toBe(testId.toString()); + }); + + describe("Database Integration", async () => { + const { server, client } = await createMockDatabase(); + + beforeAll(async () => { + await client.connect(); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + const TestSchema = createSchema("objectid_test", { + refId: objectId().optional(), + }); + + const { collections } = createDatabase(client.db(), { + testData: TestSchema, + }); + + afterAll(async () => { + await collections.testData.deleteMany({}).exec(); + }); + + test("accepts ObjectId and returns ObjectId", async () => { + const testId = new ObjectId(); + + const inserted = await collections.testData + .insertOne({ + refId: testId, + }) + .exec(); + + expect(inserted.refId).toBeInstanceOf(ObjectId); + expect(inserted.refId?.toString()).toBe(testId.toString()); + + const retrieved = await collections.testData.findOne({ _id: inserted._id }).exec(); + expect(retrieved).not.toBeNull(); + expect(retrieved!.refId).toBeInstanceOf(ObjectId); + expect(retrieved!.refId?.toString()).toBe(testId.toString()); + expect(retrieved!.refId).toEqual(inserted.refId); + }); + + test("accepts valid string and returns ObjectId", async () => { + const validId = "507f1f77bcf86cd799439011"; + + const inserted = await collections.testData + .insertOne({ + refId: validId, + }) + .exec(); + + expect(inserted.refId).toBeInstanceOf(ObjectId); + expect(inserted.refId?.toString()).toBe(validId); + + const retrieved = await collections.testData.findOne({ _id: inserted._id }).exec(); + expect(retrieved).not.toBeNull(); + expect(retrieved!.refId).toBeInstanceOf(ObjectId); + expect(retrieved!.refId?.toString()).toBe(validId); + expect(retrieved!.refId).toEqual(inserted.refId); + }); + }); +}); diff --git a/tests/types/record.test.ts b/tests/types/record.test.ts new file mode 100644 index 0000000..6a93c4f --- /dev/null +++ b/tests/types/record.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; +import { Schema, createSchema } from "../../src"; +import { number, record } from "../../src/types"; + +describe("record", () => { + test("record", () => { + const schema = createSchema("test", { + grades: record(number()), + }); + + // @ts-expect-error + expect(() => Schema.encode(schema, {})).toThrowError("expected 'object' received 'undefined'"); + // empty object is ok + expect(() => Schema.encode(schema, { grades: {} })).not.toThrowError(); + expect(() => + // @ts-expect-error + Schema.encode(schema, { grades: { math: "50" } }), + ).toThrowError("field 'math' expected 'number' received 'string'"); + const data = Schema.encode(schema, { grades: { math: 50 } }); + expect(data).toStrictEqual({ grades: { math: 50 } }); + }); +}); diff --git a/tests/types/string.test.ts b/tests/types/string.test.ts new file mode 100644 index 0000000..82b4bf7 --- /dev/null +++ b/tests/types/string.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "vitest"; +import { Schema, createSchema } from "../../src"; +import { string } from "../../src/types"; + +describe("string", () => { + test("lowercase and uppercase", () => { + const schema = createSchema("test", { + lower: string().lowercase(), + upper: string().uppercase(), + }); + const data = Schema.encode(schema, { lower: "HELLO", upper: "hello" }); + expect(data).toStrictEqual({ lower: "hello", upper: "HELLO" }); + }); + + test("length validations", () => { + const schema = createSchema("test", { + min: string().minLength(3), + max: string().maxLength(5), + exact: string().length(4), + }); + + expect(() => Schema.encode(schema, { min: "ab", max: "test", exact: "test" })).toThrowError( + "string must be at least 3 characters long", + ); + expect(() => Schema.encode(schema, { min: "test", max: "toolong", exact: "test" })).toThrowError( + "string must be at most 5 characters long", + ); + expect(() => Schema.encode(schema, { min: "test", max: "test", exact: "toolong" })).toThrowError( + "string must be exactly 4 characters long", + ); + + const data = Schema.encode(schema, { + min: "abc", + max: "test", + exact: "test", + }); + expect(data).toStrictEqual({ min: "abc", max: "test", exact: "test" }); + }); + + test("pattern", () => { + const schema = createSchema("test", { + email: string().pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/), + }); + + expect(() => Schema.encode(schema, { email: "invalid" })).toThrowError( + "string must match pattern /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/", + ); + + const data = Schema.encode(schema, { email: "test@example.com" }); + expect(data).toStrictEqual({ email: "test@example.com" }); + }); + + test("trim", () => { + const schema = createSchema("test", { + trimmed: string().trim(), + }); + + const data = Schema.encode(schema, { trimmed: " hello " }); + expect(data).toStrictEqual({ trimmed: "hello" }); + }); + + test("nonempty", () => { + const schema = createSchema("test", { + required: string().nonempty(), + }); + + expect(() => Schema.encode(schema, { required: "" })).toThrowError("string must not be empty"); + + const data = Schema.encode(schema, { required: "hello" }); + expect(data).toStrictEqual({ required: "hello" }); + }); + + test("includes", () => { + const schema = createSchema("test", { + contains: string().includes("world"), + }); + + expect(() => Schema.encode(schema, { contains: "hello" })).toThrowError('string must include "world"'); + + const data = Schema.encode(schema, { contains: "hello world" }); + expect(data).toStrictEqual({ contains: "hello world" }); + }); +}); diff --git a/tests/types/tuple.test.ts b/tests/types/tuple.test.ts new file mode 100644 index 0000000..81557ca --- /dev/null +++ b/tests/types/tuple.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "vitest"; +import { Schema, createSchema } from "../../src"; +import { number, string, tuple } from "../../src/types"; + +describe("tuple", () => { + test("tuple", () => { + const schema = createSchema("test", { + items: tuple([number(), string()]), + }); + + // @ts-expect-error + expect(() => Schema.encode(schema, {})).toThrowError("expected 'array' received 'undefined'"); + // @ts-expect-error + expect(() => Schema.encode(schema, { items: [] })).toThrowError( + "expected array with 2 elements received 0 elements", + ); + const data = Schema.encode(schema, { items: [0, "1"] }); + expect(data).toStrictEqual({ items: [0, "1"] }); + // @ts-expect-error + expect(() => Schema.encode(schema, { items: [1, "1", 2] })).toThrowError( + "expected array with 2 elements received 3 elements", + ); + }); +}); diff --git a/tests/types/type.test.ts b/tests/types/type.test.ts new file mode 100644 index 0000000..2169d9f --- /dev/null +++ b/tests/types/type.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, it, test, vi } from "vitest"; +import { Schema, createSchema } from "../../src"; +import { MonarchParseError } from "../../src/errors"; +import { pipe, type } from "../../src/types"; + +const simpleString = () => type((input) => input); +const simpleNumber = () => type((input) => input); + +describe("type()", () => { + it("validates and transforms input", () => { + const schema = createSchema("users", { + age: simpleNumber() + .validate((input) => input >= 0, "must be positive") + .transform((input) => `${input}`), + }); + + expect(() => Schema.encode(schema, { age: -1 })).toThrowError("must be positive"); + + const data = Schema.encode(schema, { + age: 0, + }); + expect(data).toStrictEqual({ age: "0" }); + }); + + it("validate and transform order", () => { + const schema = createSchema("test", { + name: simpleString() + .transform((input) => input.toUpperCase()) + .validate((input) => input.toUpperCase() === input, "String is not in all caps"), + }); + expect(() => Schema.encode(schema, { name: "somename" })).not.toThrowError(); + }); + + test("nullable", () => { + // transform is skipped when value is null + const schema1 = createSchema("users", { + age: simpleNumber() + .transform((input) => `${input}`) + .nullable(), + }); + const data1 = Schema.encode(schema1, { age: null }); + expect(data1).toStrictEqual({ age: null }); + + // transform is applied when value is not null + const schema2 = createSchema("users", { + age: simpleNumber() + .transform((input) => `${input}`) + .nullable(), + }); + const data2 = Schema.encode(schema2, { age: 0 }); + expect(data2).toStrictEqual({ age: "0" }); + }); + + test("optional", () => { + // transform is skipped when value is undefined + const schema1 = createSchema("users", { + age: simpleNumber() + .transform((input) => `${input}`) + .optional(), + }); + const data1 = Schema.encode(schema1, {}); + expect(data1).toStrictEqual({}); + + // transform is applied when value is not undefined + const schema2 = createSchema("users", { + age: simpleNumber() + .transform((input) => `${input}`) + .optional(), + }); + const data2 = Schema.encode(schema2, { age: 0 }); + expect(data2).toStrictEqual({ age: "0" }); + }); + + test("default", () => { + // default value is used when value is ommited + const defaultFnTrap1 = vi.fn(() => 11); + const schema1 = createSchema("users", { + age: simpleNumber() + .transform((input) => `${input}`) + .default(10), + ageLazy: simpleNumber() + .transform((input) => `${input}`) + .default(defaultFnTrap1), + }); + const data1 = Schema.encode(schema1, {}); + expect(data1).toStrictEqual({ age: "10", ageLazy: "11" }); + expect(defaultFnTrap1).toHaveBeenCalledTimes(1); + + // default value is ignored when value is not null or undefined + const defaultFnTrap2 = vi.fn(() => 11); + const schema2 = createSchema("users", { + age: simpleNumber() + .transform((input) => `${input}`) + .default(10), + ageLazy: simpleNumber() + .transform((input) => `${input}`) + .default(defaultFnTrap2), + }); + const data2 = Schema.encode(schema2, { age: 1, ageLazy: 2 }); + expect(data2).toStrictEqual({ age: "1", ageLazy: "2" }); + expect(defaultFnTrap2).toHaveBeenCalledTimes(0); + }); + + describe("pipe", () => { + test("pipes types in sequence", () => { + const outerValidateFnTrap = vi.fn(() => true); + const innerValidateFnTrap = vi.fn(() => true); + + const schema = createSchema("test", { + count: pipe( + simpleString() + .validate(outerValidateFnTrap, "invalid string") + .transform((input) => Number.parseInt(input)), + simpleNumber() + .validate(innerValidateFnTrap, "invalid number") + .transform((num) => `count-${num * 1000}`), + ), + }); + + const data = Schema.encode(schema, { count: "1" }); + expect(data).toStrictEqual({ count: "count-1000" }); + expect(outerValidateFnTrap).toHaveBeenCalledTimes(1); + expect(innerValidateFnTrap).toHaveBeenCalledTimes(1); + }); + + test("pipe with default on pipe input", () => { + const outerValidateFnTrap = vi.fn(() => true); + const innerValidateFnTrap = vi.fn(() => true); + + const schema = createSchema("test", { + count: pipe( + simpleString() + .validate(outerValidateFnTrap, "invalid string") + .transform((input) => Number.parseInt(input)) + .default("2"), + simpleNumber() + .validate(innerValidateFnTrap, "invalid number") + .transform((num) => `count-${num * 1000}`), + ), + }); + + const data = Schema.encode(schema, {}); + expect(data).toStrictEqual({ count: "count-2000" }); + expect(outerValidateFnTrap).toHaveBeenCalledTimes(1); + expect(innerValidateFnTrap).toHaveBeenCalledTimes(1); + }); + + test("pipe with default on pipe", () => { + const outerValidateFnTrap = vi.fn(() => true); + const innerValidateFnTrap = vi.fn(() => true); + + const schema = createSchema("test", { + count: pipe( + simpleString() + .validate(outerValidateFnTrap, "invalid string") + .transform((input) => Number.parseInt(input)), + simpleNumber() + .validate(innerValidateFnTrap, "invalid number") + .transform((num) => `count-${num * 1000}`), + ).default("3"), + }); + + const data = Schema.encode(schema, {}); + expect(data).toStrictEqual({ count: "count-3000" }); + expect(outerValidateFnTrap).toHaveBeenCalledTimes(1); + expect(innerValidateFnTrap).toHaveBeenCalledTimes(1); + }); + + test("pipe with default on pipe output", () => { + const outerValidateFnTrap = vi.fn(() => true); + const innerValidateFnTrap = vi.fn(() => true); + + const schema = createSchema("test", { + count: pipe( + simpleString() + .validate(outerValidateFnTrap, "invalid string") + .transform((input) => Number.parseInt(input)) + .optional(), + simpleNumber() + .validate(innerValidateFnTrap, "invalid number") + .transform((num) => `count-${num * 1000}`) + .default(4), + ), + }); + + const data = Schema.encode(schema, {}); + expect(data).toStrictEqual({ count: "count-4000" }); + expect(outerValidateFnTrap).toHaveBeenCalledTimes(0); + expect(innerValidateFnTrap).toHaveBeenCalledTimes(1); + }); + }); + + describe("extend", () => { + test("preprocess runs before base parser", () => { + const executionOrder: string[] = []; + + // Create a custom type with a base parser that tracks execution + const baseType = type((input) => { + executionOrder.push("base-parser"); + return input; + }); + + // Extend with preprocess + const extendedType = type((input) => input).extend(baseType, { + preprocess: (input) => { + executionOrder.push("preprocess"); + return input; + }, + }); + + const schema = createSchema("test", { value: extendedType }); + Schema.encode(schema, { value: "test" }); + + expect(executionOrder).toEqual(["preprocess", "base-parser"]); + }); + + test("parse runs after base parser", () => { + const executionOrder: string[] = []; + + // Create a custom type with a base parser that tracks execution + const baseType = type((input) => { + executionOrder.push("base-parser"); + return input; + }); + + // Extend with parse + const extendedType = type((input) => input).extend(baseType, { + parse: (input) => { + executionOrder.push("parse"); + return input; + }, + }); + + const schema = createSchema("test", { value: extendedType }); + Schema.encode(schema, { value: "test" }); + + expect(executionOrder).toEqual(["base-parser", "parse"]); + }); + + test("execution order is preprocess -> base parser -> parse", () => { + const executionOrder: string[] = []; + + // Create a custom type with a base parser that tracks execution + const baseType = type((input) => { + executionOrder.push("base-parser"); + return input; + }); + + // Extend with both preprocess and parse + const extendedType = type((input) => input).extend(baseType, { + preprocess: (input) => { + executionOrder.push("preprocess"); + return input; + }, + parse: (input) => { + executionOrder.push("parse"); + return input; + }, + }); + + const schema = createSchema("test", { value: extendedType }); + Schema.encode(schema, { value: "test" }); + + expect(executionOrder).toEqual(["preprocess", "base-parser", "parse"]); + }); + + test("preprocess can transform input before base parser", () => { + const baseType = type((input) => { + if (input !== "PREPROCESSED") { + throw new MonarchParseError("expected preprocessed input"); + } + return input; + }); + + const extendedType = type((input) => input).extend(baseType, { + preprocess: () => "PREPROCESSED", + }); + + const schema = createSchema("test", { value: extendedType }); + const data = Schema.encode(schema, { value: "anything" }); + + expect(data).toStrictEqual({ value: "PREPROCESSED" }); + }); + + test("parse can transform output after base parser", () => { + const baseType = type((input) => input.toUpperCase()); + + const extendedType = type((input) => input).extend(baseType, { + parse: (input) => input + "-PARSED", + }); + + const schema = createSchema("test", { value: extendedType }); + const data = Schema.encode(schema, { value: "hello" }); + + expect(data).toStrictEqual({ value: "HELLO-PARSED" }); + }); + + test("preprocess and parse work together for complete transformation pipeline", () => { + // Base type that converts string to uppercase + const baseType = type((input) => input.toUpperCase()); + + const extendedType = type((input) => input).extend(baseType, { + preprocess: (input) => { + // Trim in preprocess + return input.trim(); + }, + parse: (input) => { + // Add prefix and suffix in parse + return `[${input}]`; + }, + }); + + const schema = createSchema("test", { value: extendedType }); + const data = Schema.encode(schema, { value: " hello " }); + + expect(data).toStrictEqual({ value: "[HELLO]" }); + }); + + test("real-world example: string validation with preprocess and parse", () => { + const baseType = type((input) => input); + + const trimmedAndValidatedString = type((input) => input).extend(baseType, { + preprocess: (input) => input.trim(), + parse: (input) => { + if (input.length === 0) { + throw new MonarchParseError("string must not be empty after trimming"); + } + return input; + }, + }); + + const schema = createSchema("test", { value: trimmedAndValidatedString }); + + // Should trim in preprocess, then validate in parse + const data = Schema.encode(schema, { value: " hello " }); + expect(data).toStrictEqual({ value: "hello" }); + + // Should fail validation after trimming + expect(() => Schema.encode(schema, { value: " " })).toThrowError("string must not be empty after trimming"); + }); + + test("real-world example: number with range validation using parse", () => { + const baseType = type((input) => input); + + const percentageNumber = type((input) => input).extend(baseType, { + parse: (input) => { + if (input < 0 || input > 100) { + throw new MonarchParseError("number must be between 0 and 100"); + } + return input; + }, + }); + + const schema = createSchema("test", { value: percentageNumber }); + + const data = Schema.encode(schema, { value: 50 }); + expect(data).toStrictEqual({ value: 50 }); + + expect(() => Schema.encode(schema, { value: 150 })).toThrowError("number must be between 0 and 100"); + expect(() => Schema.encode(schema, { value: -10 })).toThrowError("number must be between 0 and 100"); + }); + + test("multiple extends chain correctly", () => { + const executionOrder: string[] = []; + + const baseType = type((input) => { + executionOrder.push("base-parser"); + return input; + }); + + const firstExtend = type((input) => input).extend(baseType, { + preprocess: (input) => { + executionOrder.push("first-preprocess"); + return input; + }, + parse: (input) => { + executionOrder.push("first-parse"); + return input; + }, + }); + + const secondExtend = type((input) => input).extend(firstExtend, { + preprocess: (input) => { + executionOrder.push("second-preprocess"); + return input; + }, + parse: (input) => { + executionOrder.push("second-parse"); + return input; + }, + }); + + const schema = createSchema("test", { value: secondExtend }); + Schema.encode(schema, { value: "test" }); + + // Second extend's preprocess -> first extend's full pipeline -> second extend's parse + expect(executionOrder).toEqual([ + "second-preprocess", + "first-preprocess", + "base-parser", + "first-parse", + "second-parse", + ]); + }); + }); +}); diff --git a/tests/types/union.test.ts b/tests/types/union.test.ts new file mode 100644 index 0000000..24e19b2 --- /dev/null +++ b/tests/types/union.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "vitest"; +import { Schema, createSchema } from "../../src"; +import { number, object, string, taggedUnion, tuple, union } from "../../src/types"; + +describe("Union Types", () => { + describe("union", () => { + test("union", () => { + const schema = createSchema("milf", { + emailOrPhone: union(string(), number()), + }); + + // @ts-expect-error + expect(() => Schema.encode(schema, { emailOrPhone: {} })).toThrowError( + "no matching variant found for union type", + ); + // @ts-expect-error + expect(() => Schema.encode(schema, { emailOrPhone: [] })).toThrowError( + "no matching variant found for union type", + ); + // @ts-expect-error + expect(() => Schema.encode(schema, { emailOrPhone: null })).toThrowError( + "no matching variant found for union type", + ); + + const data1 = Schema.encode(schema, { emailOrPhone: "test" }); + expect(data1).toStrictEqual({ emailOrPhone: "test" }); + + const data2 = Schema.encode(schema, { emailOrPhone: 42 }); + expect(data2).toStrictEqual({ emailOrPhone: 42 }); + }); + }); + + describe("taggedUnion", () => { + test("taggedUnion", () => { + const schema = createSchema("test", { + color: taggedUnion({ + rgba: object({ r: number(), g: number(), b: number(), a: string() }), + hex: string(), + hsl: tuple([string(), string(), string()]).transform(([f, s, t]) => f + s + t), + }), + }); + + // @ts-expect-error + expect(() => Schema.encode(schema, {})).toThrowError("expected 'object' received 'undefined'"); + // @ts-expect-error + expect(() => Schema.encode(schema, { color: {} })).toThrowError("missing field"); + // @ts-expect-error + expect(() => Schema.encode(schema, { color: { tag: "hex" } })).toThrowError( + "missing field 'value' in tagged union", + ); + expect(() => + // @ts-expect-error + Schema.encode(schema, { color: { value: "#fff" } }), + ).toThrowError("missing field 'tag' in tagged union"); + expect(() => + Schema.encode(schema, { + // @ts-expect-error + color: { tag: "hex", value: "#fff", extra: "user" }, + }), + ).toThrowError("unknown field 'extra', tagged union may only specify 'tag' and 'value' fields"); + expect(() => + // @ts-expect-error + Schema.encode(schema, { color: { tag: "hwb", value: "#fff" } }), + ).toThrowError("unknown tag 'hwb'"); + expect(() => + // @ts-expect-error + Schema.encode(schema, { color: { tag: "hsl", value: "#fff" } }), + ).toThrowError("invalid value for tag 'hsl'"); + const data1 = Schema.encode(schema, { + color: { tag: "rgba", value: { r: 0, g: 0, b: 0, a: "100%" } }, + }); + expect(data1).toStrictEqual({ + color: { tag: "rgba", value: { r: 0, g: 0, b: 0, a: "100%" } }, + }); + const data2 = Schema.encode(schema, { + color: { tag: "hex", value: "#fff" }, + }); + expect(data2).toStrictEqual({ + color: { tag: "hex", value: "#fff" }, + }); + const data3 = Schema.encode(schema, { + color: { tag: "hsl", value: ["0", "0", "0"] }, + }); + expect(data3).toStrictEqual({ + color: { tag: "hsl", value: "000" }, + }); + }); + }); +}); diff --git a/tests/update-mutation.test.ts b/tests/update-mutation.test.ts deleted file mode 100644 index f5d8bbf..0000000 --- a/tests/update-mutation.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { createDatabase, createSchema } from "../src"; -import { number, string } from "../src/types"; -import { createMockDatabase } from "./mock"; - -describe("Update mutation", async () => { - const { server, client } = await createMockDatabase(); - - beforeAll(async () => { - await client.connect(); - }); - - afterEach(async () => { - await client.db().dropDatabase(); - }); - - afterAll(async () => { - await client.close(); - await server.stop(); - }); - - it("should not mutate reused update object in updateOne", async () => { - const schema = createSchema("users", { - name: string(), - age: number().onUpdate(() => 999), - }); - const db = createDatabase(client.db(), { users: schema }); - - const user1 = await db.collections.users.insertOne({ name: "Alice", age: 20 }).exec(); - const user2 = await db.collections.users.insertOne({ name: "Bob", age: 30 }).exec(); - - // Create a reusable update object - const updateObj = { $set: { name: "Updated" } }; - - // Use the same update object twice - await db.collections.users.updateOne({ _id: user1._id }, updateObj).exec(); - await db.collections.users.updateOne({ _id: user2._id }, updateObj).exec(); - - // Verify users were updated correctly with auto-update - const updatedUser1 = await db.collections.users.findOne({ _id: user1._id }).exec(); - expect(updatedUser1?.name).toBe("Updated"); - expect(updatedUser1?.age).toBe(999); - - const updatedUser2 = await db.collections.users.findOne({ _id: user2._id }).exec(); - expect(updatedUser2?.name).toBe("Updated"); - expect(updatedUser2?.age).toBe(999); - - // The key test: original object should not be mutated - expect(updateObj).toStrictEqual({ $set: { name: "Updated" } }); - }); - - it("should not mutate reused update object in updateMany", async () => { - const schema = createSchema("users", { - name: string(), - age: number().onUpdate(() => 888), - }); - const db = createDatabase(client.db(), { users: schema }); - - await db.collections.users.insertOne({ name: "Alice", age: 20 }).exec(); - await db.collections.users.insertOne({ name: "Bob", age: 30 }).exec(); - await db.collections.users.insertOne({ name: "Charlie", age: 40 }).exec(); - - const updateObj = { $set: { name: "Updated" } }; - - // Use the same update object twice for different filters - await db.collections.users.updateMany({ age: { $lt: 30 } }, updateObj).exec(); - await db.collections.users.updateMany({ age: { $gte: 30 } }, updateObj).exec(); - - // Verify all users were updated - const users = await db.collections.users.find({}).exec(); - expect(users).toHaveLength(3); - for (const user of users) { - expect(user.name).toBe("Updated"); - expect(user.age).toBe(888); - } - - // Original object should not be mutated - expect(updateObj).toStrictEqual({ $set: { name: "Updated" } }); - }); - - it("should not mutate reused update object in findOneAndUpdate", async () => { - const schema = createSchema("users", { - name: string(), - age: number().onUpdate(() => 777), - }); - const db = createDatabase(client.db(), { users: schema }); - - const user1 = await db.collections.users.insertOne({ name: "Alice", age: 20 }).exec(); - const user2 = await db.collections.users.insertOne({ name: "Bob", age: 30 }).exec(); - - const updateObj = { $set: { name: "Updated" } }; - - // Use the same update object twice - await db.collections.users - .findOneAndUpdate({ _id: user1._id }, updateObj) - .options({ returnDocument: "after" }) - .exec(); - await db.collections.users - .findOneAndUpdate({ _id: user2._id }, updateObj) - .options({ returnDocument: "after" }) - .exec(); - - // Verify users were updated - const updatedUser1 = await db.collections.users.findOne({ _id: user1._id }).exec(); - expect(updatedUser1?.name).toBe("Updated"); - expect(updatedUser1?.age).toBe(777); - - const updatedUser2 = await db.collections.users.findOne({ _id: user2._id }).exec(); - expect(updatedUser2?.name).toBe("Updated"); - expect(updatedUser2?.age).toBe(777); - - // Original object should not be mutated - expect(updateObj).toStrictEqual({ $set: { name: "Updated" } }); - }); -}); diff --git a/todo.md b/todo.md index e9d018e..a6f905a 100644 --- a/todo.md +++ b/todo.md @@ -24,8 +24,44 @@ Here are some features we need to implement. - [] events like on save, on create and more +### API Improvements + +- [] Add `findByIdAndUpdate()` / `findByIdAndDelete()` shortcuts +- [] Add batch operations helper methods +- [] Make query `.exec()` protected +- [] Fully document public API +- [] Remove need for `ref` by auto reversing `one` relation + +### Missing Features + +- [] Transaction support - wrapper for MongoDB transactions +- [] Schema validation sync - sync Monarch schema to MongoDB validators +- [] Migration support - utilities for schema changes +- [] Repository pattern abstraction +- [] Complete proper schema types +- [] Implement where and query for populations + + +### Documentation Improvements + +- [] Add JSDoc comments on public APIs in Collection class +- [] Document population mechanism in detail +- [] Document virtual fields behavior +- [] Document error handling patterns +- [] Add examples for complex type scenarios + +### Test Coverage Gaps + +- [] Add tests for concurrent operations +- [] Add tests for memory usage with large datasets +- [] Add tests for index creation failures +- [] Add tests for edge cases in union/tagged union types +- [] Uncomment and fix null value handling tests (query.test.ts:78-93) + ### Bugs list - [] Where query argument takes anything even though the intellisense is correct - [] Insert is not a query and does not return the model. - [] optional() does not have any effect on the types +- [] $addToSet is not working +- [] findOneAndUpdate does not validate data From a9bcf1f46f73d23319df73709ef3ec4646974203 Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Thu, 1 Jan 2026 14:45:52 +0100 Subject: [PATCH 04/14] Remove .exec method and rely on awaiting to execute query --- ...ce-boxes-flash.md => lemon-seals-kneel.md} | 2 +- .changeset/odd-numbers-sin.md | 5 + .changeset/purple-hornets-kick.md | 5 + .changeset/ripe-colts-hear.md | 5 + .changeset/sunny-cycles-cross.md | 8 +- README.md | 26 +-- src/collection/pipeline/aggregation.ts | 2 +- src/collection/pipeline/base.ts | 8 +- src/collection/query/base.ts | 8 +- src/collection/query/bulk-write.ts | 2 +- src/collection/query/delete-many.ts | 2 +- src/collection/query/delete-one.ts | 2 +- src/collection/query/distinct.ts | 2 +- src/collection/query/find-one-and-delete.ts | 2 +- src/collection/query/find-one-and-replace.ts | 2 +- src/collection/query/find-one-and-update.ts | 2 +- src/collection/query/find-one.ts | 2 +- src/collection/query/find.ts | 2 +- src/collection/query/insert-many.ts | 2 +- src/collection/query/insert-one.ts | 2 +- src/collection/query/replace-one.ts | 2 +- src/collection/query/update-many.ts | 2 +- src/collection/query/update-one.ts | 2 +- tests/operators.test.ts | 46 ++--- tests/query/aggregate.test.ts | 7 +- tests/query/delete.test.ts | 10 +- tests/query/insert-find.test.ts | 135 +++++++------- tests/query/query-methods.test.ts | 32 ++-- tests/query/update-hooks.test.ts | 169 +++++++----------- tests/query/update.test.ts | 58 +++--- tests/relations/many.test.ts | 26 +-- tests/relations/one.test.ts | 22 +-- tests/relations/population-options.test.ts | 34 ++-- tests/relations/ref.test.ts | 32 ++-- tests/relations/validation.test.ts | 4 +- tests/schema/schema.test.ts | 40 ++--- tests/types/binary.test.ts | 10 +- tests/types/decimal128.test.ts | 14 +- tests/types/long.test.ts | 14 +- tests/types/objectid.test.ts | 10 +- todo.md | 1 - 41 files changed, 359 insertions(+), 402 deletions(-) rename .changeset/{nice-boxes-flash.md => lemon-seals-kneel.md} (51%) create mode 100644 .changeset/odd-numbers-sin.md create mode 100644 .changeset/purple-hornets-kick.md create mode 100644 .changeset/ripe-colts-hear.md diff --git a/.changeset/nice-boxes-flash.md b/.changeset/lemon-seals-kneel.md similarity index 51% rename from .changeset/nice-boxes-flash.md rename to .changeset/lemon-seals-kneel.md index 4879494..97e3149 100644 --- a/.changeset/nice-boxes-flash.md +++ b/.changeset/lemon-seals-kneel.md @@ -2,4 +2,4 @@ "monarch-orm": patch --- -fix build +Use prettier for formatting diff --git a/.changeset/odd-numbers-sin.md b/.changeset/odd-numbers-sin.md new file mode 100644 index 0000000..e0b4a25 --- /dev/null +++ b/.changeset/odd-numbers-sin.md @@ -0,0 +1,5 @@ +--- +"monarch-orm": minor +--- + +Replace aggregate cast method with generic param diff --git a/.changeset/purple-hornets-kick.md b/.changeset/purple-hornets-kick.md new file mode 100644 index 0000000..9e76cd9 --- /dev/null +++ b/.changeset/purple-hornets-kick.md @@ -0,0 +1,5 @@ +--- +"monarch-orm": minor +--- + +Add BSON types `binary()`, `long()` and `decimal128()` diff --git a/.changeset/ripe-colts-hear.md b/.changeset/ripe-colts-hear.md new file mode 100644 index 0000000..54c783b --- /dev/null +++ b/.changeset/ripe-colts-hear.md @@ -0,0 +1,5 @@ +--- +"monarch-orm": minor +--- + +Remove `.exec()` and fully support awaiting to execute query diff --git a/.changeset/sunny-cycles-cross.md b/.changeset/sunny-cycles-cross.md index 82cc57f..d4d973e 100644 --- a/.changeset/sunny-cycles-cross.md +++ b/.changeset/sunny-cycles-cross.md @@ -2,10 +2,4 @@ "monarch-orm": minor --- -Throw error when adding multiple relations/schema for a collection. - -Add BSON types `binary()`, `long()` and `decimal128()`. - -Replace aggregate cast method with generic param. - -Use prettier for formatting. \ No newline at end of file +Throw error when adding multiple relations/schema for a collection \ No newline at end of file diff --git a/README.md b/README.md index 3d26400..a91d7de 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,9 @@ import { boolean, createClient, createDatabase, createSchema, number, string } f age: 0, isVerified: true, }) - .exec(); + ; - const users = await collections.users.find().where({}).exec(); + const users = await collections.users.find().where({}); ``` ## Quick Start @@ -126,7 +126,7 @@ const newUser = await collections.users age: 25, isVerified: true, }) - .exec(); + ; ``` ### Querying Documents @@ -135,11 +135,11 @@ Retrieve documents from your collection using the find or findOne methods. Example: Querying all users ```typescript -const users = await collections.users.find().where({}).exec(); +const users = await collections.users.find().where({}); console.log(users); // Or just... -const users = await collections.users.find({}).exec(); +const users = await collections.users.find({}); console.log(users); @@ -147,13 +147,13 @@ console.log(users); const user = await collections.users.find().where({ name: "Alice" -}).exec(); +}); console.log(users); // Or... const user = await collections.users.findOne({ name: "Alice" -}).exec(); +}); console.log(users); ``` @@ -172,7 +172,7 @@ const updatedUser = await collections.users .where({ name: "Alice", }) - .exec(); + ; console.log(updatedUser); ``` @@ -187,7 +187,7 @@ const updatedUsers = await collections.users .where({ isVerified: false, }) - .exec(); + ; console.log(updatedUsers); ``` @@ -213,7 +213,7 @@ And use it like this ```typescript const user = await UserModel.findOne({ name: "Alice" -}).exec(); +}); console.log(users); ``` @@ -359,10 +359,10 @@ const newUser = await collections.users history: 88, }, }) - .exec(); + ; // Querying the user to retrieve grades -const user = await collections.users.findOne().where({ email: "alice@example.com" }).exec(); +const user = await collections.users.findOne().where({ email: "alice@example.com" }); console.log(user.grades); // Output: { math: 90, science: 85, history: 88 } ``` @@ -441,7 +441,7 @@ await collections.notifications.insert().values({ notification: { subject: "Welcome!", body: "Thank you for joining us.", }, -} }).exec(); +} }); ``` diff --git a/src/collection/pipeline/aggregation.ts b/src/collection/pipeline/aggregation.ts index 81e16d9..5d3e4f8 100644 --- a/src/collection/pipeline/aggregation.ts +++ b/src/collection/pipeline/aggregation.ts @@ -18,7 +18,7 @@ export class AggregationPipeline { + protected async exec(): Promise { const res = await this._collection.aggregate(this._pipeline, this._options).toArray(); return res as TOutput; } diff --git a/src/collection/pipeline/base.ts b/src/collection/pipeline/base.ts index 27b0a3a..c85a553 100644 --- a/src/collection/pipeline/base.ts +++ b/src/collection/pipeline/base.ts @@ -16,22 +16,22 @@ export abstract class Pipeline { return this; } - public abstract exec(): Promise; + protected abstract exec(): Promise; - then( + public then( onfulfilled?: ((value: TOutput) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, ): Promise { return this.exec().then(onfulfilled, onrejected); } - catch( + public catch( onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null, ): Promise { return this.exec().catch(onrejected); } - finally(onfinally?: (() => void) | undefined | null): Promise { + public finally(onfinally?: (() => void) | undefined | null): Promise { return this.exec().finally(onfinally); } } diff --git a/src/collection/query/base.ts b/src/collection/query/base.ts index e23d8a9..6b3b919 100644 --- a/src/collection/query/base.ts +++ b/src/collection/query/base.ts @@ -11,22 +11,22 @@ export abstract class Query { protected _readyPromise: Promise, ) {} - public abstract exec(): Promise; + protected abstract exec(): Promise; - then( + public then( onfulfilled?: ((value: TOutput) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, ): Promise { return this.exec().then(onfulfilled, onrejected); } - catch( + public catch( onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null, ): Promise { return this.exec().catch(onrejected); } - finally(onfinally?: (() => void) | undefined | null): Promise { + public finally(onfinally?: (() => void) | undefined | null): Promise { return this.exec().finally(onfinally); } } diff --git a/src/collection/query/bulk-write.ts b/src/collection/query/bulk-write.ts index ce81ebc..1795b97 100644 --- a/src/collection/query/bulk-write.ts +++ b/src/collection/query/bulk-write.ts @@ -19,7 +19,7 @@ export class BulkWriteQuery extends Query { + protected async exec(): Promise { await this._readyPromise; const res = await this._collection.bulkWrite(this._data, this._options); return res; diff --git a/src/collection/query/delete-many.ts b/src/collection/query/delete-many.ts index ff5bd43..6c6ffa6 100644 --- a/src/collection/query/delete-many.ts +++ b/src/collection/query/delete-many.ts @@ -19,7 +19,7 @@ export class DeleteManyQuery extends Query { + protected async exec(): Promise { await this._readyPromise; const res = await this._collection.deleteMany(this._filter, this._options); return res; diff --git a/src/collection/query/delete-one.ts b/src/collection/query/delete-one.ts index bc4cdfa..260c4c5 100644 --- a/src/collection/query/delete-one.ts +++ b/src/collection/query/delete-one.ts @@ -19,7 +19,7 @@ export class DeleteOneQuery extends Query { + protected async exec(): Promise { await this._readyPromise; const res = await this._collection.deleteOne(this._filter, this._options); return res; diff --git a/src/collection/query/distinct.ts b/src/collection/query/distinct.ts index ac7dc1b..e75d07f 100644 --- a/src/collection/query/distinct.ts +++ b/src/collection/query/distinct.ts @@ -26,7 +26,7 @@ export class DistinctQuery< return this; } - public async exec(): Promise { + protected async exec(): Promise { await this._readyPromise; const res = await this._collection.distinct(this._key as string, this._filter, { diff --git a/src/collection/query/find-one-and-delete.ts b/src/collection/query/find-one-and-delete.ts index e5a5395..130caef 100644 --- a/src/collection/query/find-one-and-delete.ts +++ b/src/collection/query/find-one-and-delete.ts @@ -39,7 +39,7 @@ export class FindOneAndDeleteQuery< return this as FindOneAndDeleteQuery]>; } - public async exec(): Promise | null> { + protected async exec(): Promise | null> { await this._readyPromise; const extras = addExtraInputsToProjection(this._projection, this._schema.options.virtuals); const res = await this._collection.findOneAndDelete(this._filter, { diff --git a/src/collection/query/find-one-and-replace.ts b/src/collection/query/find-one-and-replace.ts index 0471dc8..d51f75b 100644 --- a/src/collection/query/find-one-and-replace.ts +++ b/src/collection/query/find-one-and-replace.ts @@ -40,7 +40,7 @@ export class FindOneAndReplaceQuery< return this as FindOneAndReplaceQuery]>; } - public async exec(): Promise | null> { + protected async exec(): Promise | null> { await this._readyPromise; const extras = addExtraInputsToProjection(this._projection, this._schema.options.virtuals); const res = await this._collection.findOneAndReplace(this._filter, this._replacement, { diff --git a/src/collection/query/find-one-and-update.ts b/src/collection/query/find-one-and-update.ts index 97195df..dfcca8b 100644 --- a/src/collection/query/find-one-and-update.ts +++ b/src/collection/query/find-one-and-update.ts @@ -46,7 +46,7 @@ export class FindOneAndUpdateQuery< return this as FindOneAndUpdateQuery]>; } - public async exec(): Promise | null> { + protected async exec(): Promise | null> { await this._readyPromise; const fieldUpdates = Schema.getFieldUpdates(this._schema) as MatchKeysAndValues>; diff --git a/src/collection/query/find-one.ts b/src/collection/query/find-one.ts index 0cdb5c8..e460bbe 100644 --- a/src/collection/query/find-one.ts +++ b/src/collection/query/find-one.ts @@ -58,7 +58,7 @@ export class FindOneQuery< >; } - public async exec(): Promise | null> { + protected async exec(): Promise | null> { await this._readyPromise; if (Object.keys(this._population).length) { return this._execWithPopulate(); diff --git a/src/collection/query/find.ts b/src/collection/query/find.ts index 14a607b..d03d618 100644 --- a/src/collection/query/find.ts +++ b/src/collection/query/find.ts @@ -81,7 +81,7 @@ export class FindQuery< return this._execWithoutPopulate(); } - public async exec(): Promise[]> { + protected async exec(): Promise[]> { return (await this.cursor()).toArray(); } diff --git a/src/collection/query/insert-many.ts b/src/collection/query/insert-many.ts index 034d57d..70a7e6b 100644 --- a/src/collection/query/insert-many.ts +++ b/src/collection/query/insert-many.ts @@ -27,7 +27,7 @@ export class InsertManyQuery extends Query< return this; } - public async exec(): Promise>> { + protected async exec(): Promise>> { await this._readyPromise; const data = this._data.map((data) => Schema.encode(this._schema, data)); const res = await this._collection.insertMany( diff --git a/src/collection/query/insert-one.ts b/src/collection/query/insert-one.ts index 42fc142..c1d3472 100644 --- a/src/collection/query/insert-one.ts +++ b/src/collection/query/insert-one.ts @@ -28,7 +28,7 @@ export class InsertOneQuery< return this; } - public async exec(): Promise> { + protected async exec(): Promise> { await this._readyPromise; const data = Schema.encode(this._schema, this._data); const res = await this._collection.insertOne( diff --git a/src/collection/query/replace-one.ts b/src/collection/query/replace-one.ts index e18f9d9..fb0daf0 100644 --- a/src/collection/query/replace-one.ts +++ b/src/collection/query/replace-one.ts @@ -20,7 +20,7 @@ export class ReplaceOneQuery extends Query>> { + protected async exec(): Promise>> { await this._readyPromise; const res = await this._collection.replaceOne(this._filter, this._replacement, this._options); return res as UpdateResult>; diff --git a/src/collection/query/update-many.ts b/src/collection/query/update-many.ts index fcaed3b..9513f78 100644 --- a/src/collection/query/update-many.ts +++ b/src/collection/query/update-many.ts @@ -27,7 +27,7 @@ export class UpdateManyQuery extends Query>> { + protected async exec(): Promise>> { await this._readyPromise; const fieldUpdates = Schema.getFieldUpdates(this._schema) as MatchKeysAndValues>; diff --git a/src/collection/query/update-one.ts b/src/collection/query/update-one.ts index 861a8ba..3cc9469 100644 --- a/src/collection/query/update-one.ts +++ b/src/collection/query/update-one.ts @@ -27,7 +27,7 @@ export class UpdateOneQuery extends Query>> { + protected async exec(): Promise>> { await this._readyPromise; const fieldUpdates = Schema.getFieldUpdates(this._schema) as MatchKeysAndValues>; diff --git a/tests/operators.test.ts b/tests/operators.test.ts index 19232cc..d017223 100644 --- a/tests/operators.test.ts +++ b/tests/operators.test.ts @@ -24,7 +24,7 @@ describe("Query operators", async () => { afterEach(async () => { await collections.users.raw().dropIndexes(); - await collections.users.deleteMany({}).exec(); + await collections.users.deleteMany({}); }); afterAll(async () => { @@ -33,7 +33,7 @@ describe("Query operators", async () => { }); it("and operator", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const users = await collections.users .find( and( @@ -45,13 +45,13 @@ describe("Query operators", async () => { }, ), ) - .exec(); + ; expect(users.length).toBe(mockUsers.filter((user) => user.name === "anon" && user.age === 17).length); }); it("or operator", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const users = await collections.users .find( or( @@ -63,13 +63,13 @@ describe("Query operators", async () => { }, ), ) - .exec(); + ; expect(users.length).toBe(mockUsers.filter((user) => user.name === "anon" || user.name === "anon1").length); }); it("nor operator", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const users = await collections.users .find( nor( @@ -81,73 +81,73 @@ describe("Query operators", async () => { }, ), ) - .exec(); + ; expect(users.length).toBe(mockUsers.length - 2); }); it("eq operator", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const users = await collections.users .find({ name: eq("anon1"), }) - .exec(); + ; expect(users.length).toBe(1); }); it("ne operator", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const users = await collections.users .find({ name: neq("anon1"), }) - .exec(); + ; expect(users.length).toBe(mockUsers.length - 1); }); it("gt operator", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const users = await collections.users .find({ age: gt(17), }) - .exec(); + ; expect(users.length).toBe(mockUsers.filter((user) => user.age > 17).length); }); it("gte operator", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const users = await collections.users .find({ age: gte(17), }) - .exec(); + ; expect(users.length).toBe(mockUsers.filter((user) => user.age >= 17).length); }); it("lt operator", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const users = await collections.users .find({ age: lt(17), }) - .exec(); + ; expect(users.length).toBe(mockUsers.filter((user) => user.age < 17).length); }); it("lte operator", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const users = await collections.users .find({ age: lte(17), }) - .exec(); + ; expect(users.length).toBe(mockUsers.filter((user) => user.age <= 17).length); }); @@ -155,12 +155,12 @@ describe("Query operators", async () => { it("in operator", async () => { const ageArray = [17]; - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const users = await collections.users .find({ age: inArray(ageArray), }) - .exec(); + ; expect(users.length).toBe(mockUsers.filter((user) => ageArray.includes(user.age)).length); }); @@ -168,13 +168,13 @@ describe("Query operators", async () => { it("nin operator", async () => { const ageArray = [17, 20, 25]; - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const users = await collections.users .find({ age: notInArray([17, 20, 25]), // age: 3 }) - .exec(); + ; expect(users.length).toBe(mockUsers.filter((user) => !ageArray.includes(user.age)).length); }); diff --git a/tests/query/aggregate.test.ts b/tests/query/aggregate.test.ts index b6c7120..27b6e4d 100644 --- a/tests/query/aggregate.test.ts +++ b/tests/query/aggregate.test.ts @@ -22,7 +22,7 @@ describe("Aggregation Operations", async () => { }); afterEach(async () => { - await collections.users.deleteMany({}).exec(); + await collections.users.deleteMany({}); }); afterAll(async () => { @@ -31,12 +31,11 @@ describe("Aggregation Operations", async () => { }); it("aggregates data", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const result = await collections.users .aggregate() .addStage({ $match: { isVerified: true } }) - .addStage({ $group: { _id: "$isVerified", count: { $sum: 1 } } }) - .exec(); + .addStage({ $group: { _id: "$isVerified", count: { $sum: 1 } } }); expect(result).toBeInstanceOf(Array); expect(result.length).toBeGreaterThanOrEqual(1); }); diff --git a/tests/query/delete.test.ts b/tests/query/delete.test.ts index af7060f..f433608 100644 --- a/tests/query/delete.test.ts +++ b/tests/query/delete.test.ts @@ -22,7 +22,7 @@ describe("Delete Operations", async () => { }); afterEach(async () => { - await collections.users.deleteMany({}).exec(); + await collections.users.deleteMany({}); }); afterAll(async () => { @@ -31,15 +31,15 @@ describe("Delete Operations", async () => { }); it("finds one and deletes", async () => { - await collections.users.insertOne(mockUsers[0]).exec(); - const deletedUser = await collections.users.findOneAndDelete({ email: "anon@gmail.com" }).exec(); + await collections.users.insertOne(mockUsers[0]); + const deletedUser = await collections.users.findOneAndDelete({ email: "anon@gmail.com" }); expect(deletedUser).not.toBe(null); expect(deletedUser?.email).toBe("anon@gmail.com"); }); it("deletes one document", async () => { - await collections.users.insertOne(mockUsers[2]).exec(); - const deleted = await collections.users.deleteOne({ email: "anon2@gmail.com" }).exec(); + await collections.users.insertOne(mockUsers[2]); + const deleted = await collections.users.deleteOne({ email: "anon2@gmail.com" }); expect(deleted.deletedCount).toBe(1); }); }); diff --git a/tests/query/insert-find.test.ts b/tests/query/insert-find.test.ts index 2f86eb0..73dac97 100644 --- a/tests/query/insert-find.test.ts +++ b/tests/query/insert-find.test.ts @@ -30,8 +30,8 @@ describe("Insert and Find Operations", async () => { }); afterEach(async () => { - await collections.users.deleteMany({}).exec(); - await collections.todos.deleteMany({}).exec(); + await collections.users.deleteMany({}); + await collections.todos.deleteMany({}); }); afterAll(async () => { @@ -41,12 +41,12 @@ describe("Insert and Find Operations", async () => { describe("Insert Operations", () => { it("inserts one document with auto-generated ObjectId", async () => { - const newUser1 = await collections.users.insertOne(mockUsers[0]).exec(); + const newUser1 = await collections.users.insertOne(mockUsers[0]); expect(newUser1).toMatchObject(mockUsers[0]); expect(newUser1._id).toBeDefined(); expect(newUser1._id).toBeInstanceOf(ObjectId); - const newUser2 = await collections.users.insertOne(mockUsers[0]).exec(); + const newUser2 = await collections.users.insertOne(mockUsers[0]); expect(newUser2).toMatchObject(mockUsers[0]); expect(newUser2._id).toBeDefined(); expect(newUser2._id).toBeInstanceOf(ObjectId); @@ -55,14 +55,14 @@ describe("Insert and Find Operations", async () => { it("inserts one document with provided ObjectId", async () => { const id = new ObjectId(); - const newUser = await collections.users.insertOne({ _id: id, ...mockUsers[0] }).exec(); + const newUser = await collections.users.insertOne({ _id: id, ...mockUsers[0] }); expect(newUser).toMatchObject(mockUsers[0]); expect(newUser._id).toStrictEqual(id); }); it("inserts one document with string ObjectId", async () => { const id = new ObjectId(); - const newUser = await collections.users.insertOne({ _id: id.toString(), ...mockUsers[0] }).exec(); + const newUser = await collections.users.insertOne({ _id: id.toString(), ...mockUsers[0] }); expect(newUser).toMatchObject(mockUsers[0]); expect(newUser._id).toStrictEqual(id); }); @@ -74,87 +74,81 @@ describe("Insert and Find Operations", async () => { it("rejects invalid ObjectId string", async () => { await expect(async () => { - await collections.users.insertOne({ _id: "not_an_object_id", ...mockUsers[0] }).exec(); + await collections.users.insertOne({ _id: "not_an_object_id", ...mockUsers[0] }); }).rejects.toThrowError("expected valid ObjectId received"); }); it("inserts empty document with default values", async () => { - const emptyUser = await collections.users.insertOne({}).exec(); + const emptyUser = await collections.users.insertOne({}); expect(emptyUser).not.toBe(null); expect(emptyUser.age).toBe(10); expect(emptyUser.isVerified).toBe(false); }); it("applies transformations on insert", async () => { - const user = await collections.users - .insertOne({ - name: "Test", - email: "TEST@EXAMPLE.COM", - age: 30, - isVerified: true, - }) - .exec(); + const user = await collections.users.insertOne({ + name: "Test", + email: "TEST@EXAMPLE.COM", + age: 30, + isVerified: true, + }); expect(user).not.toBe(null); expect(user.email).toBe("test@example.com"); }); it("strips extra fields not in schema", async () => { - const user = await collections.users - .insertOne({ - name: "Extra", - email: "extra@example.com", - age: 40, - isVerified: true, - extraField: "This should be ignored", - } as any) - .exec(); + const user = await collections.users.insertOne({ + name: "Extra", + email: "extra@example.com", + age: 40, + isVerified: true, + extraField: "This should be ignored", + } as any); expect(user).not.toBe(null); expect(user).not.toHaveProperty("extraField"); }); it("inserts many documents", async () => { - const newUsers = await collections.users.insertMany(mockUsers).exec(); + const newUsers = await collections.users.insertMany(mockUsers); expect(newUsers.insertedCount).toBe(mockUsers.length); }); it("bulk writes", async () => { - const bulkWriteResult = await collections.users - .bulkWrite([ - { - insertOne: { - document: { - name: "bulk1", - email: "bulk1@gmail.com", - age: 22, - isVerified: false, - }, + const bulkWriteResult = await collections.users.bulkWrite([ + { + insertOne: { + document: { + name: "bulk1", + email: "bulk1@gmail.com", + age: 22, + isVerified: false, }, }, - { - insertOne: { - document: { - name: "bulk2", - email: "bulk2@gmail.com", - age: 23, - isVerified: true, - }, + }, + { + insertOne: { + document: { + name: "bulk2", + email: "bulk2@gmail.com", + age: 23, + isVerified: true, }, }, - ]) - .exec(); + }, + ]); expect(bulkWriteResult.insertedCount).toBe(2); }); }); describe("Find Operations", () => { it("finds documents", async () => { - await collections.users.insertMany(mockUsers).exec(); - const users = await collections.users.find().exec(); + await collections.users.insertMany(mockUsers); + const users = await collections.users.find(); expect(users.length).toBeGreaterThanOrEqual(3); }); it("finds documents with cursor", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const users1 = await collections.users.find().cursor(); expect(await users1.next()).toMatchObject(mockUsers[0]); @@ -170,93 +164,92 @@ describe("Insert and Find Operations", async () => { }); it("finds one document without filter", async () => { - await collections.users.insertOne(mockUsers[0]).exec(); - const user = await collections.users.findOne({}).exec(); + await collections.users.insertOne(mockUsers[0]); + const user = await collections.users.findOne({}); expect(user).toStrictEqual(expect.objectContaining(mockUsers[0])); }); it("finds one document with ObjectId filter", async () => { const userId = new ObjectId(); - await collections.users.insertOne({ _id: userId, ...mockUsers[0] }).exec(); + await collections.users.insertOne({ _id: userId, ...mockUsers[0] }); - const user = await collections.users.findOne({ _id: userId }).exec(); + const user = await collections.users.findOne({ _id: userId }); expect(user).toStrictEqual({ _id: userId, ...mockUsers[0] }); }); it("does not find document when using string instead of ObjectId in filter", async () => { const userId = new ObjectId(); - await collections.users.insertOne({ _id: userId, ...mockUsers[0] }).exec(); + await collections.users.insertOne({ _id: userId, ...mockUsers[0] }); const user = await collections.users //@ts-expect-error - .findOne({ _id: userId.toString() }) - .exec(); + .findOne({ _id: userId.toString() }); expect(user).toBe(null); }); it("finds one document with non-ObjectId primary key", async () => { const todoId = 1; const userId = new ObjectId(); - await collections.todos.insertOne({ _id: todoId, title: "todo 1", userId }).exec(); + await collections.todos.insertOne({ _id: todoId, title: "todo 1", userId }); - const todo = await collections.todos.findOne({ _id: todoId }).exec(); + const todo = await collections.todos.findOne({ _id: todoId }); expect(todo).toStrictEqual({ _id: todoId, title: "todo 1", userId }); }); it("finds one document by ObjectId", async () => { const userId = new ObjectId(); - await collections.users.insertOne({ _id: userId, ...mockUsers[0] }).exec(); + await collections.users.insertOne({ _id: userId, ...mockUsers[0] }); - const user = await collections.users.findById(userId).exec(); + const user = await collections.users.findById(userId); expect(user).toStrictEqual({ _id: userId, ...mockUsers[0] }); }); it("finds one document by ObjectId string", async () => { const userId = new ObjectId(); - await collections.users.insertOne({ _id: userId, ...mockUsers[0] }).exec(); + await collections.users.insertOne({ _id: userId, ...mockUsers[0] }); - const user = await collections.users.findById(userId.toString()).exec(); + const user = await collections.users.findById(userId.toString()); expect(user).toStrictEqual({ _id: userId, ...mockUsers[0] }); }); it("rejects invalid ObjectId string in findById", async () => { await expect(async () => { - await collections.users.findById("not_an_object_id").exec(); + await collections.users.findById("not_an_object_id"); }).rejects.toThrowError(); }); it("finds one document by non-ObjectId primary key", async () => { const todoId = 1; const userId = new ObjectId(); - await collections.todos.insertOne({ _id: todoId, title: "todo 1", userId }).exec(); + await collections.todos.insertOne({ _id: todoId, title: "todo 1", userId }); - const todo = await collections.todos.findById(todoId).exec(); + const todo = await collections.todos.findById(todoId); expect(todo).toStrictEqual({ _id: todoId, title: "todo 1", userId }); }); it("returns null when document not found by id", async () => { const todoId = 1; const userId = new ObjectId(); - await collections.todos.insertOne({ _id: todoId, title: "todo 1", userId }).exec(); + await collections.todos.insertOne({ _id: todoId, title: "todo 1", userId }); - const todo = await collections.todos.findById(todoId + 1).exec(); + const todo = await collections.todos.findById(todoId + 1); expect(todo).toBe(null); }); it("gets distinct values", async () => { - await collections.users.insertOne(mockUsers[0]).exec(); - const distinctEmails = await collections.users.distinct("age").exec(); + await collections.users.insertOne(mockUsers[0]); + const distinctEmails = await collections.users.distinct("age"); expect(distinctEmails).not.toBe(null); }); it("countDocuments", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const count = await collections.users.countDocuments(); expect(count).toBeGreaterThanOrEqual(2); }); it("estimatedDocumentCount", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const estimatedCount = await collections.users.estimatedDocumentCount(); expect(estimatedCount).toBe(3); }); diff --git a/tests/query/query-methods.test.ts b/tests/query/query-methods.test.ts index 09695ce..2886321 100644 --- a/tests/query/query-methods.test.ts +++ b/tests/query/query-methods.test.ts @@ -22,7 +22,7 @@ describe("Query Methods", async () => { }); afterEach(async () => { - await collections.users.deleteMany({}).exec(); + await collections.users.deleteMany({}); }); afterAll(async () => { @@ -31,23 +31,23 @@ describe("Query Methods", async () => { }); it("queries with single where condition", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); - const firstUser = await collections.users.findOne({ name: "anon" }).exec(); + const firstUser = await collections.users.findOne({ name: "anon" }); expect(firstUser?.name).toBe("anon"); }); it("queries with multiple where conditions", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); - const users = await collections.users.find({ name: "anon", age: 17 }).exec(); + const users = await collections.users.find({ name: "anon", age: 17 }); expect(users.length).toBe(1); }); it("selects specific fields", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); - const users = await collections.users.find().select({ name: true, email: true }).exec(); + const users = await collections.users.find().select({ name: true, email: true }); expect(users[0].name).toBe("anon"); expect(users[0].email).toBe("anon@gmail.com"); // @ts-expect-error @@ -57,9 +57,9 @@ describe("Query Methods", async () => { }); it("omits specific fields", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); - const users = await collections.users.find().omit({ name: true, email: true }).exec(); + const users = await collections.users.find().omit({ name: true, email: true }); // @ts-expect-error expect(users[0].name).toBeUndefined(); // @ts-expect-error @@ -69,23 +69,23 @@ describe("Query Methods", async () => { }); it("limits query results", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const limit = 2; - const users = await collections.users.find().limit(limit).exec(); + const users = await collections.users.find().limit(limit); expect(users.length).toBe(limit); }); it("skips query results", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const skip = 2; - const users = await collections.users.find().skip(skip).exec(); + const users = await collections.users.find().skip(skip); expect(users.length).toBe(mockUsers.length - skip); }); it("sorts by numeric field descending", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); const users = await collections.users.find().sort({ age: -1 }); expect(users[0].age).toBe(25); @@ -94,9 +94,9 @@ describe("Query Methods", async () => { }); it("sorts by string field ascending", async () => { - await collections.users.insertMany(mockUsers).exec(); + await collections.users.insertMany(mockUsers); - const users = await collections.users.find().sort({ email: "asc" }).exec(); + const users = await collections.users.find().sort({ email: "asc" }); expect(users[0].email).toBe("anon1@gmail.com"); expect(users[1].email).toBe("anon2@gmail.com"); expect(users[2].email).toBe("anon@gmail.com"); diff --git a/tests/query/update-hooks.test.ts b/tests/query/update-hooks.test.ts index b15a936..5d3b34e 100644 --- a/tests/query/update-hooks.test.ts +++ b/tests/query/update-hooks.test.ts @@ -26,14 +26,12 @@ describe("Update Hooks", async () => { isAdmin: boolean(), }); const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - age: 0, - isAdmin: true, - }) - .exec(); - const doc = await db.collections.users.findOne({ _id: res._id }).exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + age: 0, + isAdmin: true, + }); + const doc = await db.collections.users.findOne({ _id: res._id }); expect(doc).toStrictEqual({ _id: res._id, name: "tom", @@ -44,8 +42,7 @@ describe("Update Hooks", async () => { .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) .options({ returnDocument: "after", - }) - .exec(); + }); expect(updatedDoc).toStrictEqual({ _id: res._id, name: "jerry", @@ -63,12 +60,10 @@ describe("Update Hooks", async () => { nonce: number().onUpdate(onUpdateTrap).transform(transformTrap), }); const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: 0, - }) - .exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + nonce: 0, + }); expect(onUpdateTrap).toBeCalledTimes(0); expect(transformTrap).toBeCalledTimes(1); expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: "0" }); @@ -77,8 +72,7 @@ describe("Update Hooks", async () => { .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) .options({ returnDocument: "after", - }) - .exec(); + }); expect(onUpdateTrap).toBeCalledTimes(1); expect(transformTrap).toBeCalledTimes(2); expect(updatedDoc).toStrictEqual({ @@ -98,12 +92,10 @@ describe("Update Hooks", async () => { .validate(() => true, ""), }); const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: 0, - }) - .exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + nonce: 0, + }); expect(onUpdateTrap).toBeCalledTimes(0); expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: 0 }); @@ -111,8 +103,7 @@ describe("Update Hooks", async () => { .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) .options({ returnDocument: "after", - }) - .exec(); + }); expect(onUpdateTrap).toBeCalledTimes(1); expect(updatedDoc).toStrictEqual({ _id: res._id, @@ -129,11 +120,9 @@ describe("Update Hooks", async () => { nonce: number().onUpdate(onUpdateTrap).optional(), }); const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - }) - .exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + }); expect(onUpdateTrap).toBeCalledTimes(0); expect(res).toStrictEqual({ _id: res._id, name: "tom" }); @@ -141,8 +130,7 @@ describe("Update Hooks", async () => { .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) .options({ returnDocument: "after", - }) - .exec(); + }); expect(onUpdateTrap).toBeCalledTimes(1); expect(updatedDoc).toStrictEqual({ _id: res._id, @@ -159,12 +147,10 @@ describe("Update Hooks", async () => { nonce: number().onUpdate(onUpdateTrap).nullable(), }); const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: null, - }) - .exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + nonce: null, + }); expect(onUpdateTrap).toBeCalledTimes(0); expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: null }); @@ -172,8 +158,7 @@ describe("Update Hooks", async () => { .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) .options({ returnDocument: "after", - }) - .exec(); + }); expect(onUpdateTrap).toBeCalledTimes(1); expect(updatedDoc).toStrictEqual({ _id: res._id, @@ -190,11 +175,9 @@ describe("Update Hooks", async () => { nonce: number().onUpdate(onUpdateTrap).default(0), }); const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - }) - .exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + }); expect(onUpdateTrap).toBeCalledTimes(0); expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: 0 }); @@ -202,8 +185,7 @@ describe("Update Hooks", async () => { .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) .options({ returnDocument: "after", - }) - .exec(); + }); expect(onUpdateTrap).toBeCalledTimes(1); expect(updatedDoc).toStrictEqual({ _id: res._id, @@ -223,12 +205,10 @@ describe("Update Hooks", async () => { ).onUpdate(onUpdateTrap), }); const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: 0, - }) - .exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + nonce: 0, + }); expect(onUpdateTrap).toBeCalledTimes(0); expect(res).toStrictEqual({ _id: res._id, name: "tom", nonce: "0" }); @@ -236,8 +216,7 @@ describe("Update Hooks", async () => { .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) .options({ returnDocument: "after", - }) - .exec(); + }); expect(onUpdateTrap).toBeCalledTimes(1); expect(updatedDoc).toStrictEqual({ _id: res._id, @@ -257,12 +236,10 @@ describe("Update Hooks", async () => { const db = createDatabase(client.db(), { users: schema }); // Insert initial document - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: 50, - }) - .exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + nonce: 50, + }); expect(onUpdateTrap).toBeCalledTimes(0); expect(transformTrap).toBeCalledTimes(1); expect(transformTrap).toHaveBeenNthCalledWith(1, 50); @@ -273,8 +250,7 @@ describe("Update Hooks", async () => { .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) .options({ returnDocument: "after", - }) - .exec(); + }); expect(onUpdateTrap).toBeCalledTimes(1); expect(onUpdateTrap).toHaveReturnedWith(100); expect(transformTrap).toBeCalledTimes(2); @@ -297,12 +273,10 @@ describe("Update Hooks", async () => { const db = createDatabase(client.db(), { users: schema }); // Insert initial document - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: 50, - }) - .exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + nonce: 50, + }); expect(onUpdateTrap).toBeCalledTimes(0); expect(transformTrap).toBeCalledTimes(1); expect(transformTrap).toHaveBeenNthCalledWith(1, 50); @@ -313,8 +287,7 @@ describe("Update Hooks", async () => { .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) .options({ returnDocument: "after", - }) - .exec(); + }); expect(onUpdateTrap).toBeCalledTimes(1); expect(onUpdateTrap).toHaveReturnedWith(100); // Transform IS called because onUpdate uses the transformed parser @@ -338,12 +311,10 @@ describe("Update Hooks", async () => { const db = createDatabase(client.db(), { users: schema }); // Insert initial document with valid value - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: 25, - }) - .exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + nonce: 25, + }); expect(onUpdateTrap).toBeCalledTimes(0); expect(validateTrap).toBeCalledTimes(1); expect(validateTrap).toHaveBeenNthCalledWith(1, 25); @@ -355,8 +326,7 @@ describe("Update Hooks", async () => { .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) .options({ returnDocument: "after", - }) - .exec(); + }); expect(onUpdateTrap).toBeCalledTimes(1); expect(onUpdateTrap).toHaveReturnedWith(100); // Validate should NOT be called on update value @@ -379,12 +349,10 @@ describe("Update Hooks", async () => { const db = createDatabase(client.db(), { users: schema }); // Insert initial document with valid value - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: 25, - }) - .exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + nonce: 25, + }); expect(onUpdateTrap).toBeCalledTimes(0); expect(validateTrap).toBeCalledTimes(1); expect(validateTrap).toHaveBeenNthCalledWith(1, 25); @@ -396,8 +364,7 @@ describe("Update Hooks", async () => { .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) .options({ returnDocument: "after", - }) - .exec(); + }); expect(onUpdateTrap).toBeCalledTimes(1); expect(onUpdateTrap).toHaveReturnedWith(10); // Validate IS called because onUpdate uses the validated parser @@ -426,12 +393,10 @@ describe("Update Hooks", async () => { const db = createDatabase(client.db(), { users: schema }); // Insert initial document - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: 7, - }) - .exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + nonce: 7, + }); expect(onUpdateTrap).toBeCalledTimes(0); expect(transformTrap).toBeCalledTimes(1); expect(transformTrap).toHaveBeenNthCalledWith(1, 7); @@ -444,8 +409,7 @@ describe("Update Hooks", async () => { .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) .options({ returnDocument: "after", - }) - .exec(); + }); expect(onUpdateTrap).toBeCalledTimes(1); expect(onUpdateTrap).toHaveReturnedWith(5); // Transform IS called (onUpdate uses transformed parser) @@ -475,12 +439,10 @@ describe("Update Hooks", async () => { const db = createDatabase(client.db(), { users: schema }); // Insert initial document - const res = await db.collections.users - .insertOne({ - name: "tom", - nonce: 7, - }) - .exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + nonce: 7, + }); expect(onUpdateTrap).toBeCalledTimes(0); expect(transformTrap).toBeCalledTimes(1); expect(transformTrap).toHaveBeenNthCalledWith(1, 7); @@ -493,8 +455,7 @@ describe("Update Hooks", async () => { .findOneAndUpdate({ _id: res._id }, { $set: { name: "jerry" } }) .options({ returnDocument: "after", - }) - .exec(); + }); expect(onUpdateTrap).toBeCalledTimes(1); expect(onUpdateTrap).toHaveReturnedWith(5); expect(transformTrap).toBeCalledTimes(2); diff --git a/tests/query/update.test.ts b/tests/query/update.test.ts index 048a24c..0218547 100644 --- a/tests/query/update.test.ts +++ b/tests/query/update.test.ts @@ -22,7 +22,7 @@ describe("Update Operations", async () => { }); afterEach(async () => { - await collections.users.deleteMany({}).exec(); + await collections.users.deleteMany({}); }); afterAll(async () => { @@ -31,7 +31,7 @@ describe("Update Operations", async () => { }); it("finds one and updates", async () => { - await collections.users.insertOne(mockUsers[0]).exec(); + await collections.users.insertOne(mockUsers[0]); const updatedUser = await collections.users .findOneAndUpdate( @@ -44,27 +44,26 @@ describe("Update Operations", async () => { ) .options({ returnDocument: "after", - }) - .exec(); + }); expect(updatedUser).not.toBe(null); expect(updatedUser?.age).toBe(30); }); it("updates one document", async () => { - await collections.users.insertOne(mockUsers[1]).exec(); - const updated = await collections.users.updateOne({ email: "anon1@gmail.com" }, { $set: { age: 35 } }).exec(); + await collections.users.insertOne(mockUsers[1]); + const updated = await collections.users.updateOne({ email: "anon1@gmail.com" }, { $set: { age: 35 } }); expect(updated.acknowledged).toBe(true); }); it("updates many documents", async () => { - await collections.users.insertMany(mockUsers).exec(); - const updated = await collections.users.updateMany({ isVerified: false }, { $set: { age: 40 } }).exec(); + await collections.users.insertMany(mockUsers); + const updated = await collections.users.updateMany({ isVerified: false }, { $set: { age: 40 } }); expect(updated.acknowledged).toBe(true); }); it("replaces one document", async () => { - const original = await collections.users.insertOne(mockUsers[0]).exec(); + const original = await collections.users.insertOne(mockUsers[0]); const replaced = await collections.users .replaceOne( { email: "anon@gmail.com" }, @@ -72,8 +71,7 @@ describe("Update Operations", async () => { ...original, name: "New Name", }, - ) - .exec(); + ); expect(replaced.modifiedCount).toBe(1); }); @@ -85,22 +83,22 @@ describe("Update Operations", async () => { }); const db = createDatabase(client.db(), { users: schema }); - const user1 = await db.collections.users.insertOne({ name: "Alice", age: 20 }).exec(); - const user2 = await db.collections.users.insertOne({ name: "Bob", age: 30 }).exec(); + const user1 = await db.collections.users.insertOne({ name: "Alice", age: 20 }); + const user2 = await db.collections.users.insertOne({ name: "Bob", age: 30 }); // Create a reusable update object const updateObj = { $set: { name: "Updated" } }; // Use the same update object twice - await db.collections.users.updateOne({ _id: user1._id }, updateObj).exec(); - await db.collections.users.updateOne({ _id: user2._id }, updateObj).exec(); + await db.collections.users.updateOne({ _id: user1._id }, updateObj); + await db.collections.users.updateOne({ _id: user2._id }, updateObj); // Verify users were updated correctly with auto-update - const updatedUser1 = await db.collections.users.findOne({ _id: user1._id }).exec(); + const updatedUser1 = await db.collections.users.findOne({ _id: user1._id }); expect(updatedUser1?.name).toBe("Updated"); expect(updatedUser1?.age).toBe(999); - const updatedUser2 = await db.collections.users.findOne({ _id: user2._id }).exec(); + const updatedUser2 = await db.collections.users.findOne({ _id: user2._id }); expect(updatedUser2?.name).toBe("Updated"); expect(updatedUser2?.age).toBe(999); @@ -115,18 +113,18 @@ describe("Update Operations", async () => { }); const db = createDatabase(client.db(), { users: schema }); - await db.collections.users.insertOne({ name: "Alice", age: 20 }).exec(); - await db.collections.users.insertOne({ name: "Bob", age: 30 }).exec(); - await db.collections.users.insertOne({ name: "Charlie", age: 40 }).exec(); + await db.collections.users.insertOne({ name: "Alice", age: 20 }); + await db.collections.users.insertOne({ name: "Bob", age: 30 }); + await db.collections.users.insertOne({ name: "Charlie", age: 40 }); const updateObj = { $set: { name: "Updated" } }; // Use the same update object twice for different filters - await db.collections.users.updateMany({ age: { $lt: 30 } }, updateObj).exec(); - await db.collections.users.updateMany({ age: { $gte: 30 } }, updateObj).exec(); + await db.collections.users.updateMany({ age: { $lt: 30 } }, updateObj); + await db.collections.users.updateMany({ age: { $gte: 30 } }, updateObj); // Verify all users were updated - const users = await db.collections.users.find({}).exec(); + const users = await db.collections.users.find({}); expect(users).toHaveLength(3); for (const user of users) { expect(user.name).toBe("Updated"); @@ -144,27 +142,25 @@ describe("Update Operations", async () => { }); const db = createDatabase(client.db(), { users: schema }); - const user1 = await db.collections.users.insertOne({ name: "Alice", age: 20 }).exec(); - const user2 = await db.collections.users.insertOne({ name: "Bob", age: 30 }).exec(); + const user1 = await db.collections.users.insertOne({ name: "Alice", age: 20 }); + const user2 = await db.collections.users.insertOne({ name: "Bob", age: 30 }); const updateObj = { $set: { name: "Updated" } }; // Use the same update object twice await db.collections.users .findOneAndUpdate({ _id: user1._id }, updateObj) - .options({ returnDocument: "after" }) - .exec(); + .options({ returnDocument: "after" }); await db.collections.users .findOneAndUpdate({ _id: user2._id }, updateObj) - .options({ returnDocument: "after" }) - .exec(); + .options({ returnDocument: "after" }); // Verify users were updated - const updatedUser1 = await db.collections.users.findOne({ _id: user1._id }).exec(); + const updatedUser1 = await db.collections.users.findOne({ _id: user1._id }); expect(updatedUser1?.name).toBe("Updated"); expect(updatedUser1?.age).toBe(777); - const updatedUser2 = await db.collections.users.findOne({ _id: user2._id }).exec(); + const updatedUser2 = await db.collections.users.findOne({ _id: user2._id }); expect(updatedUser2?.name).toBe("Updated"); expect(updatedUser2?.age).toBe(777); diff --git a/tests/relations/many.test.ts b/tests/relations/many.test.ts index 1c821fa..890cdd6 100644 --- a/tests/relations/many.test.ts +++ b/tests/relations/many.test.ts @@ -63,7 +63,7 @@ describe("many() relation tests", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; const user2 = await collections.users .insertOne({ @@ -71,7 +71,7 @@ describe("many() relation tests", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; await collections.posts .insertOne({ @@ -80,14 +80,14 @@ describe("many() relation tests", async () => { author: user._id, contributors: [user2._id], }) - .exec(); + ; const populatedPost = await collections.posts .findOne({ title: "Pilot", }) .populate({ contributors: true }) - .exec(); + ; expect(populatedPost?.contributors).toBeDefined(); expect(populatedPost?.contributors).toHaveLength(1); @@ -103,7 +103,7 @@ describe("many() relation tests", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; const user2 = await collections.users .insertOne({ @@ -111,7 +111,7 @@ describe("many() relation tests", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; const user3 = await collections.users .insertOne({ @@ -119,7 +119,7 @@ describe("many() relation tests", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; await collections.posts .insertOne({ @@ -128,14 +128,14 @@ describe("many() relation tests", async () => { author: user1._id, contributors: [user2._id, user3._id], }) - .exec(); + ; const populatedPost = await collections.posts .findOne({ title: "Multi Author Post", }) .populate({ contributors: true, author: true }) - .exec(); + ; expect(populatedPost?.author).toStrictEqual(user1); expect(populatedPost?.contributors).toBeDefined(); @@ -153,7 +153,7 @@ describe("many() relation tests", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; const user2 = await collections.users .insertOne({ @@ -161,7 +161,7 @@ describe("many() relation tests", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; await collections.posts .insertOne({ @@ -170,7 +170,7 @@ describe("many() relation tests", async () => { contributors: [user1._id, user2._id], secret: "12345", }) - .exec(); + ; const populatedPost = await collections.posts .find() @@ -179,7 +179,7 @@ describe("many() relation tests", async () => { select: { name: true }, }, }) - .exec(); + ; expect(populatedPost.length).toBe(1); expect(populatedPost[0].contributorsCount).toBe(2); diff --git a/tests/relations/one.test.ts b/tests/relations/one.test.ts index 1349dd5..f24de25 100644 --- a/tests/relations/one.test.ts +++ b/tests/relations/one.test.ts @@ -65,7 +65,7 @@ describe("one() relation tests", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; const user2 = await collections.users .insertOne({ @@ -74,9 +74,9 @@ describe("one() relation tests", async () => { tutor: user._id, createdAt: new Date(), }) - .exec(); + ; - const populatedUser2 = await collections.users.findById(user2._id).populate({ tutor: true }).exec(); + const populatedUser2 = await collections.users.findById(user2._id).populate({ tutor: true }); expect(populatedUser2).toStrictEqual({ ...user2, @@ -93,7 +93,7 @@ describe("one() relation tests", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; await collections.posts .insertOne({ @@ -101,14 +101,14 @@ describe("one() relation tests", async () => { contents: "Lorem", author: user._id, }) - .exec(); + ; const populatedPost = await collections.posts .findOne({ title: "Pilot", }) .populate({ author: true }) - .exec(); + ; expect(populatedPost?.author).toStrictEqual(user); }); @@ -150,7 +150,7 @@ describe("one() relation tests", async () => { isAdmin: true, createdAt: new Date(), }) - .exec(); + ; const author = await db.collections.users .insertOne({ @@ -159,7 +159,7 @@ describe("one() relation tests", async () => { createdAt: new Date(), tutor: tutor._id, }) - .exec(); + ; // Create posts for both users await db.collections.posts @@ -168,7 +168,7 @@ describe("one() relation tests", async () => { contents: "Wisdom", author: tutor._id, }) - .exec(); + ; const studentPost = await db.collections.posts .insertOne({ @@ -176,7 +176,7 @@ describe("one() relation tests", async () => { contents: "Learning", author: author._id, }) - .exec(); + ; // Test nested population const populatedPost = await db.collections.posts @@ -194,7 +194,7 @@ describe("one() relation tests", async () => { }, }, }) - .exec(); + ; // Verify the nested population results expect(populatedPost).toBeTruthy(); diff --git a/tests/relations/population-options.test.ts b/tests/relations/population-options.test.ts index e1d1754..e352cee 100644 --- a/tests/relations/population-options.test.ts +++ b/tests/relations/population-options.test.ts @@ -68,7 +68,7 @@ describe("Population Options", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; await collections.posts .insertOne({ @@ -76,7 +76,7 @@ describe("Population Options", async () => { contents: "Content 1", author: user._id, }) - .exec(); + ; await collections.posts .insertOne({ @@ -84,12 +84,12 @@ describe("Population Options", async () => { contents: "Content 2", author: user._id, }) - .exec(); + ; const populatedUser = await collections.users .find() .populate({ posts: { limit: 1, skip: 0 } }) - .exec(); + ; expect(populatedUser.length).toBe(1); expect(populatedUser[0].posts.length).toBe(1); @@ -105,7 +105,7 @@ describe("Population Options", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; await collections.posts .insertOne({ @@ -113,14 +113,14 @@ describe("Population Options", async () => { contents: "Content 3", author: user._id, }) - .exec(); + ; const populatedUser = await collections.users .find() .populate({ posts: true, }) - .exec(); + ; expect(populatedUser.length).toBe(1); expect(populatedUser[0].posts.length).toBe(1); @@ -137,7 +137,7 @@ describe("Population Options", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; await collections.posts .insertOne({ @@ -145,7 +145,7 @@ describe("Population Options", async () => { contents: "Content 3", author: user._id, }) - .exec(); + ; const populatedUser = await collections.users .find() @@ -154,7 +154,7 @@ describe("Population Options", async () => { omit: { title: true }, }, }) - .exec(); + ; expect(populatedUser.length).toBe(1); expect(populatedUser[0].posts.length).toBe(1); @@ -171,7 +171,7 @@ describe("Population Options", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; await collections.posts .insertOne({ @@ -179,7 +179,7 @@ describe("Population Options", async () => { contents: "Content 3", author: user._id, }) - .exec(); + ; const populatedUser = await collections.users .find() @@ -188,7 +188,7 @@ describe("Population Options", async () => { select: { title: true }, }, }) - .exec(); + ; expect(populatedUser.length).toBe(1); expect(populatedUser[0].posts.length).toBe(1); @@ -206,7 +206,7 @@ describe("Population Options", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; await collections.posts .insertOne({ @@ -214,7 +214,7 @@ describe("Population Options", async () => { contents: "Content 6", author: user._id, }) - .exec(); + ; await collections.posts .insertOne({ @@ -222,7 +222,7 @@ describe("Population Options", async () => { contents: "Content 7", author: user._id, }) - .exec(); + ; const populatedUser = await collections.users .find() @@ -231,7 +231,7 @@ describe("Population Options", async () => { sort: { title: -1 }, }, }) - .exec(); + ; expect(populatedUser.length).toBe(1); expect(populatedUser[0].posts.length).toBe(2); diff --git a/tests/relations/ref.test.ts b/tests/relations/ref.test.ts index a6aa455..75ef082 100644 --- a/tests/relations/ref.test.ts +++ b/tests/relations/ref.test.ts @@ -72,7 +72,7 @@ describe("ref() relation tests", async () => { createdAt: new Date(), tutor: undefined, }) - .exec(); + ; const tutoredUser = await collections.users .insertOne({ @@ -81,7 +81,7 @@ describe("ref() relation tests", async () => { createdAt: new Date(), tutor: user._id, }) - .exec(); + ; await collections.posts .insertOne({ @@ -89,7 +89,7 @@ describe("ref() relation tests", async () => { contents: "Lorem", author: user._id, }) - .exec(); + ; await collections.posts .insertOne({ @@ -97,16 +97,16 @@ describe("ref() relation tests", async () => { contents: "Lorem2", author: user._id, }) - .exec(); + ; await collections.posts .insertOne({ title: "No Author", contents: "Lorem", }) - .exec(); + ; - const populatedUsers = await collections.users.find().populate({ posts: true, tutor: true }).exec(); + const populatedUsers = await collections.users.find().populate({ posts: true, tutor: true }); expect(populatedUsers.length).toBe(2); expect(populatedUsers[0].posts.length).toBe(2); @@ -123,7 +123,7 @@ describe("ref() relation tests", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; await collections.posts .insertOne({ @@ -131,16 +131,16 @@ describe("ref() relation tests", async () => { contents: "Content 1", author: user._id, }) - .exec(); + ; await collections.books .insertOne({ title: "Book 1", author: user._id, }) - .exec(); + ; - const populatedUser = await collections.users.findById(user._id).populate({ posts: true, books: true }).exec(); + const populatedUser = await collections.users.findById(user._id).populate({ posts: true, books: true }); expect(populatedUser).toBeTruthy(); expect(populatedUser?.posts).toHaveLength(1); @@ -192,7 +192,7 @@ describe("ref() relation tests", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; const user2 = await db.collections.users .insertOne({ @@ -200,7 +200,7 @@ describe("ref() relation tests", async () => { isAdmin: false, createdAt: new Date(), }) - .exec(); + ; await db.collections.posts .insertOne({ @@ -209,7 +209,7 @@ describe("ref() relation tests", async () => { author: user._id, editor: user2._id, }) - .exec(); + ; await db.collections.posts .insertOne({ @@ -218,14 +218,14 @@ describe("ref() relation tests", async () => { author: user2._id, editor: user2._id, }) - .exec(); + ; await db.collections.books .insertOne({ title: "Book 1", author: user._id, }) - .exec(); + ; const populatedUser = await db.collections.users .findById(user._id) @@ -241,7 +241,7 @@ describe("ref() relation tests", async () => { }, books: true, }) - .exec(); + ; expect(populatedUser).toBeTruthy(); expect(populatedUser?.posts).toHaveLength(1); diff --git a/tests/relations/validation.test.ts b/tests/relations/validation.test.ts index e80f1b6..9105b3b 100644 --- a/tests/relations/validation.test.ts +++ b/tests/relations/validation.test.ts @@ -36,7 +36,7 @@ describe("Relation Validations", async () => { }); await expect(async () => { - await db.collections.users.find().populate({ posts: true }).exec(); + await db.collections.users.find().populate({ posts: true }); }).rejects.toThrowError("Target schema not found for relation 'posts' in schema 'users'"); }); @@ -52,7 +52,7 @@ describe("Relation Validations", async () => { }); await expect(async () => { - await db.collections.users.find().populate({ posts: true }).exec(); + await db.collections.users.find().populate({ posts: true }); }).rejects.toThrowError("No relations found for schema 'users'"); }); diff --git a/tests/schema/schema.test.ts b/tests/schema/schema.test.ts index 306f138..39fea7d 100644 --- a/tests/schema/schema.test.ts +++ b/tests/schema/schema.test.ts @@ -34,9 +34,9 @@ describe("Schema", async () => { age: 0, isAdmin: true, }) - .exec(); + ; expect(res).toStrictEqual({ _id: res._id, name: "tom", age: 0 }); - const doc = await db.collections.users.findOne({ _id: res._id }).exec(); + const doc = await db.collections.users.findOne({ _id: res._id }); expect(doc).toStrictEqual({ _id: res._id, name: "tom", age: 0 }); }); @@ -55,8 +55,8 @@ describe("Schema", async () => { age: 0, isAdmin: true, }) - .exec(); - const doc = await db.collections.users.findOne({ _id: res._id }).exec(); + ; + const doc = await db.collections.users.findOne({ _id: res._id }); expect(doc).toStrictEqual({ _id: res._id, name: "tom cruise", @@ -86,8 +86,8 @@ describe("Schema", async () => { age: 0, isAdmin: true, }) - .exec(); - const doc = await db.collections.users.findOne({ _id: res._id }).exec(); + ; + const doc = await db.collections.users.findOne({ _id: res._id }); expect(doc).toStrictEqual({ _id: res._id, name: "tom", @@ -115,27 +115,27 @@ describe("Schema", async () => { age: 0, isAdmin: true, }) - .exec(); + ; expect(res).toStrictEqual({ _id: res._id, name: "tom", age: 0, role: "known", }); - const doc1 = await db.collections.users.findOne({ _id: res._id }).exec(); + const doc1 = await db.collections.users.findOne({ _id: res._id }); expect(doc1).toStrictEqual({ _id: res._id, name: "tom", age: 0, role: "known", }); - const doc2 = await db.collections.users.findOne({ _id: res._id }).omit({ age: true, isAdmin: true }).exec(); + const doc2 = await db.collections.users.findOne({ _id: res._id }).omit({ age: true, isAdmin: true }); expect(doc2).toStrictEqual({ _id: res._id, name: "tom", role: "known", }); - const doc3 = await db.collections.users.findOne({ _id: res._id }).select({ role: true }).exec(); + const doc3 = await db.collections.users.findOne({ _id: res._id }).select({ role: true }); expect(doc3).toStrictEqual({ _id: res._id, role: "known", @@ -159,8 +159,8 @@ describe("Schema", async () => { isAdmin: true, role: 1, }) - .exec(); - const doc = await db.collections.users.findOne({ _id: res._id }).exec(); + ; + const doc = await db.collections.users.findOne({ _id: res._id }); expect(doc).toStrictEqual({ _id: res._id, name: "tom", @@ -190,7 +190,7 @@ describe("Schema", async () => { username: "bobpaul", age: 0, }) - .exec(); + ; await expect(async () => { await db.collections.users .insertOne({ @@ -199,7 +199,7 @@ describe("Schema", async () => { username: "bobpaul", age: 0, }) - .exec(); + ; }).rejects.toThrowError("E11000 duplicate key error"); // duplicate firstname and lastname pair @@ -210,7 +210,7 @@ describe("Schema", async () => { username: "alicewonder", age: 0, }) - .exec(); + ; await expect(async () => { await db.collections.users .insertOne({ @@ -219,7 +219,7 @@ describe("Schema", async () => { username: "allywon", age: 0, }) - .exec(); + ; }).rejects.toThrowError("E11000 duplicate key error"); }); @@ -237,7 +237,7 @@ describe("Schema", async () => { name: "Laptop", price: 999, }) - .exec(); + ; expect(product).toStrictEqual({ _id: "product-123", @@ -245,7 +245,7 @@ describe("Schema", async () => { price: 999, }); - const foundProduct = await db.collections.products.findById("product-123").exec(); + const foundProduct = await db.collections.products.findById("product-123"); expect(foundProduct).toStrictEqual({ _id: "product-123", name: "Laptop", @@ -267,7 +267,7 @@ describe("Schema", async () => { customerId: "cust-001", total: 150.5, }) - .exec(); + ; expect(order).toStrictEqual({ _id: 12345, @@ -275,7 +275,7 @@ describe("Schema", async () => { total: 150.5, }); - const foundOrder = await db.collections.orders.findById(12345).exec(); + const foundOrder = await db.collections.orders.findById(12345); expect(foundOrder).toStrictEqual({ _id: 12345, customerId: "cust-001", diff --git a/tests/types/binary.test.ts b/tests/types/binary.test.ts index bd22bae..21adbfd 100644 --- a/tests/types/binary.test.ts +++ b/tests/types/binary.test.ts @@ -64,7 +64,7 @@ describe("binary()", () => { }); afterAll(async () => { - await collections.bsonData.deleteMany({}).exec(); + await collections.bsonData.deleteMany({}); }); test("accepts Buffer and returns Binary on insert", async () => { @@ -74,12 +74,12 @@ describe("binary()", () => { .insertOne({ binaryField: testBuffer, }) - .exec(); + ; expect(inserted.binaryField).toBeInstanceOf(Binary); expect(inserted.binaryField!.buffer.toString()).toBe("hello world"); - const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }); expect(retrieved).not.toBeNull(); expect(retrieved!.binaryField).toBeInstanceOf(Binary); expect(retrieved!.binaryField!.buffer.toString()).toBe("hello world"); @@ -93,12 +93,12 @@ describe("binary()", () => { .insertOne({ binaryField: testBinary, }) - .exec(); + ; expect(inserted.binaryField).toBeInstanceOf(Binary); expect(inserted.binaryField!.buffer.toString()).toBe("binary data"); - const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }); expect(retrieved).not.toBeNull(); expect(retrieved!.binaryField).toBeInstanceOf(Binary); expect(retrieved!.binaryField!.buffer.toString()).toBe("binary data"); diff --git a/tests/types/decimal128.test.ts b/tests/types/decimal128.test.ts index aa0133b..8a5aef2 100644 --- a/tests/types/decimal128.test.ts +++ b/tests/types/decimal128.test.ts @@ -86,7 +86,7 @@ describe("decimal128()", () => { }); afterAll(async () => { - await collections.bsonData.deleteMany({}).exec(); + await collections.bsonData.deleteMany({}); }); test("accepts Decimal128 and returns Decimal128", async () => { @@ -96,12 +96,12 @@ describe("decimal128()", () => { .insertOne({ decimalField: testDecimal, }) - .exec(); + ; expect(inserted.decimalField).toBeInstanceOf(Decimal128); expect(inserted.decimalField!.toString()).toBe("123456789.123456789123456789"); - const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }); expect(retrieved).not.toBeNull(); expect(retrieved!.decimalField).toBeInstanceOf(Decimal128); expect(retrieved!.decimalField!.toString()).toBe("123456789.123456789123456789"); @@ -113,12 +113,12 @@ describe("decimal128()", () => { .insertOne({ decimalField: "999.999999", }) - .exec(); + ; expect(inserted.decimalField).toBeInstanceOf(Decimal128); expect(inserted.decimalField!.toString()).toBe("999.999999"); - const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }); expect(retrieved).not.toBeNull(); expect(retrieved!.decimalField).toBeInstanceOf(Decimal128); expect(retrieved!.decimalField!.toString()).toBe("999.999999"); @@ -131,12 +131,12 @@ describe("decimal128()", () => { .insertOne({ decimalField: highPrecision, }) - .exec(); + ; expect(inserted.decimalField).toBeInstanceOf(Decimal128); expect(inserted.decimalField!.toString()).toBe(highPrecision); - const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }); expect(retrieved).not.toBeNull(); expect(retrieved!.decimalField).toBeInstanceOf(Decimal128); expect(retrieved!.decimalField!.toString()).toBe(highPrecision); diff --git a/tests/types/long.test.ts b/tests/types/long.test.ts index b2c3f51..1c9e41d 100644 --- a/tests/types/long.test.ts +++ b/tests/types/long.test.ts @@ -85,7 +85,7 @@ describe("long()", () => { }); afterAll(async () => { - await collections.bsonData.deleteMany({}).exec(); + await collections.bsonData.deleteMany({}); }); test("accepts Long (large value) and returns Long", async () => { @@ -95,12 +95,12 @@ describe("long()", () => { .insertOne({ longField: testLong, }) - .exec(); + ; expect(Long.isLong(inserted.longField)).toBe(true); expect((inserted.longField as Long).toString()).toBe("9223372036854775807"); - const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }); expect(retrieved).not.toBeNull(); expect(retrieved!.longField).toBeDefined(); expect(Long.isLong(retrieved!.longField)).toBe(true); @@ -113,12 +113,12 @@ describe("long()", () => { .insertOne({ longField: 123456789, }) - .exec(); + ; expect(typeof inserted.longField).toBe("number"); expect(inserted.longField).toBe(123456789); - const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }); expect(retrieved).not.toBeNull(); expect(retrieved!.longField).toBeDefined(); expect(typeof retrieved!.longField).toBe("number"); @@ -131,12 +131,12 @@ describe("long()", () => { .insertOne({ longField: BigInt("9223372036854775807"), }) - .exec(); + ; expect(Long.isLong(inserted.longField)).toBe(true); expect((inserted.longField as Long).toString()).toBe("9223372036854775807"); - const retrieved = await collections.bsonData.findOne({ _id: inserted._id }).exec(); + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }); expect(retrieved).not.toBeNull(); expect(retrieved!.longField).toBeDefined(); expect(Long.isLong(retrieved!.longField)).toBe(true); diff --git a/tests/types/objectid.test.ts b/tests/types/objectid.test.ts index 066a76b..5a2876b 100644 --- a/tests/types/objectid.test.ts +++ b/tests/types/objectid.test.ts @@ -74,7 +74,7 @@ describe("objectId()", () => { }); afterAll(async () => { - await collections.testData.deleteMany({}).exec(); + await collections.testData.deleteMany({}); }); test("accepts ObjectId and returns ObjectId", async () => { @@ -84,12 +84,12 @@ describe("objectId()", () => { .insertOne({ refId: testId, }) - .exec(); + ; expect(inserted.refId).toBeInstanceOf(ObjectId); expect(inserted.refId?.toString()).toBe(testId.toString()); - const retrieved = await collections.testData.findOne({ _id: inserted._id }).exec(); + const retrieved = await collections.testData.findOne({ _id: inserted._id }); expect(retrieved).not.toBeNull(); expect(retrieved!.refId).toBeInstanceOf(ObjectId); expect(retrieved!.refId?.toString()).toBe(testId.toString()); @@ -103,12 +103,12 @@ describe("objectId()", () => { .insertOne({ refId: validId, }) - .exec(); + ; expect(inserted.refId).toBeInstanceOf(ObjectId); expect(inserted.refId?.toString()).toBe(validId); - const retrieved = await collections.testData.findOne({ _id: inserted._id }).exec(); + const retrieved = await collections.testData.findOne({ _id: inserted._id }); expect(retrieved).not.toBeNull(); expect(retrieved!.refId).toBeInstanceOf(ObjectId); expect(retrieved!.refId?.toString()).toBe(validId); diff --git a/todo.md b/todo.md index a6f905a..b66286b 100644 --- a/todo.md +++ b/todo.md @@ -28,7 +28,6 @@ Here are some features we need to implement. - [] Add `findByIdAndUpdate()` / `findByIdAndDelete()` shortcuts - [] Add batch operations helper methods -- [] Make query `.exec()` protected - [] Fully document public API - [] Remove need for `ref` by auto reversing `one` relation From 671a9d15f5b86ef939d886365570893524d7b899 Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Thu, 1 Jan 2026 14:47:48 +0100 Subject: [PATCH 05/14] Format files --- tests/operators.test.ts | 143 ++++++-------- tests/query/update.test.ts | 23 +-- tests/relations/many.test.ts | 155 ++++++--------- tests/relations/one.test.ts | 114 +++++------ tests/relations/population-options.test.ts | 211 ++++++++------------- tests/relations/ref.test.ts | 197 ++++++++----------- tests/schema/schema.test.ts | 136 ++++++------- tests/types/binary.test.ts | 18 +- tests/types/decimal128.test.ts | 27 +-- tests/types/long.test.ts | 27 +-- tests/types/objectid.test.ts | 18 +- 11 files changed, 416 insertions(+), 653 deletions(-) diff --git a/tests/operators.test.ts b/tests/operators.test.ts index d017223..1d68169 100644 --- a/tests/operators.test.ts +++ b/tests/operators.test.ts @@ -34,121 +34,94 @@ describe("Query operators", async () => { it("and operator", async () => { await collections.users.insertMany(mockUsers); - const users = await collections.users - .find( - and( - { - name: "anon", - }, - { - age: 17, - }, - ), - ) - ; - + const users = await collections.users.find( + and( + { + name: "anon", + }, + { + age: 17, + }, + ), + ); expect(users.length).toBe(mockUsers.filter((user) => user.name === "anon" && user.age === 17).length); }); it("or operator", async () => { await collections.users.insertMany(mockUsers); - const users = await collections.users - .find( - or( - { - name: "anon", - }, - { - name: "anon1", - }, - ), - ) - ; - + const users = await collections.users.find( + or( + { + name: "anon", + }, + { + name: "anon1", + }, + ), + ); expect(users.length).toBe(mockUsers.filter((user) => user.name === "anon" || user.name === "anon1").length); }); it("nor operator", async () => { await collections.users.insertMany(mockUsers); - const users = await collections.users - .find( - nor( - { - name: "anon", - }, - { - name: "anon1", - }, - ), - ) - ; - + const users = await collections.users.find( + nor( + { + name: "anon", + }, + { + name: "anon1", + }, + ), + ); expect(users.length).toBe(mockUsers.length - 2); }); it("eq operator", async () => { await collections.users.insertMany(mockUsers); - const users = await collections.users - .find({ - name: eq("anon1"), - }) - ; - + const users = await collections.users.find({ + name: eq("anon1"), + }); expect(users.length).toBe(1); }); it("ne operator", async () => { await collections.users.insertMany(mockUsers); - const users = await collections.users - .find({ - name: neq("anon1"), - }) - ; - + const users = await collections.users.find({ + name: neq("anon1"), + }); expect(users.length).toBe(mockUsers.length - 1); }); it("gt operator", async () => { await collections.users.insertMany(mockUsers); - const users = await collections.users - .find({ - age: gt(17), - }) - ; - + const users = await collections.users.find({ + age: gt(17), + }); expect(users.length).toBe(mockUsers.filter((user) => user.age > 17).length); }); it("gte operator", async () => { await collections.users.insertMany(mockUsers); - const users = await collections.users - .find({ - age: gte(17), - }) - ; - + const users = await collections.users.find({ + age: gte(17), + }); expect(users.length).toBe(mockUsers.filter((user) => user.age >= 17).length); }); it("lt operator", async () => { await collections.users.insertMany(mockUsers); - const users = await collections.users - .find({ - age: lt(17), - }) - ; - + const users = await collections.users.find({ + age: lt(17), + }); expect(users.length).toBe(mockUsers.filter((user) => user.age < 17).length); }); it("lte operator", async () => { await collections.users.insertMany(mockUsers); - const users = await collections.users - .find({ - age: lte(17), - }) - ; - + const users = await collections.users.find({ + age: lte(17), + }); expect(users.length).toBe(mockUsers.filter((user) => user.age <= 17).length); }); @@ -156,12 +129,9 @@ describe("Query operators", async () => { const ageArray = [17]; await collections.users.insertMany(mockUsers); - const users = await collections.users - .find({ - age: inArray(ageArray), - }) - ; - + const users = await collections.users.find({ + age: inArray(ageArray), + }); expect(users.length).toBe(mockUsers.filter((user) => ageArray.includes(user.age)).length); }); @@ -169,13 +139,10 @@ describe("Query operators", async () => { const ageArray = [17, 20, 25]; await collections.users.insertMany(mockUsers); - const users = await collections.users - .find({ - age: notInArray([17, 20, 25]), - // age: 3 - }) - ; - + const users = await collections.users.find({ + age: notInArray([17, 20, 25]), + // age: 3 + }); expect(users.length).toBe(mockUsers.filter((user) => !ageArray.includes(user.age)).length); }); }); diff --git a/tests/query/update.test.ts b/tests/query/update.test.ts index 0218547..87e3fe9 100644 --- a/tests/query/update.test.ts +++ b/tests/query/update.test.ts @@ -64,14 +64,13 @@ describe("Update Operations", async () => { it("replaces one document", async () => { const original = await collections.users.insertOne(mockUsers[0]); - const replaced = await collections.users - .replaceOne( - { email: "anon@gmail.com" }, - { - ...original, - name: "New Name", - }, - ); + const replaced = await collections.users.replaceOne( + { email: "anon@gmail.com" }, + { + ...original, + name: "New Name", + }, + ); expect(replaced.modifiedCount).toBe(1); }); @@ -148,12 +147,8 @@ describe("Update Operations", async () => { const updateObj = { $set: { name: "Updated" } }; // Use the same update object twice - await db.collections.users - .findOneAndUpdate({ _id: user1._id }, updateObj) - .options({ returnDocument: "after" }); - await db.collections.users - .findOneAndUpdate({ _id: user2._id }, updateObj) - .options({ returnDocument: "after" }); + await db.collections.users.findOneAndUpdate({ _id: user1._id }, updateObj).options({ returnDocument: "after" }); + await db.collections.users.findOneAndUpdate({ _id: user2._id }, updateObj).options({ returnDocument: "after" }); // Verify users were updated const updatedUser1 = await db.collections.users.findOne({ _id: user1._id }); diff --git a/tests/relations/many.test.ts b/tests/relations/many.test.ts index 890cdd6..ae34946 100644 --- a/tests/relations/many.test.ts +++ b/tests/relations/many.test.ts @@ -57,38 +57,28 @@ describe("many() relation tests", async () => { it("should populate many() relation (contributors)", async () => { const { collections } = setupSchemasAndCollections(); - const user = await collections.users - .insertOne({ - name: "Bob", - isAdmin: false, - createdAt: new Date(), - }) - ; - - const user2 = await collections.users - .insertOne({ - name: "Alex", - isAdmin: false, - createdAt: new Date(), - }) - ; - - await collections.posts - .insertOne({ - title: "Pilot", - contents: "Lorem", - author: user._id, - contributors: [user2._id], - }) - ; + const user = await collections.users.insertOne({ + name: "Bob", + isAdmin: false, + createdAt: new Date(), + }); + const user2 = await collections.users.insertOne({ + name: "Alex", + isAdmin: false, + createdAt: new Date(), + }); + await collections.posts.insertOne({ + title: "Pilot", + contents: "Lorem", + author: user._id, + contributors: [user2._id], + }); const populatedPost = await collections.posts .findOne({ title: "Pilot", }) - .populate({ contributors: true }) - ; - + .populate({ contributors: true }); expect(populatedPost?.contributors).toBeDefined(); expect(populatedPost?.contributors).toHaveLength(1); expect(populatedPost?.contributors[0]).toStrictEqual(user2); @@ -97,46 +87,33 @@ describe("many() relation tests", async () => { it("should populate many() relation with multiple contributors", async () => { const { collections } = setupSchemasAndCollections(); - const user1 = await collections.users - .insertOne({ - name: "Bob", - isAdmin: false, - createdAt: new Date(), - }) - ; - - const user2 = await collections.users - .insertOne({ - name: "Alex", - isAdmin: false, - createdAt: new Date(), - }) - ; - - const user3 = await collections.users - .insertOne({ - name: "Charlie", - isAdmin: false, - createdAt: new Date(), - }) - ; - - await collections.posts - .insertOne({ - title: "Multi Author Post", - contents: "Content", - author: user1._id, - contributors: [user2._id, user3._id], - }) - ; + const user1 = await collections.users.insertOne({ + name: "Bob", + isAdmin: false, + createdAt: new Date(), + }); + const user2 = await collections.users.insertOne({ + name: "Alex", + isAdmin: false, + createdAt: new Date(), + }); + const user3 = await collections.users.insertOne({ + name: "Charlie", + isAdmin: false, + createdAt: new Date(), + }); + await collections.posts.insertOne({ + title: "Multi Author Post", + contents: "Content", + author: user1._id, + contributors: [user2._id, user3._id], + }); const populatedPost = await collections.posts .findOne({ title: "Multi Author Post", }) - .populate({ contributors: true, author: true }) - ; - + .populate({ contributors: true, author: true }); expect(populatedPost?.author).toStrictEqual(user1); expect(populatedPost?.contributors).toBeDefined(); expect(populatedPost?.contributors).toHaveLength(2); @@ -147,40 +124,28 @@ describe("many() relation tests", async () => { it("should access original many() field in virtuals", async () => { const { collections } = setupSchemasAndCollections(); - const user1 = await collections.users - .insertOne({ - name: "Test User 1", - isAdmin: false, - createdAt: new Date(), - }) - ; - - const user2 = await collections.users - .insertOne({ - name: "Test User 2", - isAdmin: false, - createdAt: new Date(), - }) - ; - - await collections.posts - .insertOne({ - title: "Post 6", - contents: "Content 6", - contributors: [user1._id, user2._id], - secret: "12345", - }) - ; - - const populatedPost = await collections.posts - .find() - .populate({ - contributors: { - select: { name: true }, - }, - }) - ; + const user1 = await collections.users.insertOne({ + name: "Test User 1", + isAdmin: false, + createdAt: new Date(), + }); + const user2 = await collections.users.insertOne({ + name: "Test User 2", + isAdmin: false, + createdAt: new Date(), + }); + await collections.posts.insertOne({ + title: "Post 6", + contents: "Content 6", + contributors: [user1._id, user2._id], + secret: "12345", + }); + const populatedPost = await collections.posts.find().populate({ + contributors: { + select: { name: true }, + }, + }); expect(populatedPost.length).toBe(1); expect(populatedPost[0].contributorsCount).toBe(2); expect(populatedPost[0].contributors.length).toBe(2); diff --git a/tests/relations/one.test.ts b/tests/relations/one.test.ts index f24de25..7706e63 100644 --- a/tests/relations/one.test.ts +++ b/tests/relations/one.test.ts @@ -59,23 +59,17 @@ describe("one() relation tests", async () => { it("should populate one() relation (tutor)", async () => { const { collections } = setupSchemasAndCollections(); - const user = await collections.users - .insertOne({ - name: "Bob", - isAdmin: false, - createdAt: new Date(), - }) - ; - - const user2 = await collections.users - .insertOne({ - name: "Alex", - isAdmin: false, - tutor: user._id, - createdAt: new Date(), - }) - ; - + const user = await collections.users.insertOne({ + name: "Bob", + isAdmin: false, + createdAt: new Date(), + }); + const user2 = await collections.users.insertOne({ + name: "Alex", + isAdmin: false, + tutor: user._id, + createdAt: new Date(), + }); const populatedUser2 = await collections.users.findById(user2._id).populate({ tutor: true }); expect(populatedUser2).toStrictEqual({ @@ -87,29 +81,22 @@ describe("one() relation tests", async () => { it("should populate one() relation (author)", async () => { const { collections } = setupSchemasAndCollections(); - const user = await collections.users - .insertOne({ - name: "Bob", - isAdmin: false, - createdAt: new Date(), - }) - ; - - await collections.posts - .insertOne({ - title: "Pilot", - contents: "Lorem", - author: user._id, - }) - ; + const user = await collections.users.insertOne({ + name: "Bob", + isAdmin: false, + createdAt: new Date(), + }); + await collections.posts.insertOne({ + title: "Pilot", + contents: "Lorem", + author: user._id, + }); const populatedPost = await collections.posts .findOne({ title: "Pilot", }) - .populate({ author: true }) - ; - + .populate({ author: true }); expect(populatedPost?.author).toStrictEqual(user); }); @@ -144,40 +131,29 @@ describe("one() relation tests", async () => { }); // Create users with tutor relationship - const tutor = await db.collections.users - .insertOne({ - name: "Master Tutor", - isAdmin: true, - createdAt: new Date(), - }) - ; - - const author = await db.collections.users - .insertOne({ - name: "Student Author", - isAdmin: false, - createdAt: new Date(), - tutor: tutor._id, - }) - ; - + const tutor = await db.collections.users.insertOne({ + name: "Master Tutor", + isAdmin: true, + createdAt: new Date(), + }); + const author = await db.collections.users.insertOne({ + name: "Student Author", + isAdmin: false, + createdAt: new Date(), + tutor: tutor._id, + }); // Create posts for both users - await db.collections.posts - .insertOne({ - title: "Tutor's Post", - contents: "Wisdom", - author: tutor._id, - }) - ; - - const studentPost = await db.collections.posts - .insertOne({ - title: "Student's Post", - contents: "Learning", - author: author._id, - }) - ; + await db.collections.posts.insertOne({ + title: "Tutor's Post", + contents: "Wisdom", + author: tutor._id, + }); + const studentPost = await db.collections.posts.insertOne({ + title: "Student's Post", + contents: "Learning", + author: author._id, + }); // Test nested population const populatedPost = await db.collections.posts .findById(studentPost._id) @@ -193,9 +169,7 @@ describe("one() relation tests", async () => { posts: true, }, }, - }) - ; - + }); // Verify the nested population results expect(populatedPost).toBeTruthy(); expect(populatedPost?.author).toBeTruthy(); diff --git a/tests/relations/population-options.test.ts b/tests/relations/population-options.test.ts index e352cee..404ba69 100644 --- a/tests/relations/population-options.test.ts +++ b/tests/relations/population-options.test.ts @@ -62,35 +62,24 @@ describe("Population Options", async () => { it("should populate with limit and skip options", async () => { const { collections } = setupSchemasAndCollections(); - const user = await collections.users - .insertOne({ - name: "Test User", - isAdmin: false, - createdAt: new Date(), - }) - ; - - await collections.posts - .insertOne({ - title: "Post 1", - contents: "Content 1", - author: user._id, - }) - ; - - await collections.posts - .insertOne({ - title: "Post 2", - contents: "Content 2", - author: user._id, - }) - ; - - const populatedUser = await collections.users - .find() - .populate({ posts: { limit: 1, skip: 0 } }) - ; + const user = await collections.users.insertOne({ + name: "Test User", + isAdmin: false, + createdAt: new Date(), + }); + await collections.posts.insertOne({ + title: "Post 1", + contents: "Content 1", + author: user._id, + }); + await collections.posts.insertOne({ + title: "Post 2", + contents: "Content 2", + author: user._id, + }); + + const populatedUser = await collections.users.find().populate({ posts: { limit: 1, skip: 0 } }); expect(populatedUser.length).toBe(1); expect(populatedUser[0].posts.length).toBe(1); expect(populatedUser[0].posts[0].title).toBe("Post 1"); @@ -99,29 +88,20 @@ describe("Population Options", async () => { it("should populate with default omit option", async () => { const { collections } = setupSchemasAndCollections(); - const user = await collections.users - .insertOne({ - name: "Test User 2", - isAdmin: false, - createdAt: new Date(), - }) - ; - - await collections.posts - .insertOne({ - title: "Post 3", - contents: "Content 3", - author: user._id, - }) - ; - - const populatedUser = await collections.users - .find() - .populate({ - posts: true, - }) - ; + const user = await collections.users.insertOne({ + name: "Test User 2", + isAdmin: false, + createdAt: new Date(), + }); + await collections.posts.insertOne({ + title: "Post 3", + contents: "Content 3", + author: user._id, + }); + const populatedUser = await collections.users.find().populate({ + posts: true, + }); expect(populatedUser.length).toBe(1); expect(populatedUser[0].posts.length).toBe(1); expect(populatedUser[0].posts[0]).toHaveProperty("contents"); @@ -131,31 +111,22 @@ describe("Population Options", async () => { it("should populate with omit option", async () => { const { collections } = setupSchemasAndCollections(); - const user = await collections.users - .insertOne({ - name: "Test User 2", - isAdmin: false, - createdAt: new Date(), - }) - ; - - await collections.posts - .insertOne({ - title: "Post 3", - contents: "Content 3", - author: user._id, - }) - ; - - const populatedUser = await collections.users - .find() - .populate({ - posts: { - omit: { title: true }, - }, - }) - ; + const user = await collections.users.insertOne({ + name: "Test User 2", + isAdmin: false, + createdAt: new Date(), + }); + await collections.posts.insertOne({ + title: "Post 3", + contents: "Content 3", + author: user._id, + }); + const populatedUser = await collections.users.find().populate({ + posts: { + omit: { title: true }, + }, + }); expect(populatedUser.length).toBe(1); expect(populatedUser[0].posts.length).toBe(1); expect(populatedUser[0].posts[0]).toHaveProperty("secret"); @@ -165,31 +136,22 @@ describe("Population Options", async () => { it("should populate with select option", async () => { const { collections } = setupSchemasAndCollections(); - const user = await collections.users - .insertOne({ - name: "Test User 2", - isAdmin: false, - createdAt: new Date(), - }) - ; - - await collections.posts - .insertOne({ - title: "Post 3", - contents: "Content 3", - author: user._id, - }) - ; - - const populatedUser = await collections.users - .find() - .populate({ - posts: { - select: { title: true }, - }, - }) - ; + const user = await collections.users.insertOne({ + name: "Test User 2", + isAdmin: false, + createdAt: new Date(), + }); + await collections.posts.insertOne({ + title: "Post 3", + contents: "Content 3", + author: user._id, + }); + const populatedUser = await collections.users.find().populate({ + posts: { + select: { title: true }, + }, + }); expect(populatedUser.length).toBe(1); expect(populatedUser[0].posts.length).toBe(1); expect(populatedUser[0].posts[0]).toHaveProperty("title"); @@ -200,39 +162,28 @@ describe("Population Options", async () => { it("should populate with sort option", async () => { const { collections } = setupSchemasAndCollections(); - const user = await collections.users - .insertOne({ - name: "Test User 5", - isAdmin: false, - createdAt: new Date(), - }) - ; - - await collections.posts - .insertOne({ - title: "Post 6", - contents: "Content 6", - author: user._id, - }) - ; - - await collections.posts - .insertOne({ - title: "Post 7", - contents: "Content 7", - author: user._id, - }) - ; - - const populatedUser = await collections.users - .find() - .populate({ - posts: { - sort: { title: -1 }, - }, - }) - ; + const user = await collections.users.insertOne({ + name: "Test User 5", + isAdmin: false, + createdAt: new Date(), + }); + await collections.posts.insertOne({ + title: "Post 6", + contents: "Content 6", + author: user._id, + }); + + await collections.posts.insertOne({ + title: "Post 7", + contents: "Content 7", + author: user._id, + }); + const populatedUser = await collections.users.find().populate({ + posts: { + sort: { title: -1 }, + }, + }); expect(populatedUser.length).toBe(1); expect(populatedUser[0].posts.length).toBe(2); expect(populatedUser[0].posts[0]).toHaveProperty("title", "Post 7"); diff --git a/tests/relations/ref.test.ts b/tests/relations/ref.test.ts index 75ef082..4201423 100644 --- a/tests/relations/ref.test.ts +++ b/tests/relations/ref.test.ts @@ -65,46 +65,34 @@ describe("ref() relation tests", async () => { it("should populate ref() relation (posts)", async () => { const { collections } = setupSchemasAndCollections(); - const user = await collections.users - .insertOne({ - name: "Bob", - isAdmin: false, - createdAt: new Date(), - tutor: undefined, - }) - ; - - const tutoredUser = await collections.users - .insertOne({ - name: "Alexa", - isAdmin: false, - createdAt: new Date(), - tutor: user._id, - }) - ; - - await collections.posts - .insertOne({ - title: "Pilot", - contents: "Lorem", - author: user._id, - }) - ; - - await collections.posts - .insertOne({ - title: "Pilot 2", - contents: "Lorem2", - author: user._id, - }) - ; - - await collections.posts - .insertOne({ - title: "No Author", - contents: "Lorem", - }) - ; + const user = await collections.users.insertOne({ + name: "Bob", + isAdmin: false, + createdAt: new Date(), + tutor: undefined, + }); + const tutoredUser = await collections.users.insertOne({ + name: "Alexa", + isAdmin: false, + createdAt: new Date(), + tutor: user._id, + }); + await collections.posts.insertOne({ + title: "Pilot", + contents: "Lorem", + author: user._id, + }); + + await collections.posts.insertOne({ + title: "Pilot 2", + contents: "Lorem2", + author: user._id, + }); + + await collections.posts.insertOne({ + title: "No Author", + contents: "Lorem", + }); const populatedUsers = await collections.users.find().populate({ posts: true, tutor: true }); @@ -117,28 +105,21 @@ describe("ref() relation tests", async () => { it("should handle multiple ref() relations with same field", async () => { const { collections } = setupSchemasAndCollections(); - const user = await collections.users - .insertOne({ - name: "Test User", - isAdmin: false, - createdAt: new Date(), - }) - ; - - await collections.posts - .insertOne({ - title: "Post 1", - contents: "Content 1", - author: user._id, - }) - ; - - await collections.books - .insertOne({ - title: "Book 1", - author: user._id, - }) - ; + const user = await collections.users.insertOne({ + name: "Test User", + isAdmin: false, + createdAt: new Date(), + }); + await collections.posts.insertOne({ + title: "Post 1", + contents: "Content 1", + author: user._id, + }); + + await collections.books.insertOne({ + title: "Book 1", + author: user._id, + }); const populatedUser = await collections.users.findById(user._id).populate({ posts: true, books: true }); @@ -186,63 +167,47 @@ describe("ref() relation tests", async () => { PostRelationsEditor, }); - const user = await db.collections.users - .insertOne({ - name: "Test User", - isAdmin: false, - createdAt: new Date(), - }) - ; - - const user2 = await db.collections.users - .insertOne({ - name: "Test User 2", - isAdmin: false, - createdAt: new Date(), - }) - ; - - await db.collections.posts - .insertOne({ - title: "Post 1", - contents: "Content 1", - author: user._id, - editor: user2._id, - }) - ; - - await db.collections.posts - .insertOne({ - title: "Post 2", - contents: "Content 2", - author: user2._id, - editor: user2._id, - }) - ; - - await db.collections.books - .insertOne({ - title: "Book 1", - author: user._id, - }) - ; - - const populatedUser = await db.collections.users - .findById(user._id) - .populate({ - posts: { - populate: { - editor: { - populate: { - posts: true, - }, + const user = await db.collections.users.insertOne({ + name: "Test User", + isAdmin: false, + createdAt: new Date(), + }); + const user2 = await db.collections.users.insertOne({ + name: "Test User 2", + isAdmin: false, + createdAt: new Date(), + }); + await db.collections.posts.insertOne({ + title: "Post 1", + contents: "Content 1", + author: user._id, + editor: user2._id, + }); + + await db.collections.posts.insertOne({ + title: "Post 2", + contents: "Content 2", + author: user2._id, + editor: user2._id, + }); + + await db.collections.books.insertOne({ + title: "Book 1", + author: user._id, + }); + + const populatedUser = await db.collections.users.findById(user._id).populate({ + posts: { + populate: { + editor: { + populate: { + posts: true, }, }, }, - books: true, - }) - ; - + }, + books: true, + }); expect(populatedUser).toBeTruthy(); expect(populatedUser?.posts).toHaveLength(1); expect(populatedUser?.books).toHaveLength(1); diff --git a/tests/schema/schema.test.ts b/tests/schema/schema.test.ts index 39fea7d..24b5491 100644 --- a/tests/schema/schema.test.ts +++ b/tests/schema/schema.test.ts @@ -28,13 +28,11 @@ describe("Schema", async () => { isAdmin: true, }); const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - age: 0, - isAdmin: true, - }) - ; + const res = await db.collections.users.insertOne({ + name: "tom", + age: 0, + isAdmin: true, + }); expect(res).toStrictEqual({ _id: res._id, name: "tom", age: 0 }); const doc = await db.collections.users.findOne({ _id: res._id }); expect(doc).toStrictEqual({ _id: res._id, name: "tom", age: 0 }); @@ -49,13 +47,11 @@ describe("Schema", async () => { role: virtual("isAdmin", ({ isAdmin }) => (isAdmin ? "admin" : "user")), }); const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom cruise", - age: 0, - isAdmin: true, - }) - ; + const res = await db.collections.users.insertOne({ + name: "tom cruise", + age: 0, + isAdmin: true, + }); const doc = await db.collections.users.findOne({ _id: res._id }); expect(doc).toStrictEqual({ _id: res._id, @@ -80,13 +76,11 @@ describe("Schema", async () => { role: virtual("isAdmin", ({ isAdmin }) => (isAdmin ? "admin" : "user")), }); const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - age: 0, - isAdmin: true, - }) - ; + const res = await db.collections.users.insertOne({ + name: "tom", + age: 0, + isAdmin: true, + }); const doc = await db.collections.users.findOne({ _id: res._id }); expect(doc).toStrictEqual({ _id: res._id, @@ -109,13 +103,11 @@ describe("Schema", async () => { role: virtual("isAdmin", ({ isAdmin }) => (isAdmin !== undefined ? "known" : "unknown")), }); const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - age: 0, - isAdmin: true, - }) - ; + const res = await db.collections.users.insertOne({ + name: "tom", + age: 0, + isAdmin: true, + }); expect(res).toStrictEqual({ _id: res._id, name: "tom", @@ -152,14 +144,12 @@ describe("Schema", async () => { role: virtual("isAdmin", ({ isAdmin }) => (isAdmin ? "admin" : "user")), }); const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - age: 0, - isAdmin: true, - role: 1, - }) - ; + const res = await db.collections.users.insertOne({ + name: "tom", + age: 0, + isAdmin: true, + role: 1, + }); const doc = await db.collections.users.findOne({ _id: res._id }); expect(doc).toStrictEqual({ _id: res._id, @@ -183,43 +173,35 @@ describe("Schema", async () => { const db = createDatabase(client.db(), { users: schema }); // duplicate username - await db.collections.users - .insertOne({ - firstname: "bob", + await db.collections.users.insertOne({ + firstname: "bob", + surname: "paul", + username: "bobpaul", + age: 0, + }); + await expect(async () => { + await db.collections.users.insertOne({ + firstname: "bobby", surname: "paul", username: "bobpaul", age: 0, - }) - ; - await expect(async () => { - await db.collections.users - .insertOne({ - firstname: "bobby", - surname: "paul", - username: "bobpaul", - age: 0, - }) - ; + }); }).rejects.toThrowError("E11000 duplicate key error"); // duplicate firstname and lastname pair - await db.collections.users - .insertOne({ + await db.collections.users.insertOne({ + firstname: "alice", + surname: "wonder", + username: "alicewonder", + age: 0, + }); + await expect(async () => { + await db.collections.users.insertOne({ firstname: "alice", surname: "wonder", - username: "alicewonder", + username: "allywon", age: 0, - }) - ; - await expect(async () => { - await db.collections.users - .insertOne({ - firstname: "alice", - surname: "wonder", - username: "allywon", - age: 0, - }) - ; + }); }).rejects.toThrowError("E11000 duplicate key error"); }); @@ -231,14 +213,11 @@ describe("Schema", async () => { }); const db = createDatabase(client.db(), { products: schema }); - const product = await db.collections.products - .insertOne({ - _id: "product-123", - name: "Laptop", - price: 999, - }) - ; - + const product = await db.collections.products.insertOne({ + _id: "product-123", + name: "Laptop", + price: 999, + }); expect(product).toStrictEqual({ _id: "product-123", name: "Laptop", @@ -261,14 +240,11 @@ describe("Schema", async () => { }); const db = createDatabase(client.db(), { orders: schema }); - const order = await db.collections.orders - .insertOne({ - _id: 12345, - customerId: "cust-001", - total: 150.5, - }) - ; - + const order = await db.collections.orders.insertOne({ + _id: 12345, + customerId: "cust-001", + total: 150.5, + }); expect(order).toStrictEqual({ _id: 12345, customerId: "cust-001", diff --git a/tests/types/binary.test.ts b/tests/types/binary.test.ts index 21adbfd..76876d0 100644 --- a/tests/types/binary.test.ts +++ b/tests/types/binary.test.ts @@ -70,12 +70,9 @@ describe("binary()", () => { test("accepts Buffer and returns Binary on insert", async () => { const testBuffer = Buffer.from("hello world"); - const inserted = await collections.bsonData - .insertOne({ - binaryField: testBuffer, - }) - ; - + const inserted = await collections.bsonData.insertOne({ + binaryField: testBuffer, + }); expect(inserted.binaryField).toBeInstanceOf(Binary); expect(inserted.binaryField!.buffer.toString()).toBe("hello world"); @@ -89,12 +86,9 @@ describe("binary()", () => { test("accepts Binary and returns Binary on insert", async () => { const testBinary = new Binary(Buffer.from("binary data")); - const inserted = await collections.bsonData - .insertOne({ - binaryField: testBinary, - }) - ; - + const inserted = await collections.bsonData.insertOne({ + binaryField: testBinary, + }); expect(inserted.binaryField).toBeInstanceOf(Binary); expect(inserted.binaryField!.buffer.toString()).toBe("binary data"); diff --git a/tests/types/decimal128.test.ts b/tests/types/decimal128.test.ts index 8a5aef2..70d50f0 100644 --- a/tests/types/decimal128.test.ts +++ b/tests/types/decimal128.test.ts @@ -92,12 +92,9 @@ describe("decimal128()", () => { test("accepts Decimal128 and returns Decimal128", async () => { const testDecimal = Decimal128.fromString("123456789.123456789123456789"); - const inserted = await collections.bsonData - .insertOne({ - decimalField: testDecimal, - }) - ; - + const inserted = await collections.bsonData.insertOne({ + decimalField: testDecimal, + }); expect(inserted.decimalField).toBeInstanceOf(Decimal128); expect(inserted.decimalField!.toString()).toBe("123456789.123456789123456789"); @@ -109,12 +106,9 @@ describe("decimal128()", () => { }); test("accepts string and returns Decimal128", async () => { - const inserted = await collections.bsonData - .insertOne({ - decimalField: "999.999999", - }) - ; - + const inserted = await collections.bsonData.insertOne({ + decimalField: "999.999999", + }); expect(inserted.decimalField).toBeInstanceOf(Decimal128); expect(inserted.decimalField!.toString()).toBe("999.999999"); @@ -127,12 +121,9 @@ describe("decimal128()", () => { test("handles high precision decimals", async () => { const highPrecision = "99999999999999.999999999999999999"; - const inserted = await collections.bsonData - .insertOne({ - decimalField: highPrecision, - }) - ; - + const inserted = await collections.bsonData.insertOne({ + decimalField: highPrecision, + }); expect(inserted.decimalField).toBeInstanceOf(Decimal128); expect(inserted.decimalField!.toString()).toBe(highPrecision); diff --git a/tests/types/long.test.ts b/tests/types/long.test.ts index 1c9e41d..da2101a 100644 --- a/tests/types/long.test.ts +++ b/tests/types/long.test.ts @@ -91,12 +91,9 @@ describe("long()", () => { test("accepts Long (large value) and returns Long", async () => { const testLong = Long.fromString("9223372036854775807"); - const inserted = await collections.bsonData - .insertOne({ - longField: testLong, - }) - ; - + const inserted = await collections.bsonData.insertOne({ + longField: testLong, + }); expect(Long.isLong(inserted.longField)).toBe(true); expect((inserted.longField as Long).toString()).toBe("9223372036854775807"); @@ -109,12 +106,9 @@ describe("long()", () => { }); test("accepts number (safe integer) and returns number", async () => { - const inserted = await collections.bsonData - .insertOne({ - longField: 123456789, - }) - ; - + const inserted = await collections.bsonData.insertOne({ + longField: 123456789, + }); expect(typeof inserted.longField).toBe("number"); expect(inserted.longField).toBe(123456789); @@ -127,12 +121,9 @@ describe("long()", () => { }); test("accepts bigint (outside safe range) and returns Long", async () => { - const inserted = await collections.bsonData - .insertOne({ - longField: BigInt("9223372036854775807"), - }) - ; - + const inserted = await collections.bsonData.insertOne({ + longField: BigInt("9223372036854775807"), + }); expect(Long.isLong(inserted.longField)).toBe(true); expect((inserted.longField as Long).toString()).toBe("9223372036854775807"); diff --git a/tests/types/objectid.test.ts b/tests/types/objectid.test.ts index 5a2876b..67430a2 100644 --- a/tests/types/objectid.test.ts +++ b/tests/types/objectid.test.ts @@ -80,12 +80,9 @@ describe("objectId()", () => { test("accepts ObjectId and returns ObjectId", async () => { const testId = new ObjectId(); - const inserted = await collections.testData - .insertOne({ - refId: testId, - }) - ; - + const inserted = await collections.testData.insertOne({ + refId: testId, + }); expect(inserted.refId).toBeInstanceOf(ObjectId); expect(inserted.refId?.toString()).toBe(testId.toString()); @@ -99,12 +96,9 @@ describe("objectId()", () => { test("accepts valid string and returns ObjectId", async () => { const validId = "507f1f77bcf86cd799439011"; - const inserted = await collections.testData - .insertOne({ - refId: validId, - }) - ; - + const inserted = await collections.testData.insertOne({ + refId: validId, + }); expect(inserted.refId).toBeInstanceOf(ObjectId); expect(inserted.refId?.toString()).toBe(validId); From 5223e60f53bce3d82364af2055d31e84eb4deb57 Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Thu, 1 Jan 2026 15:00:20 +0100 Subject: [PATCH 06/14] Add findByIdAndUpdate and findByIdAndDelete collection methods --- .changeset/orange-pets-battle.md | 5 ++++ src/collection/collection.ts | 27 +++++++++++++++++++++ tests/query/delete.test.ts | 35 +++++++++++++++++++++++++++ tests/query/update.test.ts | 41 ++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 .changeset/orange-pets-battle.md diff --git a/.changeset/orange-pets-battle.md b/.changeset/orange-pets-battle.md new file mode 100644 index 0000000..e2740e9 --- /dev/null +++ b/.changeset/orange-pets-battle.md @@ -0,0 +1,5 @@ +--- +"monarch-orm": minor +--- + +Add `findByIdAndUpdate()` and `findByIdAndDelete()` collection methods diff --git a/src/collection/collection.ts b/src/collection/collection.ts index e078b37..4626616 100644 --- a/src/collection/collection.ts +++ b/src/collection/collection.ts @@ -94,6 +94,33 @@ export class Collection, "_id">, update: UpdateFilter>) { + const _idType = Schema.types(this.schema)._id; + const isObjectIdType = MonarchType.isInstanceOf(_idType, MonarchObjectId); + + return new FindOneAndUpdateQuery( + this.schema, + this._collection, + this._readyPromise, + // @ts-ignore + { _id: isObjectIdType ? new ObjectId(id) : id }, + update, + ); + } + + public findByIdAndDelete(id: Index, "_id">) { + const _idType = Schema.types(this.schema)._id; + const isObjectIdType = MonarchType.isInstanceOf(_idType, MonarchObjectId); + + return new FindOneAndDeleteQuery( + this.schema, + this._collection, + this._readyPromise, + // @ts-ignore + { _id: isObjectIdType ? new ObjectId(id) : id }, + ); + } + public findOne(filter: Filter>) { return new FindOneQuery(this.schema, this.relations, this._collection, this._readyPromise, filter); } diff --git a/tests/query/delete.test.ts b/tests/query/delete.test.ts index f433608..cc6347d 100644 --- a/tests/query/delete.test.ts +++ b/tests/query/delete.test.ts @@ -42,4 +42,39 @@ describe("Delete Operations", async () => { const deleted = await collections.users.deleteOne({ email: "anon2@gmail.com" }); expect(deleted.deletedCount).toBe(1); }); + + it("finds and deletes one by ObjectId", async () => { + const user = await collections.users.insertOne(mockUsers[0]); + + const deletedUser = await collections.users.findByIdAndDelete(user._id); + + expect(deletedUser).not.toBe(null); + expect(deletedUser?._id).toStrictEqual(user._id); + expect(deletedUser?.email).toBe(mockUsers[0].email); + + // Verify it was actually deleted + const found = await collections.users.findById(user._id); + expect(found).toBe(null); + }); + + it("finds and deletes one by ObjectId string", async () => { + const user = await collections.users.insertOne(mockUsers[1]); + + const deletedUser = await collections.users.findByIdAndDelete(user._id.toString()); + + expect(deletedUser).not.toBe(null); + expect(deletedUser?._id).toStrictEqual(user._id); + expect(deletedUser?.email).toBe(mockUsers[1].email); + + // Verify it was actually deleted + const found = await collections.users.findById(user._id); + expect(found).toBe(null); + }); + + it("findByIdAndDelete returns null when document not found", async () => { + const { ObjectId } = await import("mongodb"); + const userId = new ObjectId(); + const deletedUser = await collections.users.findByIdAndDelete(userId); + expect(deletedUser).toBe(null); + }); }); diff --git a/tests/query/update.test.ts b/tests/query/update.test.ts index 87e3fe9..7eebf61 100644 --- a/tests/query/update.test.ts +++ b/tests/query/update.test.ts @@ -74,6 +74,47 @@ describe("Update Operations", async () => { expect(replaced.modifiedCount).toBe(1); }); + it("finds and updates one by ObjectId", async () => { + const user = await collections.users.insertOne(mockUsers[0]); + + const updatedUser = await collections.users + .findByIdAndUpdate(user._id, { $set: { age: 99 } }) + .options({ returnDocument: "after" }); + + expect(updatedUser).not.toBe(null); + expect(updatedUser?._id).toStrictEqual(user._id); + expect(updatedUser?.age).toBe(99); + }); + + it("finds and updates one by ObjectId string", async () => { + const user = await collections.users.insertOne(mockUsers[1]); + + const updatedUser = await collections.users + .findByIdAndUpdate(user._id.toString(), { $set: { age: 77 } }) + .options({ returnDocument: "after" }); + + expect(updatedUser).not.toBe(null); + expect(updatedUser?._id).toStrictEqual(user._id); + expect(updatedUser?.age).toBe(77); + }); + + it("findByIdAndUpdate triggers onUpdate hooks", async () => { + const schema = createSchema("users", { + name: string(), + age: number().onUpdate(() => 555), + }); + const db = createDatabase(client.db(), { users: schema }); + + const user = await db.collections.users.insertOne({ name: "Alice", age: 20 }); + + const updatedUser = await db.collections.users + .findByIdAndUpdate(user._id, { $set: { name: "Bob" } }) + .options({ returnDocument: "after" }); + + expect(updatedUser?.name).toBe("Bob"); + expect(updatedUser?.age).toBe(555); + }); + describe("edge cases", () => { it("should not mutate reused update object in updateOne", async () => { const schema = createSchema("users", { From 4cc4438cfdbaed8c6c71af03718f8af7baa4218f Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Thu, 1 Jan 2026 15:02:19 +0100 Subject: [PATCH 07/14] Format files --- src/collection/collection.ts | 5 ++++- todo.md | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/collection/collection.ts b/src/collection/collection.ts index 4626616..ffdea3b 100644 --- a/src/collection/collection.ts +++ b/src/collection/collection.ts @@ -94,7 +94,10 @@ export class Collection, "_id">, update: UpdateFilter>) { + public findByIdAndUpdate( + id: Index, "_id">, + update: UpdateFilter>, + ) { const _idType = Schema.types(this.schema)._id; const isObjectIdType = MonarchType.isInstanceOf(_idType, MonarchObjectId); diff --git a/todo.md b/todo.md index b66286b..222c27d 100644 --- a/todo.md +++ b/todo.md @@ -26,7 +26,6 @@ Here are some features we need to implement. ### API Improvements -- [] Add `findByIdAndUpdate()` / `findByIdAndDelete()` shortcuts - [] Add batch operations helper methods - [] Fully document public API - [] Remove need for `ref` by auto reversing `one` relation From 58526167ba109280614082a2300579a35e5c7c5d Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Fri, 2 Jan 2026 20:12:43 +0100 Subject: [PATCH 08/14] Remove redundant ObjectId functions --- src/index.ts | 2 +- src/utils/objectId.ts | 29 ----------------------------- 2 files changed, 1 insertion(+), 30 deletions(-) diff --git a/src/index.ts b/src/index.ts index a667d30..c967f3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ export { createRelations, Relations, type Relation } from "./relations/relations export { createSchema, Schema } from "./schema/schema"; export { type InferSchemaInput, type InferSchemaOutput } from "./schema/type-helpers"; export { virtual, type Virtual } from "./schema/virtuals"; -export { generateObjectId, isValidObjectId, objectIdToString, toObjectId } from "./utils/objectId"; +export { toObjectId } from "./utils/objectId"; export const m = { array, diff --git a/src/utils/objectId.ts b/src/utils/objectId.ts index 19f628e..ff0a8b0 100644 --- a/src/utils/objectId.ts +++ b/src/utils/objectId.ts @@ -13,32 +13,3 @@ export const toObjectId = (id: string | ObjectId): ObjectId | undefined => { } return undefined; }; - -/** - * Checks if a given string is a valid MongoDB ObjectId. - * - * @param id - The string to check. - * @returns True if the string is a valid ObjectId, false otherwise. - */ -export const isValidObjectId = (id: string): boolean => { - return ObjectId.isValid(id); -}; - -/** - * Converts an ObjectId to its string representation. - * - * @param objectId - The ObjectId to convert. - * @returns The string representation of the ObjectId. - */ -export const objectIdToString = (objectId: ObjectId): string => { - return objectId.toHexString(); -}; - -/** - * Generates a new MongoDB ObjectId. - * - * @returns A new ObjectId instance. - */ -export const generateObjectId = (): ObjectId => { - return new ObjectId(); -}; From bf3fba567d3e5252d00e3ef2aebb36f09048738e Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Fri, 2 Jan 2026 20:47:05 +0100 Subject: [PATCH 09/14] Document public API --- src/collection/collection.ts | 150 +++++++++++++++++++++++++++++++++++ src/database.ts | 56 +++++++++++++ src/errors.ts | 6 ++ src/index.ts | 16 ++++ src/operators/index.ts | 111 +++++++++++++++++++++++++- src/relations/relations.ts | 75 ++++++++++++++++-- src/schema/schema.ts | 63 +++++++++++++++ src/schema/virtuals.ts | 19 +++++ src/types/boolean.ts | 8 ++ src/types/date.ts | 50 ++++++++++++ src/types/number.ts | 25 ++++++ src/types/string.ts | 58 ++++++++++++++ 12 files changed, 630 insertions(+), 7 deletions(-) diff --git a/src/collection/collection.ts b/src/collection/collection.ts index ffdea3b..bfc0d97 100644 --- a/src/collection/collection.ts +++ b/src/collection/collection.ts @@ -33,10 +33,23 @@ import { ReplaceOneQuery } from "./query/replace-one"; import { UpdateManyQuery } from "./query/update-many"; import { UpdateOneQuery } from "./query/update-one"; +/** + * Type-safe collection interface for MongoDB operations. + * + * @typeParam TSchema - Schema definition for the collection + * @typeParam TDbRelations - Database relation definitions + */ export class Collection> { private _collection: MongoCollection>; private _readyPromise: Promise; + /** + * Creates a Collection instance. + * + * @param db - MongoDB database instance + * @param schema - Schema definition + * @param relations - Relation definitions + */ constructor( db: Db, public schema: TSchema, @@ -57,14 +70,30 @@ export class Collection>(this.schema.name); } + /** + * Promise that resolves when collection indexes are created. + */ public get isReady() { return this._readyPromise; } + /** + * Returns the underlying MongoDB collection instance. + * + * @returns Native MongoDB collection + */ public raw() { return this._collection; } + /** + * Finds distinct values for a specified field. + * + * @typeParam K - Field key + * @param key - Field name + * @param filter - Query filter + * @returns DistinctQuery instance + */ public distinct>(key: K, filter: Filter> = {}) { return new DistinctQuery[K]>>( this.schema, @@ -76,10 +105,22 @@ export class Collection> = {}) { return new FindQuery(this.schema, this.relations, this._collection, this._readyPromise, filter); } + /** + * Finds a document by its _id field. + * + * @param id - Document ID + * @returns FindOneQuery instance + */ public findById(id: Index, "_id">) { const _idType = Schema.types(this.schema)._id; const isObjectIdType = MonarchType.isInstanceOf(_idType, MonarchObjectId); @@ -94,6 +135,13 @@ export class Collection, "_id">, update: UpdateFilter>, @@ -111,6 +159,12 @@ export class Collection, "_id">) { const _idType = Schema.types(this.schema)._id; const isObjectIdType = MonarchType.isInstanceOf(_idType, MonarchObjectId); @@ -124,62 +178,158 @@ export class Collection>) { return new FindOneQuery(this.schema, this.relations, this._collection, this._readyPromise, filter); } + /** + * Finds a document and replaces it with a new document. + * + * @param filter - Query filter + * @param replacement - Replacement document + * @returns FindOneAndReplaceQuery instance + */ public findOneAndReplace(filter: Filter>, replacement: WithoutId>) { return new FindOneAndReplaceQuery(this.schema, this._collection, this._readyPromise, filter, replacement); } + /** + * Finds a document and updates it. + * + * @param filter - Query filter + * @param update - Update operations + * @returns FindOneAndUpdateQuery instance + */ public findOneAndUpdate(filter: Filter>, update: UpdateFilter>) { return new FindOneAndUpdateQuery(this.schema, this._collection, this._readyPromise, filter, update); } + /** + * Finds a document and deletes it. + * + * @param filter - Query filter + * @returns FindOneAndDeleteQuery instance + */ public findOneAndDelete(filter: Filter>) { return new FindOneAndDeleteQuery(this.schema, this._collection, this._readyPromise, filter); } + /** + * Inserts a single document into the collection. + * + * @param data - Document to insert + * @returns InsertOneQuery instance + */ public insertOne(data: InferSchemaInput) { return new InsertOneQuery(this.schema, this._collection, this._readyPromise, data); } + /** + * Inserts multiple documents into the collection. + * + * @param data - Array of documents to insert + * @returns InsertManyQuery instance + */ public insertMany(data: InferSchemaInput[]) { return new InsertManyQuery(this.schema, this._collection, this._readyPromise, data); } + /** + * Performs multiple write operations in bulk. + * + * @param data - Array of bulk write operations + * @returns BulkWriteQuery instance + */ public bulkWrite(data: AnyBulkWriteOperation>[]) { return new BulkWriteQuery(this.schema, this._collection, this._readyPromise, data); } + /** + * Replaces a single document matching the filter. + * + * @param filter - Query filter + * @param replacement - Replacement document + * @returns ReplaceOneQuery instance + */ public replaceOne(filter: Filter>, replacement: WithoutId>) { return new ReplaceOneQuery(this.schema, this._collection, this._readyPromise, filter, replacement); } + /** + * Updates a single document matching the filter. + * + * @param filter - Query filter + * @param update - Update operations + * @returns UpdateOneQuery instance + */ public updateOne(filter: Filter>, update: UpdateFilter>) { return new UpdateOneQuery(this.schema, this._collection, this._readyPromise, filter, update); } + /** + * Updates multiple documents matching the filter. + * + * @param filter - Query filter + * @param update - Update operations + * @returns UpdateManyQuery instance + */ public updateMany(filter: Filter>, update: UpdateFilter>) { return new UpdateManyQuery(this.schema, this._collection, this._readyPromise, filter, update); } + /** + * Deletes a single document matching the filter. + * + * @param filter - Query filter + * @returns DeleteOneQuery instance + */ public deleteOne(filter: Filter>) { return new DeleteOneQuery(this.schema, this._collection, this._readyPromise, filter); } + /** + * Deletes multiple documents matching the filter. + * + * @param filter - Query filter + * @returns DeleteManyQuery instance + */ public deleteMany(filter: Filter>) { return new DeleteManyQuery(this.schema, this._collection, this._readyPromise, filter); } + /** + * Creates an aggregation pipeline for complex queries. + * + * @typeParam TOutput - Output type of aggregation + * @returns AggregationPipeline instance + */ public aggregate() { return new AggregationPipeline(this.schema, this._collection, this._readyPromise); } + /** + * Counts documents matching the filter. + * + * @param filter - Query filter + * @param options - Count options + * @returns Promise resolving to document count + */ public async countDocuments(filter: Filter> = {}, options?: CountDocumentsOptions) { return await this._collection.countDocuments(filter, options); } + /** + * Estimates total document count in the collection. + * + * @param options - Estimation options + * @returns Promise resolving to estimated count + */ public async estimatedDocumentCount(options?: EstimatedDocumentCountOptions) { return await this._collection.estimatedDocumentCount(options); } diff --git a/src/database.ts b/src/database.ts index 5dfed6a..3b169d2 100644 --- a/src/database.ts +++ b/src/database.ts @@ -9,6 +9,13 @@ import type { AnySchema } from "./schema/schema"; import type { InferSchemaInput, InferSchemaOmit, InferSchemaOutput } from "./schema/type-helpers"; import type { ExtractObject, IdFirst, Merge, Pretty } from "./utils/type-helpers"; +/** + * Creates a MongoDB client configured with Monarch ORM driver information. + * + * @param uri - MongoDB connection URI + * @param options - MongoDB client options + * @returns Configured MongoClient instance + */ export function createClient(uri: string, options: MongoClientOptions = {}) { if (!options.driverInfo) { options.driverInfo = { name: "Monarch ORM", version }; @@ -16,13 +23,29 @@ export function createClient(uri: string, options: MongoClientOptions = {}) { return new MongoClient(uri, options); } +/** + * Manages database collections and relations for type-safe MongoDB operations. + * + * @typeParam TSchemas - Record of schema definitions for collections + * @typeParam TRelations - Record of relation definitions between schemas + */ export class Database< TSchemas extends Record = {}, TRelations extends Record> = {}, > { + /** Relation definitions organized by schema name */ public relations: DbRelations; + /** Type-safe collection instances for each schema */ public collections: DbCollections>; + /** + * Creates a Database instance with collections and relations. + * + * @param db - MongoDB database instance + * @param schemas - Schema definitions for collections + * @param relations - Relation definitions between schemas + * @throws {MonarchError} If duplicate schema or relation names are found + */ constructor( public db: Db, schemas: TSchemas, @@ -61,15 +84,35 @@ export class Database< this.listCollections = this.listCollections.bind(this); } + /** + * Creates a collection instance from a schema. + * + * @typeParam S - Schema type + * @param schema - Schema definition + * @returns Collection instance for the schema + */ public use(schema: S): Collection> { return new Collection(this.db, schema, this.relations[schema.name as keyof DbRelations]); } + /** + * Lists all collection keys defined in the database. + * + * @returns Array of collection keys + */ public listCollections() { return Object.keys(this.collections) as (keyof this["collections"])[]; } } +/** + * Creates a database instance with type-safe collections and relations. + * + * @typeParam T - Record containing schemas and relations + * @param db - MongoDB database instance + * @param schemas - Object containing schema and relation definitions + * @returns Database instance with initialized collections and relations + */ export function createDatabase>>( db: Db, schemas: T, @@ -95,11 +138,24 @@ type DbRelations>> = { [K in keyof TRelations as TRelations[K]["name"]]: TRelations[K]["relations"]; } & {}; +/** + * Infers the input type for a collection in a database. + * + * @typeParam TDatabase - Database instance type + * @typeParam TCollection - Collection key in the database + */ export type InferInput< TDatabase extends Database, TCollection extends keyof TDatabase["collections"], > = InferSchemaInput; +/** + * Infers the output type for a collection query with projection and population options. + * + * @typeParam TDatabase - Database instance type + * @typeParam TCollection - Collection key in the database + * @typeParam TOptions - Query options including select, omit, and populate + */ export type InferOutput< TDatabase extends Database, TCollection extends keyof TDatabase["collections"], diff --git a/src/errors.ts b/src/errors.ts index fc9f62b..55bb18b 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,9 @@ +/** + * Base error class for Monarch ORM errors. + */ export class MonarchError extends Error {} +/** + * Error thrown during schema parsing and validation. + */ export class MonarchParseError extends MonarchError {} diff --git a/src/index.ts b/src/index.ts index c967f3c..31568e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,22 @@ export { type InferSchemaInput, type InferSchemaOutput } from "./schema/type-hel export { virtual, type Virtual } from "./schema/virtuals"; export { toObjectId } from "./utils/objectId"; +/** + * Bundled type constructors and modifiers for convenient access. + * + * Provides all type constructors (string, number, boolean, etc.) and + * modifiers (nullable, optional, defaulted) in a single object. + * + * @example + * ```ts + * import { m } from 'monarch-orm'; + * + * const UserSchema = createSchema('users', { + * name: m.string(), + * age: m.number().optional(), + * }); + * ``` + */ export const m = { array, boolean, diff --git a/src/operators/index.ts b/src/operators/index.ts index 56c9705..e3c8508 100644 --- a/src/operators/index.ts +++ b/src/operators/index.ts @@ -2,53 +2,162 @@ import type { Filter } from "mongodb"; import type { AnySchema } from "../schema/schema"; import type { InferSchemaData } from "../schema/type-helpers"; +/** + * Logical AND operator - matches documents that satisfy all expressions. + * + * @typeParam T - Schema type + * @param expressions - Array of filter expressions + * @returns MongoDB $and operator + */ export function and(...expressions: Filter>[]) { return { $and: expressions }; } + +/** + * Logical OR operator - matches documents that satisfy at least one expression. + * + * @typeParam T - Schema type + * @param expressions - Array of filter expressions + * @returns MongoDB $or operator + */ export function or(...expressions: Filter>[]) { return { $or: expressions }; } + +/** + * Logical NOR operator - matches documents that fail all expressions. + * + * @typeParam T - Schema type + * @param expressions - Array of filter expressions + * @returns MongoDB $nor operator + */ export function nor(...expressions: Filter>[]) { return { $nor: expressions }; } -// Does not exist on root selector +/** + * Logical NOT operator - inverts the effect of a filter expression. + * + * @typeParam T - Schema type + * @param expression - Filter expression to negate + * @returns MongoDB $not operator + */ export function not(expression: Filter>) { return { $not: expression }; } +/** + * Equality operator - matches values equal to a specified value. + * + * @typeParam T - Value type + * @param value - Value to match + * @returns MongoDB $eq operator + */ export function eq(value: T) { return { $eq: value }; } + +/** + * Inequality operator - matches values not equal to a specified value. + * + * @typeParam T - Value type + * @param value - Value to exclude + * @returns MongoDB $ne operator + */ export function neq(value: T) { return { $ne: value }; } + +/** + * Greater than operator - matches values greater than a specified value. + * + * @typeParam T - Value type + * @param value - Comparison value + * @returns MongoDB $gt operator + */ export function gt(value: T) { return { $gt: value }; } + +/** + * Less than operator - matches values less than a specified value. + * + * @typeParam T - Value type + * @param value - Comparison value + * @returns MongoDB $lt operator + */ export function lt(value: T) { return { $lt: value }; } + +/** + * Greater than or equal operator - matches values greater than or equal to a specified value. + * + * @typeParam T - Value type + * @param value - Comparison value + * @returns MongoDB $gte operator + */ export function gte(value: T) { return { $gte: value }; } + +/** + * Less than or equal operator - matches values less than or equal to a specified value. + * + * @typeParam T - Value type + * @param value - Comparison value + * @returns MongoDB $lte operator + */ export function lte(value: T) { return { $lte: value }; } + +/** + * In array operator - matches values that exist in a specified array. + * + * @typeParam T - Value type + * @param values - Array of values to match + * @returns MongoDB $in operator + */ export function inArray(values: T[]) { return { $in: values }; } + +/** + * Not in array operator - matches values that do not exist in a specified array. + * + * @typeParam T - Value type + * @param values - Array of values to exclude + * @returns MongoDB $nin operator + */ export function notInArray(values: T[]) { return { $nin: values }; } +/** + * Exists operator - matches documents where the field exists. + * + * @returns MongoDB $exists operator with true + */ export function exists() { return { $exists: true }; } + +/** + * Not exists operator - matches documents where the field does not exist. + * + * @returns MongoDB $exists operator with false + */ export function notExists() { return { $exists: false }; } +/** + * Size operator - matches arrays with a specified number of elements. + * + * @param value - Required array size + * @returns MongoDB $size operator + */ export function size(value: number) { return { $size: value }; } diff --git a/src/relations/relations.ts b/src/relations/relations.ts index b1c1117..1b7f26b 100644 --- a/src/relations/relations.ts +++ b/src/relations/relations.ts @@ -3,6 +3,15 @@ import type { SchemaRelatableField } from "./type-helpers"; export type AnyRelation = Relation<"one" | "many" | "ref", any, any, any, any>; +/** + * Defines a relationship between two schemas. + * + * @typeParam TRelation - Type of relation: "one", "many", or "ref" + * @typeParam TSchema - Source schema + * @typeParam TSchemaField - Field in source schema + * @typeParam TTarget - Target schema + * @typeParam TTargetField - Field in target schema + */ export type Relation< TRelation extends "one" | "many" | "ref", TSchema extends AnySchema, @@ -19,13 +28,39 @@ export type Relation< export type AnyRelations = Record; +/** + * Container for schema relationships. + * + * @typeParam TName - Schema name + * @typeParam TRelations - Relation definitions + */ export class Relations { + /** + * Creates a Relations instance. + * + * @param name - Schema name + * @param relations - Relation definitions + */ constructor( public name: TName, public relations: TRelations, ) {} } +/** + * Creates type-safe relationship definitions for a schema. + * + * Provides three relation types: + * - `one`: One-to-one relationship + * - `many`: One-to-many relationship + * - `ref`: Reference relationship + * + * @typeParam TSchema - Source schema + * @typeParam TRelations - Relation definitions + * @param schema - Source schema + * @param relations - Function that defines relations using relation builders + * @returns Relations instance for the schema + */ export function createRelations>( schema: TSchema, relations: (relation: CreateRelation) => TRelations, @@ -58,9 +93,41 @@ export function createRelations = { + /** + * Creates a one-to-one relationship. + * + * @param target - Target schema + * @param options - Relation options + * @param options.field - Field in source schema containing the reference + * @param options.references - Field in target schema being referenced + * @returns One-to-one relation definition + */ one: One; + /** + * Creates a one-to-many relationship. + * + * @param target - Target schema + * @param options - Relation options + * @param options.field - Field in source schema containing the reference + * @param options.references - Field in target schema being referenced + * @returns One-to-many relation definition + */ many: Many; + /** + * Creates a reference relationship. + * + * @param target - Target schema + * @param options - Relation options + * @param options.field - Field in source schema containing the reference + * @param options.references - Field in target schema being referenced + * @returns Reference relation definition + */ ref: Ref; }; type One = RelationFactory<"one", TSchema>; @@ -74,13 +141,9 @@ type RelationFactory( target: TTarget, options: { - /** - * The schema field - */ + /** Field in source schema containing the reference */ field: TSchemaField; - /** - * The target schema field - */ + /** Field in target schema being referenced */ references: TTargetField; }, ) => Relation; diff --git a/src/schema/schema.ts b/src/schema/schema.ts index c268ef1..6530e39 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -13,12 +13,27 @@ type SchemaOmit> = { export type AnySchema = Schema; +/** + * Defines the structure and behavior of a MongoDB collection with type safety. + * + * @typeParam TName - Collection name + * @typeParam TTypes - Field type definitions + * @typeParam TOmit - Fields to omit from output + * @typeParam TVirtuals - Virtual field definitions + */ export class Schema< TName extends string, TTypes extends Record, TOmit extends SchemaOmit = {}, TVirtuals extends Record> = {}, > { + /** + * Creates a Schema instance. + * + * @param name - Collection name + * @param _types - Field type definitions + * @param options - Schema options including omit, virtuals, and indexes + */ constructor( public name: TName, private _types: TTypes, @@ -32,12 +47,26 @@ export class Schema< if (!_types._id) this._types._id = objectId().optional(); } + /** + * Specifies fields to omit from query output. + * + * @typeParam TOmit - Fields to omit + * @param omit - Object specifying which fields to omit + * @returns Schema instance with omit configuration + */ omit>(omit: TOmit) { const schema = this as unknown as Schema; schema.options.omit = omit; return schema; } + /** + * Adds virtual computed fields to the schema. + * + * @typeParam TVirtuals - Virtual field definitions + * @param virtuals - Object defining virtual fields + * @returns Schema instance with virtual fields configured + */ virtuals, any, any>>>( virtuals: SchemaVirtuals, ) { @@ -69,10 +98,25 @@ export class Schema< return this; } + /** + * Retrieves the field type definitions from a schema. + * + * @typeParam T - Schema type + * @param schema - Schema instance + * @returns Field type definitions + */ public static types(schema: T): InferSchemaTypes { return schema._types; } + /** + * Parses and validates input data according to schema type definitions. + * + * @typeParam T - Schema type + * @param schema - Schema instance + * @param input - Input data to encode + * @returns Encoded data ready for database storage + */ public static encode(schema: T, input: InferSchemaInput) { const data = {} as InferSchemaData; // parse fields @@ -86,6 +130,16 @@ export class Schema< return data; } + /** + * Transforms database data to output format with virtual fields and projections. + * + * @typeParam T - Schema type + * @param schema - Schema instance + * @param data - Database data to decode + * @param projection - Field projection configuration + * @param forceOmit - Fields to force omit from output + * @returns Decoded output data + */ public static decode( schema: T, data: InferSchemaData, @@ -130,6 +184,15 @@ export class Schema< } } +/** + * Creates a type-safe schema definition for a MongoDB collection. + * + * @typeParam TName - Collection name + * @typeParam TTypes - Field type definitions + * @param name - Collection name + * @param types - Object defining field types + * @returns Schema instance for the collection + */ export function createSchema>( name: TName, types: TTypes, diff --git a/src/schema/virtuals.ts b/src/schema/virtuals.ts index d57ba9f..a3a072f 100644 --- a/src/schema/virtuals.ts +++ b/src/schema/virtuals.ts @@ -14,11 +14,30 @@ type Props, P extends keyof T> = { [K in keyof T as K extends P ? K : never]: InferTypeOutput; } & {}; +/** + * Defines a virtual computed field. + * + * @typeParam T - Schema field types + * @typeParam P - Field names used as input + * @typeParam R - Return type of the virtual field + */ export type Virtual, P extends keyof T, R> = { input: P[]; output(props: Props): R; }; +/** + * Creates a virtual computed field that derives its value from other schema fields. + * + * Virtual fields are computed on query results and are not stored in the database. + * + * @typeParam T - Schema field types + * @typeParam P - Field names used as input + * @typeParam R - Return type of the virtual field + * @param input - Field name or array of field names used as input + * @param output - Function that computes the virtual field value + * @returns Virtual field definition + */ export function virtual, const P extends keyof T, R>( input: P | P[], output: (props: Props) => R, diff --git a/src/types/boolean.ts b/src/types/boolean.ts index 4105ac7..3642532 100644 --- a/src/types/boolean.ts +++ b/src/types/boolean.ts @@ -1,8 +1,16 @@ import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; +/** + * Creates a boolean type definition. + * + * @returns MonarchBoolean instance + */ export const boolean = () => new MonarchBoolean(); +/** + * Boolean type for true/false values. + */ export class MonarchBoolean extends MonarchType { constructor() { super((input) => { diff --git a/src/types/date.ts b/src/types/date.ts index 274665c..d1bbeb4 100644 --- a/src/types/date.ts +++ b/src/types/date.ts @@ -1,8 +1,16 @@ import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; +/** + * Creates a Date type definition. + * + * @returns MonarchDate instance + */ export const date = () => new MonarchDate(); +/** + * Date type with validation methods. + */ export class MonarchDate extends MonarchType { constructor() { super((input) => { @@ -11,6 +19,12 @@ export class MonarchDate extends MonarchType { }); } + /** + * Validates date is after a target date. + * + * @param targetDate - Target date for comparison + * @returns MonarchDate with after validation + */ public after(targetDate: Date) { return date().extend(this, { parse: (input) => { @@ -22,6 +36,12 @@ export class MonarchDate extends MonarchType { }); } + /** + * Validates date is before a target date. + * + * @param targetDate - Target date for comparison + * @returns MonarchDate with before validation + */ public before(targetDate: Date) { return date().extend(this, { parse: (input) => { @@ -34,15 +54,33 @@ export class MonarchDate extends MonarchType { } } +/** + * Date field that automatically sets to current date on creation. + * + * @returns MonarchDate with default value + */ export const createdAt = () => date().default(() => new Date()); +/** + * Date field that automatically updates to current date on modification. + * + * @returns MonarchDate with update and default values + */ export const updatedAt = () => { const base = date(); return base.extend(base, { onUpdate: () => new Date() }).default(() => new Date()); }; +/** + * Creates a date type that accepts ISO date strings. + * + * @returns MonarchDateString instance + */ export const dateString = () => new MonarchDateString(); +/** + * Date type that accepts ISO date strings as input. + */ export class MonarchDateString extends MonarchType { constructor() { super((input) => { @@ -53,6 +91,12 @@ export class MonarchDateString extends MonarchType { }); } + /** + * Validates date is after a target date. + * + * @param targetDate - Target date for comparison + * @returns MonarchDateString with after validation + */ public after(targetDate: Date) { return dateString().extend(this, { parse: (input) => { @@ -64,6 +108,12 @@ export class MonarchDateString extends MonarchType { }); } + /** + * Validates date is before a target date. + * + * @param targetDate - Target date for comparison + * @returns MonarchDateString with before validation + */ public before(targetDate: Date) { return dateString().extend(this, { parse: (input) => { diff --git a/src/types/number.ts b/src/types/number.ts index 511e420..49991f3 100644 --- a/src/types/number.ts +++ b/src/types/number.ts @@ -1,8 +1,16 @@ import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; +/** + * Creates a number type definition. + * + * @returns MonarchNumber instance + */ export const number = () => new MonarchNumber(); +/** + * Number type with validation methods. + */ export class MonarchNumber extends MonarchType { constructor() { super((input) => { @@ -11,6 +19,12 @@ export class MonarchNumber extends MonarchType { }); } + /** + * Validates minimum value. + * + * @param value - Minimum value + * @returns MonarchNumber with min validation + */ public min(value: number) { return number().extend(this, { parse: (input) => { @@ -22,6 +36,12 @@ export class MonarchNumber extends MonarchType { }); } + /** + * Validates maximum value. + * + * @param value - Maximum value + * @returns MonarchNumber with max validation + */ public max(value: number) { return number().extend(this, { parse: (input) => { @@ -33,6 +53,11 @@ export class MonarchNumber extends MonarchType { }); } + /** + * Validates value is an integer. + * + * @returns MonarchNumber with integer validation + */ public integer() { return number().extend(this, { parse: (input) => { diff --git a/src/types/string.ts b/src/types/string.ts index 8f4f5bf..76e8006 100644 --- a/src/types/string.ts +++ b/src/types/string.ts @@ -1,8 +1,16 @@ import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; +/** + * Creates a string type definition. + * + * @returns MonarchString instance + */ export const string = () => new MonarchString(); +/** + * String type with validation and transformation methods. + */ export class MonarchString extends MonarchType { constructor() { super((input) => { @@ -11,24 +19,45 @@ export class MonarchString extends MonarchType { }); } + /** + * Trims whitespace from both ends of the string. + * + * @returns MonarchString with trim transformation + */ public trim() { return string().extend(this, { parse: (input) => input.trim(), }); } + /** + * Converts string to lowercase. + * + * @returns MonarchString with lowercase transformation + */ public lowercase() { return string().extend(this, { parse: (input) => input.toLowerCase(), }); } + /** + * Converts string to uppercase. + * + * @returns MonarchString with uppercase transformation + */ public uppercase() { return string().extend(this, { parse: (input) => input.toUpperCase(), }); } + /** + * Validates minimum string length. + * + * @param length - Minimum length + * @returns MonarchString with length validation + */ public minLength(length: number) { return string().extend(this, { parse: (input) => { @@ -40,6 +69,12 @@ export class MonarchString extends MonarchType { }); } + /** + * Validates maximum string length. + * + * @param length - Maximum length + * @returns MonarchString with length validation + */ public maxLength(length: number) { return string().extend(this, { parse: (input) => { @@ -51,6 +86,12 @@ export class MonarchString extends MonarchType { }); } + /** + * Validates exact string length. + * + * @param length - Required length + * @returns MonarchString with length validation + */ public length(length: number) { return string().extend(this, { parse: (input) => { @@ -62,6 +103,12 @@ export class MonarchString extends MonarchType { }); } + /** + * Validates string matches a regex pattern. + * + * @param regex - Regular expression pattern + * @returns MonarchString with pattern validation + */ public pattern(regex: RegExp) { return string().extend(this, { parse: (input) => { @@ -73,6 +120,11 @@ export class MonarchString extends MonarchType { }); } + /** + * Validates string is not empty. + * + * @returns MonarchString with non-empty validation + */ public nonempty() { return string().extend(this, { preprocess: (input) => { @@ -84,6 +136,12 @@ export class MonarchString extends MonarchType { }); } + /** + * Validates string includes a substring. + * + * @param searchString - Substring to search for + * @returns MonarchString with inclusion validation + */ public includes(searchString: string) { return string().extend(this, { preprocess: (input) => { From 3770f9d4086ec4f25efe1bad9a67cbf4883862cb Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Sat, 3 Jan 2026 12:34:19 +0100 Subject: [PATCH 10/14] Update public API docs --- src/collection/collection.ts | 6 +- src/collection/pipeline/aggregation.ts | 9 +++ src/collection/pipeline/base.ts | 9 +++ src/collection/query/base.ts | 3 + src/collection/query/delete-many.ts | 9 +++ src/collection/query/delete-one.ts | 9 +++ src/collection/query/find-one-and-delete.ts | 21 +++++ src/collection/query/find-one-and-replace.ts | 21 +++++ src/collection/query/find-one-and-update.ts | 21 +++++ src/collection/query/find-one.ts | 27 +++++++ src/collection/query/find.ts | 50 ++++++++++++ src/collection/query/insert-many.ts | 9 +++ src/collection/query/insert-one.ts | 9 +++ src/collection/query/replace-one.ts | 9 +++ src/collection/query/update-many.ts | 9 +++ src/collection/query/update-one.ts | 9 +++ src/database.ts | 17 +--- src/errors.ts | 2 +- src/index.ts | 5 +- src/operators/index.ts | 12 --- src/relations/relations.ts | 12 +-- src/schema/schema.ts | 15 +--- src/schema/virtuals.ts | 6 -- src/types/array.ts | 32 ++++++++ src/types/binary.ts | 8 ++ src/types/boolean.ts | 4 +- src/types/date.ts | 8 +- src/types/decimal128.ts | 8 ++ src/types/literal.ts | 9 +++ src/types/long.ts | 8 ++ src/types/mixed.ts | 8 ++ src/types/number.ts | 4 +- src/types/object.ts | 9 +++ src/types/objectId.ts | 8 ++ src/types/pipe.ts | 10 +++ src/types/record.ts | 9 +++ src/types/string.ts | 4 +- src/types/tuple.ts | 9 +++ src/types/type.ts | 83 ++++++++++++++++++++ src/types/union.ts | 18 +++++ src/utils/misc.ts | 6 ++ 41 files changed, 469 insertions(+), 75 deletions(-) diff --git a/src/collection/collection.ts b/src/collection/collection.ts index bfc0d97..8fb1c4f 100644 --- a/src/collection/collection.ts +++ b/src/collection/collection.ts @@ -34,10 +34,8 @@ import { UpdateManyQuery } from "./query/update-many"; import { UpdateOneQuery } from "./query/update-one"; /** - * Type-safe collection interface for MongoDB operations. + * Collection interface for MongoDB operations. * - * @typeParam TSchema - Schema definition for the collection - * @typeParam TDbRelations - Database relation definitions */ export class Collection> { private _collection: MongoCollection>; @@ -89,7 +87,6 @@ export class Collection() { diff --git a/src/collection/pipeline/aggregation.ts b/src/collection/pipeline/aggregation.ts index 5d3e4f8..2a286c7 100644 --- a/src/collection/pipeline/aggregation.ts +++ b/src/collection/pipeline/aggregation.ts @@ -3,6 +3,9 @@ import type { AnySchema } from "../../schema/schema"; import type { InferSchemaData } from "../../schema/type-helpers"; import { Pipeline } from "./base"; +/** + * Collection.aggregate(). + */ export class AggregationPipeline extends Pipeline { constructor( protected _schema: TSchema, @@ -13,6 +16,12 @@ export class AggregationPipeline { constructor( protected _schema: TSchema, @@ -11,6 +14,12 @@ 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; diff --git a/src/collection/query/base.ts b/src/collection/query/base.ts index 6b3b919..9a80daa 100644 --- a/src/collection/query/base.ts +++ b/src/collection/query/base.ts @@ -4,6 +4,9 @@ import type { InferSchemaData } from "../../schema/type-helpers"; import type { IdFirst, Merge, Pretty } from "../../utils/type-helpers"; import type { WithProjection } from "../types/query-options"; +/** + * Base query class implementing thenable interface. + */ export abstract class Query { constructor( protected _schema: TSchema, diff --git a/src/collection/query/delete-many.ts b/src/collection/query/delete-many.ts index 6c6ffa6..d791aa4 100644 --- a/src/collection/query/delete-many.ts +++ b/src/collection/query/delete-many.ts @@ -3,6 +3,9 @@ import type { AnySchema } from "../../schema/schema"; import type { InferSchemaData } from "../../schema/type-helpers"; import { Query } from "./base"; +/** + * Collection.deleteMany(). + */ export class DeleteManyQuery extends Query { constructor( protected _schema: TSchema, @@ -14,6 +17,12 @@ export class DeleteManyQuery extends Query extends Query { constructor( protected _schema: TSchema, @@ -14,6 +17,12 @@ export class DeleteOneQuery extends Query, @@ -24,16 +27,34 @@ export class FindOneAndDeleteQuery< this._projection = makeProjection("omit", _schema.options.omit ?? {}); } + /** + * Adds delete options. Options are merged into existing options. + * + * @param options - FindOneAndDeleteOptions + * @returns FindOneAndDeleteQuery instance + */ public options(options: FindOneAndDeleteOptions): this { Object.assign(this._options, options); return this; } + /** + * Excludes fields from results. + * + * @param projection - Fields to exclude + * @returns FindOneAndDeleteQuery instance + */ public omit>>(projection: TProjection) { this._projection = makeProjection("omit", projection); return this as FindOneAndDeleteQuery]>; } + /** + * Includes only specified fields in results. + * + * @param projection - Fields to include + * @returns FindOneAndDeleteQuery instance + */ public select>>(projection: TProjection) { this._projection = makeProjection("select", projection); return this as FindOneAndDeleteQuery]>; diff --git a/src/collection/query/find-one-and-replace.ts b/src/collection/query/find-one-and-replace.ts index d51f75b..afca7c3 100644 --- a/src/collection/query/find-one-and-replace.ts +++ b/src/collection/query/find-one-and-replace.ts @@ -6,6 +6,9 @@ import type { BoolProjection, Projection } from "../types/query-options"; import { addExtraInputsToProjection, makeProjection } from "../utils/projection"; import { Query, type QueryOutput } from "./base"; +/** + * Collection.findOneAndReplace(). + */ export class FindOneAndReplaceQuery< TSchema extends AnySchema, TOutput = InferSchemaOutput, @@ -25,16 +28,34 @@ export class FindOneAndReplaceQuery< this._projection = makeProjection("omit", _schema.options.omit ?? {}); } + /** + * Adds replace options. Options are merged into existing options. + * + * @param options - FindOneAndReplaceOptions + * @returns FindOneAndReplaceQuery instance + */ public options(options: FindOneAndReplaceOptions): this { Object.assign(this._options, options); return this; } + /** + * Excludes fields from results. + * + * @param projection - Fields to exclude + * @returns FindOneAndReplaceQuery instance + */ public omit>>(projection: TProjection) { this._projection = makeProjection("omit", projection); return this as FindOneAndReplaceQuery]>; } + /** + * Includes only specified fields in results. + * + * @param projection - Fields to include + * @returns FindOneAndReplaceQuery instance + */ public select>>(projection: TProjection) { this._projection = makeProjection("select", projection); return this as FindOneAndReplaceQuery]>; diff --git a/src/collection/query/find-one-and-update.ts b/src/collection/query/find-one-and-update.ts index dfcca8b..a5c1980 100644 --- a/src/collection/query/find-one-and-update.ts +++ b/src/collection/query/find-one-and-update.ts @@ -12,6 +12,9 @@ import type { BoolProjection, Projection } from "../types/query-options"; import { addExtraInputsToProjection, makeProjection } from "../utils/projection"; import { Query, type QueryOutput } from "./base"; +/** + * Collection.findOneAndUpdate(). + */ export class FindOneAndUpdateQuery< TSchema extends AnySchema, TOutput = InferSchemaOutput, @@ -31,16 +34,34 @@ export class FindOneAndUpdateQuery< this._projection = makeProjection("omit", _schema.options.omit ?? {}); } + /** + * Adds update options. Options are merged into existing options. + * + * @param options - FindOneAndUpdateOptions + * @returns FindOneAndUpdateQuery instance + */ public options(options: FindOneAndUpdateOptions): this { Object.assign(this._options, options); return this; } + /** + * Excludes fields from results. + * + * @param projection - Fields to exclude + * @returns FindOneAndUpdateQuery instance + */ public omit>>(projection: TProjection) { this._projection = makeProjection("omit", projection); return this as FindOneAndUpdateQuery]>; } + /** + * Includes only specified fields in results. + * + * @param projection - Fields to include + * @returns FindOneAndUpdateQuery instance + */ public select>>(projection: TProjection) { this._projection = makeProjection("select", projection); return this as FindOneAndUpdateQuery]>; diff --git a/src/collection/query/find-one.ts b/src/collection/query/find-one.ts index e460bbe..d5f206e 100644 --- a/src/collection/query/find-one.ts +++ b/src/collection/query/find-one.ts @@ -10,6 +10,9 @@ import { addPipelineMetas, addPopulations, expandPopulations, getSortDirection } import { addExtraInputsToProjection, makeProjection } from "../utils/projection"; import { Query, type QueryOutput } from "./base"; +/** + * Collection.findOne(). + */ export class FindOneQuery< TSchema extends AnySchema, TDbRelations extends Record, @@ -32,21 +35,45 @@ export class FindOneQuery< this._projection = makeProjection("omit", _schema.options.omit ?? {}); } + /** + * Adds find options. Options are merged into existing options. + * + * @param options - FindOptions + * @returns FindOneQuery instance + */ public options(options: FindOptions): this { Object.assign(this._options, options); return this; } + /** + * Excludes fields from results. + * + * @param projection - Fields to exclude + * @returns FindOneQuery instance + */ public omit>>(projection: TProjection) { this._projection = makeProjection("omit", projection); return this as FindOneQuery]>; } + /** + * Includes only specified fields in results. + * + * @param projection - Fields to include + * @returns FindOneQuery instance + */ public select>>(projection: TProjection) { this._projection = makeProjection("select", projection); return this as FindOneQuery]>; } + /** + * Populates relations. + * + * @param population - Relation population config + * @returns FindOneQuery instance + */ public populate>(population: TPopulation) { this._population = population; return this as FindOneQuery< diff --git a/src/collection/query/find.ts b/src/collection/query/find.ts index d03d618..9075ca6 100644 --- a/src/collection/query/find.ts +++ b/src/collection/query/find.ts @@ -10,6 +10,9 @@ import { addPipelineMetas, addPopulations, expandPopulations, getSortDirection } import { addExtraInputsToProjection, makeProjection } from "../utils/projection"; import { Query, type QueryOutput } from "./base"; +/** + * Collection.find(). + */ export class FindQuery< TSchema extends AnySchema, TDbRelations extends Record, @@ -32,36 +35,78 @@ export class FindQuery< this._projection = makeProjection("omit", _schema.options.omit ?? {}); } + /** + * Adds find options. Options are merged into existing options. + * + * @param options - FindOptions + * @returns FindQuery instance + */ public options(options: FindOptions): this { Object.assign(this._options, options); return this; } + /** + * Sets sort order for results. + * + * @param sort - Sort specification + * @returns FindQuery instance + */ public sort(sort: Sort>): this { this._options.sort = sort as MongoSort; return this; } + /** + * Sets maximum number of documents to return. + * + * @param limit - Maximum documents + * @returns FindQuery instance + */ public limit(limit: number): this { this._options.limit = limit; return this; } + /** + * Sets number of documents to skip. + * + * @param skip - Number to skip + * @returns FindQuery instance + */ public skip(skip: number): this { this._options.skip = skip; return this; } + /** + * Sets fields to exclude from results. + * + * @param projection - Fields to exclude + * @returns FindQuery instance + */ public omit>>(projection: TProjection) { this._projection = makeProjection("omit", projection); return this as FindQuery]>; } + /** + * Sets fields to include in results. + * + * @param projection - Fields to include + * @returns FindQuery instance + */ public select>>(projection: TProjection) { this._projection = makeProjection("select", projection); return this as FindQuery]>; } + /** + * Sets relations to populate in results. + * + * @param population - Relation population config + * @returns FindQuery instance + */ public populate>(population: TPopulation) { this._population = population; return this as FindQuery< @@ -73,6 +118,11 @@ export class FindQuery< >; } + /** + * Returns MongoDB cursor for result iteration. + * + * @returns AbstractCursor + */ public async cursor(): Promise>> { await this._readyPromise; if (Object.keys(this._population).length) { diff --git a/src/collection/query/insert-many.ts b/src/collection/query/insert-many.ts index 70a7e6b..abd4347 100644 --- a/src/collection/query/insert-many.ts +++ b/src/collection/query/insert-many.ts @@ -8,6 +8,9 @@ import { type AnySchema, Schema } from "../../schema/schema"; import type { InferSchemaData, InferSchemaInput } from "../../schema/type-helpers"; import { Query } from "./base"; +/** + * Collection.insertMany(). + */ export class InsertManyQuery extends Query< TSchema, InsertManyResult> @@ -22,6 +25,12 @@ export class InsertManyQuery extends Query< super(_schema, _collection, _readyPromise); } + /** + * Adds insert options. Options are merged into existing options. + * + * @param options - BulkWriteOptions + * @returns InsertManyQuery instance + */ public options(options: BulkWriteOptions): this { Object.assign(this._options, options); return this; diff --git a/src/collection/query/insert-one.ts b/src/collection/query/insert-one.ts index c1d3472..849e014 100644 --- a/src/collection/query/insert-one.ts +++ b/src/collection/query/insert-one.ts @@ -5,6 +5,9 @@ import type { Projection } from "../types/query-options"; import { makeProjection } from "../utils/projection"; import { Query, type QueryOutput } from "./base"; +/** + * Collection.insertOne(). + */ export class InsertOneQuery< TSchema extends AnySchema, TOutput = InferSchemaOutput, @@ -23,6 +26,12 @@ export class InsertOneQuery< this._projection = makeProjection("omit", _schema.options.omit ?? {}); } + /** + * Adds insert options. Options are merged into existing options. + * + * @param options - InsertOneOptions + * @returns InsertOneQuery instance + */ public options(options: InsertOneOptions): this { Object.assign(this._options, options); return this; diff --git a/src/collection/query/replace-one.ts b/src/collection/query/replace-one.ts index fb0daf0..7205f63 100644 --- a/src/collection/query/replace-one.ts +++ b/src/collection/query/replace-one.ts @@ -3,6 +3,9 @@ import type { AnySchema } from "../../schema/schema"; import type { InferSchemaData } from "../../schema/type-helpers"; import { Query } from "./base"; +/** + * Collection.replaceOne(). + */ export class ReplaceOneQuery extends Query>> { constructor( protected _schema: TSchema, @@ -15,6 +18,12 @@ export class ReplaceOneQuery extends Query extends Query>> { constructor( protected _schema: TSchema, @@ -22,6 +25,12 @@ export class UpdateManyQuery extends Query extends Query>> { constructor( protected _schema: TSchema, @@ -22,6 +25,12 @@ export class UpdateOneQuery extends Query = {}, TRelations extends Record> = {}, > { - /** Relation definitions organized by schema name */ + /** Relation definitions for each schema */ public relations: DbRelations; - /** Type-safe collection instances for each schema */ + /** Collection instances for each schema */ public collections: DbCollections>; /** @@ -87,7 +85,6 @@ export class Database< /** * Creates a collection instance from a schema. * - * @typeParam S - Schema type * @param schema - Schema definition * @returns Collection instance for the schema */ @@ -106,9 +103,8 @@ export class Database< } /** - * Creates a database instance with type-safe collections and relations. + * Creates a database instance with collections and relations. * - * @typeParam T - Record containing schemas and relations * @param db - MongoDB database instance * @param schemas - Object containing schema and relation definitions * @returns Database instance with initialized collections and relations @@ -141,8 +137,6 @@ type DbRelations>> = { /** * Infers the input type for a collection in a database. * - * @typeParam TDatabase - Database instance type - * @typeParam TCollection - Collection key in the database */ export type InferInput< TDatabase extends Database, @@ -152,9 +146,6 @@ export type InferInput< /** * Infers the output type for a collection query with projection and population options. * - * @typeParam TDatabase - Database instance type - * @typeParam TCollection - Collection key in the database - * @typeParam TOptions - Query options including select, omit, and populate */ export type InferOutput< TDatabase extends Database, diff --git a/src/errors.ts b/src/errors.ts index 55bb18b..7bb9bc1 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -4,6 +4,6 @@ export class MonarchError extends Error {} /** - * Error thrown during schema parsing and validation. + * Schema parsing and validation error. */ export class MonarchParseError extends MonarchError {} diff --git a/src/index.ts b/src/index.ts index 31568e0..6c6ba69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,10 +27,7 @@ export { virtual, type Virtual } from "./schema/virtuals"; export { toObjectId } from "./utils/objectId"; /** - * Bundled type constructors and modifiers for convenient access. - * - * Provides all type constructors (string, number, boolean, etc.) and - * modifiers (nullable, optional, defaulted) in a single object. + * Monarch types namespace for convenient access. * * @example * ```ts diff --git a/src/operators/index.ts b/src/operators/index.ts index e3c8508..89ef490 100644 --- a/src/operators/index.ts +++ b/src/operators/index.ts @@ -5,7 +5,6 @@ import type { InferSchemaData } from "../schema/type-helpers"; /** * Logical AND operator - matches documents that satisfy all expressions. * - * @typeParam T - Schema type * @param expressions - Array of filter expressions * @returns MongoDB $and operator */ @@ -16,7 +15,6 @@ export function and(...expressions: Filter(...expressions: Filter(...expressions: Filter(expression: Filter>) /** * Equality operator - matches values equal to a specified value. * - * @typeParam T - Value type * @param value - Value to match * @returns MongoDB $eq operator */ @@ -60,7 +55,6 @@ export function eq(value: T) { /** * Inequality operator - matches values not equal to a specified value. * - * @typeParam T - Value type * @param value - Value to exclude * @returns MongoDB $ne operator */ @@ -71,7 +65,6 @@ export function neq(value: T) { /** * Greater than operator - matches values greater than a specified value. * - * @typeParam T - Value type * @param value - Comparison value * @returns MongoDB $gt operator */ @@ -82,7 +75,6 @@ export function gt(value: T) { /** * Less than operator - matches values less than a specified value. * - * @typeParam T - Value type * @param value - Comparison value * @returns MongoDB $lt operator */ @@ -93,7 +85,6 @@ export function lt(value: T) { /** * Greater than or equal operator - matches values greater than or equal to a specified value. * - * @typeParam T - Value type * @param value - Comparison value * @returns MongoDB $gte operator */ @@ -104,7 +95,6 @@ export function gte(value: T) { /** * Less than or equal operator - matches values less than or equal to a specified value. * - * @typeParam T - Value type * @param value - Comparison value * @returns MongoDB $lte operator */ @@ -115,7 +105,6 @@ export function lte(value: T) { /** * In array operator - matches values that exist in a specified array. * - * @typeParam T - Value type * @param values - Array of values to match * @returns MongoDB $in operator */ @@ -126,7 +115,6 @@ export function inArray(values: T[]) { /** * Not in array operator - matches values that do not exist in a specified array. * - * @typeParam T - Value type * @param values - Array of values to exclude * @returns MongoDB $nin operator */ diff --git a/src/relations/relations.ts b/src/relations/relations.ts index 1b7f26b..e60c7f3 100644 --- a/src/relations/relations.ts +++ b/src/relations/relations.ts @@ -6,11 +6,6 @@ export type AnyRelation = Relation<"one" | "many" | "ref", any, any, any, any>; /** * Defines a relationship between two schemas. * - * @typeParam TRelation - Type of relation: "one", "many", or "ref" - * @typeParam TSchema - Source schema - * @typeParam TSchemaField - Field in source schema - * @typeParam TTarget - Target schema - * @typeParam TTargetField - Field in target schema */ export type Relation< TRelation extends "one" | "many" | "ref", @@ -31,8 +26,6 @@ export type AnyRelations = Record; /** * Container for schema relationships. * - * @typeParam TName - Schema name - * @typeParam TRelations - Relation definitions */ export class Relations { /** @@ -48,15 +41,13 @@ export class Relations { } /** - * Creates type-safe relationship definitions for a schema. + * Creates relationship definitions for a schema. * * Provides three relation types: * - `one`: One-to-one relationship * - `many`: One-to-many relationship * - `ref`: Reference relationship * - * @typeParam TSchema - Source schema - * @typeParam TRelations - Relation definitions * @param schema - Source schema * @param relations - Function that defines relations using relation builders * @returns Relations instance for the schema @@ -96,7 +87,6 @@ export function createRelations = { /** diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 6530e39..bae4067 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -14,12 +14,8 @@ type SchemaOmit> = { export type AnySchema = Schema; /** - * Defines the structure and behavior of a MongoDB collection with type safety. + * Defines the structure and behavior of a MongoDB collection. * - * @typeParam TName - Collection name - * @typeParam TTypes - Field type definitions - * @typeParam TOmit - Fields to omit from output - * @typeParam TVirtuals - Virtual field definitions */ export class Schema< TName extends string, @@ -50,7 +46,6 @@ export class Schema< /** * Specifies fields to omit from query output. * - * @typeParam TOmit - Fields to omit * @param omit - Object specifying which fields to omit * @returns Schema instance with omit configuration */ @@ -63,7 +58,6 @@ export class Schema< /** * Adds virtual computed fields to the schema. * - * @typeParam TVirtuals - Virtual field definitions * @param virtuals - Object defining virtual fields * @returns Schema instance with virtual fields configured */ @@ -101,7 +95,6 @@ export class Schema< /** * Retrieves the field type definitions from a schema. * - * @typeParam T - Schema type * @param schema - Schema instance * @returns Field type definitions */ @@ -112,7 +105,6 @@ export class Schema< /** * Parses and validates input data according to schema type definitions. * - * @typeParam T - Schema type * @param schema - Schema instance * @param input - Input data to encode * @returns Encoded data ready for database storage @@ -133,7 +125,6 @@ export class Schema< /** * Transforms database data to output format with virtual fields and projections. * - * @typeParam T - Schema type * @param schema - Schema instance * @param data - Database data to decode * @param projection - Field projection configuration @@ -185,10 +176,8 @@ export class Schema< } /** - * Creates a type-safe schema definition for a MongoDB collection. + * Creates a schema definition for a MongoDB collection. * - * @typeParam TName - Collection name - * @typeParam TTypes - Field type definitions * @param name - Collection name * @param types - Object defining field types * @returns Schema instance for the collection diff --git a/src/schema/virtuals.ts b/src/schema/virtuals.ts index a3a072f..e26fb03 100644 --- a/src/schema/virtuals.ts +++ b/src/schema/virtuals.ts @@ -17,9 +17,6 @@ type Props, P extends keyof T> = { /** * Defines a virtual computed field. * - * @typeParam T - Schema field types - * @typeParam P - Field names used as input - * @typeParam R - Return type of the virtual field */ export type Virtual, P extends keyof T, R> = { input: P[]; @@ -31,9 +28,6 @@ export type Virtual, P extends keyof T, * * Virtual fields are computed on query results and are not stored in the database. * - * @typeParam T - Schema field types - * @typeParam P - Field names used as input - * @typeParam R - Return type of the virtual field * @param input - Field name or array of field names used as input * @param output - Function that computes the virtual field value * @returns Virtual field definition diff --git a/src/types/array.ts b/src/types/array.ts index b57ac3a..bfe223f 100644 --- a/src/types/array.ts +++ b/src/types/array.ts @@ -2,8 +2,17 @@ import { MonarchParseError } from "../errors"; import { type AnyMonarchType, MonarchType } from "./type"; import type { InferTypeInput, InferTypeOutput } from "./type-helpers"; +/** + * Array type. + * + * @param type - Element type + * @returns MonarchArray instance + */ export const array = (type: T) => new MonarchArray(type); +/** + * Type for array fields. + */ export class MonarchArray extends MonarchType[], InferTypeOutput[]> { private elementType: T; @@ -30,6 +39,12 @@ export class MonarchArray extends MonarchType { @@ -41,6 +56,12 @@ export class MonarchArray extends MonarchType { @@ -52,6 +73,12 @@ export class MonarchArray extends MonarchType { @@ -63,6 +90,11 @@ export class MonarchArray extends MonarchType new MonarchBinary(); +/** + * Type for Binary fields. + */ export class MonarchBinary extends MonarchType { constructor() { super((input) => { diff --git a/src/types/boolean.ts b/src/types/boolean.ts index 3642532..796a4b1 100644 --- a/src/types/boolean.ts +++ b/src/types/boolean.ts @@ -2,14 +2,14 @@ import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; /** - * Creates a boolean type definition. + * Boolean type. * * @returns MonarchBoolean instance */ export const boolean = () => new MonarchBoolean(); /** - * Boolean type for true/false values. + * Type for boolean fields. */ export class MonarchBoolean extends MonarchType { constructor() { diff --git a/src/types/date.ts b/src/types/date.ts index d1bbeb4..d1fc5ff 100644 --- a/src/types/date.ts +++ b/src/types/date.ts @@ -2,14 +2,14 @@ import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; /** - * Creates a Date type definition. + * Date type. * * @returns MonarchDate instance */ export const date = () => new MonarchDate(); /** - * Date type with validation methods. + * Type for Date fields. */ export class MonarchDate extends MonarchType { constructor() { @@ -72,14 +72,14 @@ export const updatedAt = () => { }; /** - * Creates a date type that accepts ISO date strings. + * Date string type that accepts ISO date strings. * * @returns MonarchDateString instance */ export const dateString = () => new MonarchDateString(); /** - * Date type that accepts ISO date strings as input. + * Type for ISO date string fields. */ export class MonarchDateString extends MonarchType { constructor() { diff --git a/src/types/decimal128.ts b/src/types/decimal128.ts index 9280fdc..b4fc62f 100644 --- a/src/types/decimal128.ts +++ b/src/types/decimal128.ts @@ -2,8 +2,16 @@ import { Decimal128 } from "mongodb"; import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; +/** + * Decimal128 type for high-precision decimals. + * + * @returns MonarchDecimal128 instance + */ export const decimal128 = () => new MonarchDecimal128(); +/** + * Type for Decimal128 fields. + */ export class MonarchDecimal128 extends MonarchType { constructor() { super((input) => { diff --git a/src/types/literal.ts b/src/types/literal.ts index ea738dd..760210e 100644 --- a/src/types/literal.ts +++ b/src/types/literal.ts @@ -1,8 +1,17 @@ import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; +/** + * Literal type. + * + * @param values - Allowed values + * @returns MonarchLiteral instance + */ export const literal = (...values: T[]) => new MonarchLiteral(values); +/** + * Type for literal fields. + */ export class MonarchLiteral extends MonarchType { constructor(values: T[]) { super((input) => { diff --git a/src/types/long.ts b/src/types/long.ts index 01427d1..13206cc 100644 --- a/src/types/long.ts +++ b/src/types/long.ts @@ -2,8 +2,16 @@ import { Long } from "mongodb"; import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; +/** + * Long type for 64-bit integers. + * + * @returns MonarchLong instance + */ export const long = () => new MonarchLong(); +/** + * Type for Long fields. + */ export class MonarchLong extends MonarchType { constructor() { super((input) => { diff --git a/src/types/mixed.ts b/src/types/mixed.ts index ea20a5e..b068a65 100644 --- a/src/types/mixed.ts +++ b/src/types/mixed.ts @@ -1,7 +1,15 @@ import { MonarchType } from "./type"; +/** + * Mixed type. + * + * @returns MonarchMixed instance + */ export const mixed = () => new MonarchMixed(); +/** + * Type for mixed fields. + */ export class MonarchMixed extends MonarchType { constructor() { super((input) => { diff --git a/src/types/number.ts b/src/types/number.ts index 49991f3..cfd3e43 100644 --- a/src/types/number.ts +++ b/src/types/number.ts @@ -2,14 +2,14 @@ import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; /** - * Creates a number type definition. + * Number type. * * @returns MonarchNumber instance */ export const number = () => new MonarchNumber(); /** - * Number type with validation methods. + * Type for number fields. */ export class MonarchNumber extends MonarchType { constructor() { diff --git a/src/types/object.ts b/src/types/object.ts index c761731..ac99a3d 100644 --- a/src/types/object.ts +++ b/src/types/object.ts @@ -2,8 +2,17 @@ import { MonarchParseError } from "../errors"; import { type AnyMonarchType, MonarchType } from "./type"; import type { InferTypeInput, InferTypeObjectInput, InferTypeObjectOutput } from "./type-helpers"; +/** + * Object type. + * + * @param types - Field types + * @returns MonarchObject instance + */ export const object = >(types: T) => new MonarchObject(types); +/** + * Type for object fields. + */ export class MonarchObject> extends MonarchType< InferTypeObjectInput, InferTypeObjectOutput diff --git a/src/types/objectId.ts b/src/types/objectId.ts index 82bf405..d67b741 100644 --- a/src/types/objectId.ts +++ b/src/types/objectId.ts @@ -2,8 +2,16 @@ import { ObjectId } from "mongodb"; import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; +/** + * ObjectId type. + * + * @returns MonarchObjectId instance + */ export const objectId = () => new MonarchObjectId(); +/** + * Type for ObjectId fields. + */ export class MonarchObjectId extends MonarchType { constructor() { super((input) => { diff --git a/src/types/pipe.ts b/src/types/pipe.ts index 28ae79f..ae6562a 100644 --- a/src/types/pipe.ts +++ b/src/types/pipe.ts @@ -1,11 +1,21 @@ import { type AnyMonarchType, MonarchType, pipeParser } from "./type"; import type { InferTypeInput, InferTypeOutput } from "./type-helpers"; +/** + * Pipe type. + * + * @param pipeIn - Input type + * @param pipeOut - Output type + * @returns MonarchPipe instance + */ export const pipe = , any>>( pipeIn: TPipeIn, pipeOut: TPipeOut, ) => new MonarchPipe(pipeIn, pipeOut); +/** + * Type for piped transformations. + */ export class MonarchPipe< TPipeIn extends AnyMonarchType, TPipeOut extends AnyMonarchType, any>, diff --git a/src/types/record.ts b/src/types/record.ts index 281d8ee..77abdc3 100644 --- a/src/types/record.ts +++ b/src/types/record.ts @@ -2,8 +2,17 @@ import { MonarchParseError } from "../errors"; import { type AnyMonarchType, MonarchType } from "./type"; import type { InferTypeInput, InferTypeOutput } from "./type-helpers"; +/** + * Record type. + * + * @param type - Value type + * @returns MonarchRecord instance + */ export const record = (type: T) => new MonarchRecord(type); +/** + * Type for record fields. + */ export class MonarchRecord extends MonarchType< Record>, Record> diff --git a/src/types/string.ts b/src/types/string.ts index 76e8006..73b5d86 100644 --- a/src/types/string.ts +++ b/src/types/string.ts @@ -2,14 +2,14 @@ import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; /** - * Creates a string type definition. + * String type. * * @returns MonarchString instance */ export const string = () => new MonarchString(); /** - * String type with validation and transformation methods. + * Type for string fields. */ export class MonarchString extends MonarchType { constructor() { diff --git a/src/types/tuple.ts b/src/types/tuple.ts index 16e9df1..1f00d8b 100644 --- a/src/types/tuple.ts +++ b/src/types/tuple.ts @@ -2,10 +2,19 @@ import { MonarchParseError } from "../errors"; import { type AnyMonarchType, MonarchType } from "./type"; import type { InferTypeTupleInput, InferTypeTupleOutput } from "./type-helpers"; +/** + * Tuple type. + * + * @param types - Element types + * @returns MonarchTuple instance + */ export const tuple = (types: T) => { return new MonarchTuple(types); }; +/** + * Type for tuple fields. + */ export class MonarchTuple extends MonarchType< InferTypeTupleInput, InferTypeTupleOutput diff --git a/src/types/type.ts b/src/types/type.ts index c883409..918e70a 100644 --- a/src/types/type.ts +++ b/src/types/type.ts @@ -1,8 +1,18 @@ import { MonarchParseError } from "../errors"; import type { InferTypeInput, InferTypeOutput } from "./type-helpers"; +/** + * Parser function type. + */ export type Parser = (input: Input) => Output; +/** + * Chains two parsers into a single parser. + * + * @param prevParser - First parser + * @param nextParser - Second parser + * @returns Chained parser + */ export function pipeParser( prevParser: Parser, nextParser: Parser, @@ -10,25 +20,54 @@ export function pipeParser( return (input) => nextParser(prevParser(input)); } +/** + * Creates a MonarchType with custom parser and optional updater. + * + * @param parser - Parser function + * @param updater - Optional updater function + * @returns MonarchType instance + */ export const type = (parser: Parser, updater?: Parser) => new MonarchType(parser, updater); export type AnyMonarchType = MonarchType; +/** + * Base class for all Monarch types. + */ export class MonarchType { constructor( protected parser: Parser, protected updater?: Parser, ) {} + /** + * Gets parser function from type. + * + * @param type - Monarch type + * @returns Parser function + */ public static parser(type: T): Parser, InferTypeOutput> { return type.parser; } + /** + * Gets updater function from type. + * + * @param type - Monarch type + * @returns Updater function or undefined + */ public static updater(type: T): Parser> | undefined { return type.updater; } + /** + * Checks if type is instance of target class. + * + * @param type - Monarch type + * @param target - Target class + * @returns True if type is instance of target + */ public static isInstanceOf AnyMonarchType>( type: AnyMonarchType, target: T, @@ -40,14 +79,30 @@ export class MonarchType { return this instanceof target; } + /** + * Nullable type modifier. + * + * @returns MonarchNullable instance + */ public nullable() { return nullable(this); } + /** + * Optional type modifier. + * + * @returns MonarchOptional instance + */ public optional() { return optional(this); } + /** + * Default value type modifier. + * + * @param defaultInput - Default value or function + * @returns MonarchDefaulted instance + */ public default(defaultInput: TInput | (() => TInput)) { return defaulted(this, defaultInput as InferTypeInput | (() => InferTypeInput)); } @@ -116,8 +171,17 @@ export class MonarchType { } } +/** + * Nullable type modifier. + * + * @param type - Monarch type + * @returns MonarchNullable instance + */ export const nullable = (type: T) => new MonarchNullable(type); +/** + * Type for nullable fields. + */ export class MonarchNullable extends MonarchType< InferTypeInput | null, InferTypeOutput | null @@ -137,8 +201,17 @@ export class MonarchNullable extends MonarchType< } } +/** + * Optional type modifier. + * + * @param type - Monarch type + * @returns MonarchOptional instance + */ export const optional = (type: T) => new MonarchOptional(type); +/** + * Type for optional fields. + */ export class MonarchOptional extends MonarchType< InferTypeInput | undefined, InferTypeOutput | undefined @@ -158,11 +231,21 @@ export class MonarchOptional extends MonarchType< } } +/** + * Default value type modifier. + * + * @param type - Monarch type + * @param defaultInput - Default value or function + * @returns MonarchDefaulted instance + */ export const defaulted = ( type: T, defaultInput: InferTypeInput | (() => InferTypeInput), ) => new MonarchDefaulted(type, defaultInput); +/** + * Type for fields with default values. + */ export class MonarchDefaulted extends MonarchType< InferTypeInput | undefined, InferTypeOutput diff --git a/src/types/union.ts b/src/types/union.ts index 75444b9..deffb90 100644 --- a/src/types/union.ts +++ b/src/types/union.ts @@ -7,8 +7,17 @@ import type { InferTypeUnionOutput, } from "./type-helpers"; +/** + * Union type. + * + * @param variants - Type variants + * @returns MonarchUnion instance + */ export const union = (...variants: T) => new MonarchUnion(variants); +/** + * Type for union fields. + */ export class MonarchUnion extends MonarchType< InferTypeUnionInput, InferTypeUnionOutput @@ -34,8 +43,17 @@ export class MonarchUnion exten } } +/** + * Tagged union type. + * + * @param variants - Tag to type mapping + * @returns MonarchTaggedUnion instance + */ export const taggedUnion = >(variants: T) => new MonarchTaggedUnion(variants); +/** + * Type for tagged union fields. + */ export class MonarchTaggedUnion> extends MonarchType< InferTypeTaggedUnionInput, InferTypeTaggedUnionOutput diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 8ffd7e9..b3b6404 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -9,6 +9,12 @@ export function mapOneOrArray, U>(input: T | T[], return fn(input); } +/** + * Generates a hash string from input string. + * + * @param input - String to hash + * @returns Base-36 hash string + */ export function hashString(input: string) { let hash = 0; for (let i = 0; i < input.length; i++) { From c40620a96874e9d41e1b6bc7779f73707ec318f5 Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Sat, 3 Jan 2026 12:39:28 +0100 Subject: [PATCH 11/14] Update todos --- tests/types/objectid.test.ts | 1 - todo.md | 12 ------------ 2 files changed, 13 deletions(-) diff --git a/tests/types/objectid.test.ts b/tests/types/objectid.test.ts index 67430a2..d44cd94 100644 --- a/tests/types/objectid.test.ts +++ b/tests/types/objectid.test.ts @@ -32,7 +32,6 @@ describe("objectId()", () => { id: objectId(), }); - // @ts-expect-error expect(() => Schema.encode(schema, { id: "invalid" })).toThrowError("expected valid ObjectId"); // @ts-expect-error expect(() => Schema.encode(schema, { id: {} })).toThrowError("expected valid ObjectId"); diff --git a/todo.md b/todo.md index 222c27d..dc4070c 100644 --- a/todo.md +++ b/todo.md @@ -5,10 +5,6 @@ ## Features Here are some features we need to implement. -### Query Methods - -- [] Find by ID - ### Schema methods - [] unique @@ -23,12 +19,9 @@ Here are some features we need to implement. - [] events like on save, on create and more - ### API Improvements - [] Add batch operations helper methods -- [] Fully document public API -- [] Remove need for `ref` by auto reversing `one` relation ### Missing Features @@ -39,12 +32,9 @@ Here are some features we need to implement. - [] Complete proper schema types - [] Implement where and query for populations - ### Documentation Improvements -- [] Add JSDoc comments on public APIs in Collection class - [] Document population mechanism in detail -- [] Document virtual fields behavior - [] Document error handling patterns - [] Add examples for complex type scenarios @@ -53,8 +43,6 @@ Here are some features we need to implement. - [] Add tests for concurrent operations - [] Add tests for memory usage with large datasets - [] Add tests for index creation failures -- [] Add tests for edge cases in union/tagged union types -- [] Uncomment and fix null value handling tests (query.test.ts:78-93) ### Bugs list From f0048231964a82a27c32a4dffaef5c2e2fc08218 Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Sat, 3 Jan 2026 12:45:02 +0100 Subject: [PATCH 12/14] Use same language for find by id queries --- src/collection/collection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/collection/collection.ts b/src/collection/collection.ts index 8fb1c4f..66e25b3 100644 --- a/src/collection/collection.ts +++ b/src/collection/collection.ts @@ -133,7 +133,7 @@ export class Collection Date: Sat, 3 Jan 2026 12:57:22 +0100 Subject: [PATCH 13/14] Use parse for extending string methods --- src/types/string.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/string.ts b/src/types/string.ts index 73b5d86..7ae5f69 100644 --- a/src/types/string.ts +++ b/src/types/string.ts @@ -127,7 +127,7 @@ export class MonarchString extends MonarchType { */ public nonempty() { return string().extend(this, { - preprocess: (input) => { + parse: (input) => { if (input.length === 0) { throw new MonarchParseError("string must not be empty"); } @@ -144,7 +144,7 @@ export class MonarchString extends MonarchType { */ public includes(searchString: string) { return string().extend(this, { - preprocess: (input) => { + parse: (input) => { if (!input.includes(searchString)) { throw new MonarchParseError(`string must include "${searchString}"`); } From 091c3db78edc2c55a795ad756cfe205896ca86b7 Mon Sep 17 00:00:00 2001 From: Eric Afes Date: Sat, 3 Jan 2026 13:09:23 +0100 Subject: [PATCH 14/14] Remove updater parameter from custom type function --- src/types/type.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/types/type.ts b/src/types/type.ts index 918e70a..f058752 100644 --- a/src/types/type.ts +++ b/src/types/type.ts @@ -21,14 +21,12 @@ export function pipeParser( } /** - * Creates a MonarchType with custom parser and optional updater. + * Creates a MonarchType with custom parser. * * @param parser - Parser function - * @param updater - Optional updater function * @returns MonarchType instance */ -export const type = (parser: Parser, updater?: Parser) => - new MonarchType(parser, updater); +export const type = (parser: Parser) => new MonarchType(parser); export type AnyMonarchType = MonarchType; @@ -115,7 +113,7 @@ export class MonarchType { * @param updateFn function that returns the new value for this field on update operations. */ public onUpdate(updateFn: () => TInput) { - return type(this.parser, pipeParser(updateFn, this.parser)); + return new MonarchType(this.parser, pipeParser(updateFn, this.parser)); } /** @@ -125,7 +123,7 @@ export class MonarchType { * @param fn function that returns a transformed input. */ public transform(fn: (input: TOutput) => TTransformOutput) { - return type(pipeParser(this.parser, fn), this.updater && pipeParser(this.updater, fn)); + return new MonarchType(pipeParser(this.parser, fn), this.updater && pipeParser(this.updater, fn)); } /** @@ -136,7 +134,7 @@ export class MonarchType { * @param message error message when validation fails. */ public validate(fn: (input: TOutput) => boolean, message: string) { - return type( + return new MonarchType( pipeParser(this.parser, (input) => { const valid = fn(input); if (!valid) throw new MonarchParseError(message);