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/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/.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 new file mode 100644 index 0000000..d4d973e --- /dev/null +++ b/.changeset/sunny-cycles-cross.md @@ -0,0 +1,5 @@ +--- +"monarch-orm": minor +--- + +Throw error when adding multiple relations/schema for a collection \ 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/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/collection.ts b/src/collection/collection.ts index 467b797..66e25b3 100644 --- a/src/collection/collection.ts +++ b/src/collection/collection.ts @@ -33,10 +33,21 @@ import { ReplaceOneQuery } from "./query/replace-one"; import { UpdateManyQuery } from "./query/update-many"; import { UpdateOneQuery } from "./query/update-one"; +/** + * Collection interface for MongoDB operations. + * + */ 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 +68,29 @@ 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. + * + * @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 +102,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,62 +132,200 @@ 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, + ); + } + + /** + * Finds a document by its _id field and deletes it. + * + * @param id - Document ID + * @returns FindOneAndDeleteQuery instance + */ + 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 }, + ); + } + + /** + * Finds a single document matching the filter. + * + * @param filter - Query filter + * @returns FindOneQuery instance + */ public findOne(filter: Filter>) { 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); } - public aggregate() { - return new AggregationPipeline(this.schema, this._collection, this._readyPromise); + /** + * Creates an aggregation pipeline for complex queries. + * + * @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/collection/pipeline/aggregation.ts b/src/collection/pipeline/aggregation.ts index 1c859cd..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,16 +16,18 @@ export class AggregationPipeline() { - return this as unknown as AggregationPipeline; - } - - public async exec(): Promise { + 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 7f9c89c..e9e9c3c 100644 --- a/src/collection/pipeline/base.ts +++ b/src/collection/pipeline/base.ts @@ -3,6 +3,9 @@ import type { AnySchema } from "../../schema/schema"; import type { InferSchemaData } from "../../schema/type-helpers"; import type { PipelineStage } from "../types/pipeline-stage"; +/** + * Base aggregation pipeline class implementing thenable interface. + */ export abstract class Pipeline { constructor( protected _schema: TSchema, @@ -11,28 +14,33 @@ 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; } - public abstract exec(): Promise; + protected abstract exec(): Promise; - // biome-ignore lint/suspicious/noThenProperty: We need automatic promise resolution - 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 626f27e..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, @@ -11,23 +14,22 @@ export abstract class Query { protected _readyPromise: Promise, ) {} - public abstract exec(): Promise; + protected abstract exec(): Promise; - // biome-ignore lint/suspicious/noThenProperty: We need automatic promise resolution - 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..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,12 +17,18 @@ 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..9cda173 100644 --- a/src/collection/query/delete-one.ts +++ b/src/collection/query/delete-one.ts @@ -3,6 +3,9 @@ import type { AnySchema } from "../../schema/schema"; import type { InferSchemaData } from "../../schema/type-helpers"; import { Query } from "./base"; +/** + * Collection.deleteOne(). + */ export class DeleteOneQuery extends Query { constructor( protected _schema: TSchema, @@ -14,12 +17,18 @@ 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 10ade57..fb0c25c 100644 --- a/src/collection/query/find-one-and-delete.ts +++ b/src/collection/query/find-one-and-delete.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.findOneAndDelete(). + */ export class FindOneAndDeleteQuery< TSchema extends AnySchema, TOutput = InferSchemaOutput, @@ -24,22 +27,40 @@ 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]>; } - 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, { @@ -47,7 +68,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..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,22 +28,40 @@ 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]>; } - 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, { @@ -48,7 +69,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 8d75328..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,33 +34,57 @@ 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]>; } - public async exec(): Promise | null> { + protected 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, }); 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..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< @@ -58,7 +85,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(); @@ -73,7 +100,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..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) { @@ -81,7 +131,7 @@ export class FindQuery< return this._execWithoutPopulate(); } - public async exec(): Promise[]> { + protected async exec(): Promise[]> { return (await this.cursor()).toArray(); } @@ -91,7 +141,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..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,14 +25,20 @@ 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; } - public async exec(): Promise>> { + protected 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..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,19 +26,25 @@ 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; } - public async exec(): Promise> { + protected 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/query/replace-one.ts b/src/collection/query/replace-one.ts index e18f9d9..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,12 +18,18 @@ 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 ade801f..3c88f2f 100644 --- a/src/collection/query/update-many.ts +++ b/src/collection/query/update-many.ts @@ -10,6 +10,9 @@ import { type AnySchema, Schema } from "../../schema/schema"; import type { InferSchemaData } from "../../schema/type-helpers"; import { Query } from "./base"; +/** + * Collection.updateMany(). + */ export class UpdateManyQuery extends Query>> { constructor( protected _schema: TSchema, @@ -22,17 +25,29 @@ export class UpdateManyQuery extends Query>> { + protected async exec(): Promise>> { 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..45500b0 100644 --- a/src/collection/query/update-one.ts +++ b/src/collection/query/update-one.ts @@ -10,6 +10,9 @@ import { type AnySchema, Schema } from "../../schema/schema"; import type { InferSchemaData } from "../../schema/type-helpers"; import { Query } from "./base"; +/** + * Collection.updateOne(). + */ export class UpdateOneQuery extends Query>> { constructor( protected _schema: TSchema, @@ -22,17 +25,29 @@ export class UpdateOneQuery extends Query>> { + protected async exec(): Promise>> { 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/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..53d5e9f 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,20 +23,39 @@ export function createClient(uri: string, options: MongoClientOptions = {}) { return new MongoClient(uri, options); } +/** + * Manages database collections and relations for MongoDB operations. + * + */ export class Database< TSchemas extends Record = {}, TRelations extends Record> = {}, > { + /** Relation definitions for each schema */ public relations: DbRelations; + /** 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, 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 +64,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, @@ -56,15 +82,33 @@ export class Database< this.listCollections = this.listCollections.bind(this); } + /** + * Creates a collection instance from a schema. + * + * @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 collections 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, @@ -90,11 +134,19 @@ type DbRelations>> = { [K in keyof TRelations as TRelations[K]["name"]]: TRelations[K]["relations"]; } & {}; +/** + * Infers the input type for a collection in a 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. + * + */ export type InferOutput< TDatabase extends Database, TCollection extends keyof TDatabase["collections"], diff --git a/src/errors.ts b/src/errors.ts index fc9f62b..7bb9bc1 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 {} +/** + * Schema parsing and validation error. + */ export class MonarchParseError extends MonarchError {} diff --git a/src/index.ts b/src/index.ts index e78979c..6c6ba69 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"; @@ -22,16 +24,32 @@ 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"; +/** + * Monarch types namespace for convenient access. + * + * @example + * ```ts + * import { m } from 'monarch-orm'; + * + * const UserSchema = createSchema('users', { + * name: m.string(), + * age: m.number().optional(), + * }); + * ``` + */ export const m = { array, boolean, + binary, date, dateString, + decimal128, createdAt, updatedAt, literal, + long, mixed, number, object, @@ -40,6 +58,7 @@ export const m = { record, string, taggedUnion, + type, tuple, union, nullable, diff --git a/src/operators/index.ts b/src/operators/index.ts index 56c9705..89ef490 100644 --- a/src/operators/index.ts +++ b/src/operators/index.ts @@ -2,53 +2,150 @@ 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. + * + * @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. + * + * @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. + * + * @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. + * + * @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. + * + * @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. + * + * @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. + * + * @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. + * + * @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. + * + * @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. + * + * @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. + * + * @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. + * + * @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..e60c7f3 100644 --- a/src/relations/relations.ts +++ b/src/relations/relations.ts @@ -3,6 +3,10 @@ import type { SchemaRelatableField } from "./type-helpers"; export type AnyRelation = Relation<"one" | "many" | "ref", any, any, any, any>; +/** + * Defines a relationship between two schemas. + * + */ export type Relation< TRelation extends "one" | "many" | "ref", TSchema extends AnySchema, @@ -19,13 +23,35 @@ export type Relation< export type AnyRelations = Record; +/** + * Container for schema relationships. + * + */ export class Relations { + /** + * Creates a Relations instance. + * + * @param name - Schema name + * @param relations - Relation definitions + */ constructor( public name: TName, public relations: TRelations, ) {} } +/** + * Creates relationship definitions for a schema. + * + * Provides three relation types: + * - `one`: One-to-one relationship + * - `many`: One-to-many relationship + * - `ref`: Reference relationship + * + * @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 +84,40 @@ 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 +131,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 463f524..bae4067 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -13,12 +13,23 @@ type SchemaOmit> = { export type AnySchema = Schema; +/** + * Defines the structure and behavior of a MongoDB collection. + * + */ 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,43 +43,104 @@ export class Schema< if (!_types._id) this._types._id = objectId().optional(); } + /** + * Specifies fields to omit from query output. + * + * @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. + * + * @param virtuals - Object defining virtual fields + * @returns Schema instance with virtual fields configured + */ + 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; + } + + /** + * Retrieves the field type definitions from a schema. + * + * @param schema - Schema instance + * @returns Field type definitions + */ 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; + /** + * Parses and validates input data according to schema type definitions. + * + * @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 - 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( + /** + * Transforms database data to output format with virtual fields and projections. + * + * @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, 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,65 +151,37 @@ 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; } + /** + * 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(); - } - 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; - } } +/** + * Creates a schema definition for a MongoDB collection. + * + * @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..e26fb03 100644 --- a/src/schema/virtuals.ts +++ b/src/schema/virtuals.ts @@ -14,11 +14,24 @@ type Props, P extends keyof T> = { [K in keyof T as K extends P ? K : never]: InferTypeOutput; } & {}; +/** + * Defines a virtual computed 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. + * + * @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/array.ts b/src/types/array.ts index a65dc04..bfe223f 100644 --- a/src/types/array.ts +++ b/src/types/array.ts @@ -2,27 +2,100 @@ 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; + 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; + } + + /** + * Validates minimum array length. + * + * @param length - Minimum length + * @returns MonarchArray with length validation + */ + 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; + }, + }); + } + + /** + * Validates maximum array length. + * + * @param length - Maximum length + * @returns MonarchArray with length validation + */ + 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; + }, + }); + } + + /** + * Validates exact array length. + * + * @param length - Exact length + * @returns MonarchArray with length validation + */ + 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; + }, + }); + } + + /** + * Validates array is not empty. + * + * @returns MonarchArray with non-empty validation + */ + public nonempty() { + return this.min(1); } } diff --git a/src/types/binary.ts b/src/types/binary.ts new file mode 100644 index 0000000..05866b4 --- /dev/null +++ b/src/types/binary.ts @@ -0,0 +1,23 @@ +import { Binary } from "mongodb"; +import { MonarchParseError } from "../errors"; +import { MonarchType } from "./type"; + +/** + * Binary type. + * + * @returns MonarchBinary instance + */ +export const binary = () => new MonarchBinary(); + +/** + * Type for Binary fields. + */ +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/boolean.ts b/src/types/boolean.ts index 4105ac7..796a4b1 100644 --- a/src/types/boolean.ts +++ b/src/types/boolean.ts @@ -1,8 +1,16 @@ import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; +/** + * Boolean type. + * + * @returns MonarchBoolean instance + */ export const boolean = () => new MonarchBoolean(); +/** + * Type for boolean fields. + */ export class MonarchBoolean extends MonarchType { constructor() { super((input) => { diff --git a/src/types/date.ts b/src/types/date.ts index d49c249..d1fc5ff 100644 --- a/src/types/date.ts +++ b/src/types/date.ts @@ -1,8 +1,16 @@ import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; +/** + * Date type. + * + * @returns MonarchDate instance + */ export const date = () => new MonarchDate(); +/** + * Type for Date fields. + */ export class MonarchDate extends MonarchType { constructor() { super((input) => { @@ -11,19 +19,33 @@ export class MonarchDate extends MonarchType { }); } - public after(afterDate: Date) { + /** + * 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, { - preParse: (input) => { - if (input > afterDate) return input; - throw new MonarchParseError(`date must be after ${afterDate}`); + parse: (input) => { + if (input <= targetDate) { + throw new MonarchParseError(`date must be after ${targetDate.toISOString()}`); + } + return input; }, }); } + /** + * 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, { - preParse: (input) => { - if (input > targetDate) { + parse: (input) => { + if (input >= targetDate) { throw new MonarchParseError(`date must be before ${targetDate.toISOString()}`); } return input; @@ -32,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()); }; +/** + * Date string type that accepts ISO date strings. + * + * @returns MonarchDateString instance + */ export const dateString = () => new MonarchDateString(); +/** + * Type for ISO date string fields. + */ export class MonarchDateString extends MonarchType { constructor() { super((input) => { @@ -51,21 +91,33 @@ export class MonarchDateString extends MonarchType { }); } - public after(afterDate: Date) { + /** + * 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, { - preParse: (input) => { - const date = new Date(input); - if (date > afterDate) return input; - throw new MonarchParseError(`date must be after ${afterDate}`); + parse: (input) => { + if (input <= targetDate) { + throw new MonarchParseError(`date must be after ${targetDate.toISOString()}`); + } + return input; }, }); } + /** + * 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, { - 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..b4fc62f --- /dev/null +++ b/src/types/decimal128.ts @@ -0,0 +1,23 @@ +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) => { + 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/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 new file mode 100644 index 0000000..13206cc --- /dev/null +++ b/src/types/long.ts @@ -0,0 +1,30 @@ +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) => { + 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/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 e633920..cfd3e43 100644 --- a/src/types/number.ts +++ b/src/types/number.ts @@ -1,8 +1,16 @@ import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; +/** + * Number type. + * + * @returns MonarchNumber instance + */ export const number = () => new MonarchNumber(); +/** + * Type for number fields. + */ export class MonarchNumber extends MonarchType { constructor() { super((input) => { @@ -11,9 +19,15 @@ 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, { - preParse: (input) => { + parse: (input) => { if (input < value) { throw new MonarchParseError(`number must be greater than or equal to ${value}`); } @@ -22,9 +36,15 @@ 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, { - preParse: (input) => { + parse: (input) => { if (input > value) { throw new MonarchParseError(`number must be less than or equal to ${value}`); } @@ -33,19 +53,16 @@ export class MonarchNumber extends MonarchType { }); } + /** + * Validates value is an integer. + * + * @returns MonarchNumber with integer validation + */ 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/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 8cf5a87..7ae5f69 100644 --- a/src/types/string.ts +++ b/src/types/string.ts @@ -1,8 +1,16 @@ import { MonarchParseError } from "../errors"; import { MonarchType } from "./type"; +/** + * String type. + * + * @returns MonarchString instance + */ export const string = () => new MonarchString(); +/** + * Type for string fields. + */ export class MonarchString extends MonarchType { constructor() { super((input) => { @@ -11,21 +19,48 @@ 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, { - postParse: (input) => input.toLowerCase(), + parse: (input) => input.toLowerCase(), }); } + /** + * Converts string to uppercase. + * + * @returns MonarchString with uppercase transformation + */ public uppercase() { return string().extend(this, { - postParse: (input) => input.toUpperCase(), + 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, { - postParse: (input) => { + parse: (input) => { if (input.length < length) { throw new MonarchParseError(`string must be at least ${length} characters long`); } @@ -34,9 +69,15 @@ 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, { - postParse: (input) => { + parse: (input) => { if (input.length > length) { throw new MonarchParseError(`string must be at most ${length} characters long`); } @@ -45,9 +86,15 @@ 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, { - postParse: (input) => { + parse: (input) => { if (input.length !== length) { throw new MonarchParseError(`string must be exactly ${length} characters long`); } @@ -56,9 +103,15 @@ 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, { - postParse: (input) => { + parse: (input) => { if (!regex.test(input)) { throw new MonarchParseError(`string must match pattern ${regex}`); } @@ -67,15 +120,14 @@ export class MonarchString extends MonarchType { }); } - public trim() { - return string().extend(this, { - postParse: (input) => input.trim(), - }); - } - - public nonEmpty() { + /** + * Validates string is not empty. + * + * @returns MonarchString with non-empty validation + */ + public nonempty() { return string().extend(this, { - preParse: (input) => { + parse: (input) => { if (input.length === 0) { throw new MonarchParseError("string must not be empty"); } @@ -84,9 +136,15 @@ 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, { - preParse: (input) => { + parse: (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/tuple.ts b/src/types/tuple.ts index 8adf029..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 @@ -13,7 +22,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/src/types/type.ts b/src/types/type.ts index 0398060..f058752 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,50 +20,100 @@ export function pipeParser( return (input) => nextParser(prevParser(input)); } -export const type = (parser: Parser, updater?: Parser) => - new MonarchType(parser, updater); +/** + * Creates a MonarchType with custom parser. + * + * @param parser - Parser function + * @returns MonarchType instance + */ +export const type = (parser: Parser) => new MonarchType(parser); export type AnyMonarchType = MonarchType; -export class MonarchType { +/** + * Base class for all Monarch types. + */ +export class MonarchType { constructor( - private _parser: Parser, - private _updater?: Parser, + 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; + 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; + return type.updater; } - public static isInstanceOf AnyMonarchType>( + /** + * 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, ): 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; } + /** + * 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)); } + /** + * 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)); + return new MonarchType(this.parser, pipeParser(updateFn, this.parser)); } /** @@ -63,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)); } /** @@ -74,20 +134,20 @@ export class MonarchType { * @param message error message when validation fails. */ public validate(fn: (input: TOutput) => boolean, message: string) { - return type( - pipeParser(this._parser, (input) => { + return new MonarchType( + 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. @@ -96,21 +156,30 @@ 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; } } +/** + * 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 @@ -130,8 +199,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 @@ -151,11 +229,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 ab04294..deffb90 100644 --- a/src/types/union.ts +++ b/src/types/union.ts @@ -1,9 +1,23 @@ 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"; +/** + * 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 @@ -28,3 +42,55 @@ 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 +> { + 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/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++) { 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(); -}; diff --git a/tests/operators.test.ts b/tests/operators.test.ts index 19232cc..1d68169 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,149 +33,116 @@ describe("Query operators", async () => { }); it("and operator", async () => { - await collections.users.insertMany(mockUsers).exec(); - const users = await collections.users - .find( - and( - { - name: "anon", - }, - { - age: 17, - }, - ), - ) - .exec(); - + await collections.users.insertMany(mockUsers); + 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).exec(); - const users = await collections.users - .find( - or( - { - name: "anon", - }, - { - name: "anon1", - }, - ), - ) - .exec(); - + await collections.users.insertMany(mockUsers); + 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).exec(); - const users = await collections.users - .find( - nor( - { - name: "anon", - }, - { - name: "anon1", - }, - ), - ) - .exec(); - + await collections.users.insertMany(mockUsers); + 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).exec(); - const users = await collections.users - .find({ - name: eq("anon1"), - }) - .exec(); - + await collections.users.insertMany(mockUsers); + const users = await collections.users.find({ + name: eq("anon1"), + }); expect(users.length).toBe(1); }); it("ne operator", async () => { - await collections.users.insertMany(mockUsers).exec(); - const users = await collections.users - .find({ - name: neq("anon1"), - }) - .exec(); - + await collections.users.insertMany(mockUsers); + 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).exec(); - const users = await collections.users - .find({ - age: gt(17), - }) - .exec(); - + await collections.users.insertMany(mockUsers); + 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).exec(); - const users = await collections.users - .find({ - age: gte(17), - }) - .exec(); - + await collections.users.insertMany(mockUsers); + 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).exec(); - const users = await collections.users - .find({ - age: lt(17), - }) - .exec(); - + await collections.users.insertMany(mockUsers); + 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).exec(); - const users = await collections.users - .find({ - age: lte(17), - }) - .exec(); - + await collections.users.insertMany(mockUsers); + const users = await collections.users.find({ + age: lte(17), + }); expect(users.length).toBe(mockUsers.filter((user) => user.age <= 17).length); }); it("in operator", async () => { const ageArray = [17]; - await collections.users.insertMany(mockUsers).exec(); - const users = await collections.users - .find({ - age: inArray(ageArray), - }) - .exec(); - + await collections.users.insertMany(mockUsers); + const users = await collections.users.find({ + age: inArray(ageArray), + }); expect(users.length).toBe(mockUsers.filter((user) => ageArray.includes(user.age)).length); }); it("nin operator", async () => { const ageArray = [17, 20, 25]; - await collections.users.insertMany(mockUsers).exec(); - const users = await collections.users - .find({ - age: notInArray([17, 20, 25]), - // age: 3 - }) - .exec(); - + await collections.users.insertMany(mockUsers); + 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.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..27b6e4d --- /dev/null +++ b/tests/query/aggregate.test.ts @@ -0,0 +1,47 @@ +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({}); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + it("aggregates data", async () => { + await collections.users.insertMany(mockUsers); + const result = await collections.users + .aggregate() + .addStage({ $match: { isVerified: true } }) + .addStage({ $group: { _id: "$isVerified", count: { $sum: 1 } } }); + 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..cc6347d --- /dev/null +++ b/tests/query/delete.test.ts @@ -0,0 +1,80 @@ +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({}); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + it("finds one and deletes", async () => { + 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]); + 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/insert-find.test.ts b/tests/query/insert-find.test.ts new file mode 100644 index 0000000..73dac97 --- /dev/null +++ b/tests/query/insert-find.test.ts @@ -0,0 +1,257 @@ +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({}); + await collections.todos.deleteMany({}); + }); + + 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]); + expect(newUser1).toMatchObject(mockUsers[0]); + expect(newUser1._id).toBeDefined(); + expect(newUser1._id).toBeInstanceOf(ObjectId); + + const newUser2 = await collections.users.insertOne(mockUsers[0]); + 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] }); + 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] }); + 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] }); + }).rejects.toThrowError("expected valid ObjectId received"); + }); + + it("inserts empty document with default values", async () => { + 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, + }); + 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); + expect(user).not.toBe(null); + expect(user).not.toHaveProperty("extraField"); + }); + + it("inserts many documents", async () => { + 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, + }, + }, + }, + { + insertOne: { + document: { + name: "bulk2", + email: "bulk2@gmail.com", + age: 23, + isVerified: true, + }, + }, + }, + ]); + expect(bulkWriteResult.insertedCount).toBe(2); + }); + }); + + describe("Find Operations", () => { + it("finds documents", async () => { + 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); + + 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]); + 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] }); + + 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] }); + + const user = await collections.users + //@ts-expect-error + .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 }); + + 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] }); + + 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] }); + + 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"); + }).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 }); + + 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 }); + + const todo = await collections.todos.findById(todoId + 1); + expect(todo).toBe(null); + }); + + it("gets distinct values", async () => { + 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); + const count = await collections.users.countDocuments(); + expect(count).toBeGreaterThanOrEqual(2); + }); + + it("estimatedDocumentCount", async () => { + 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 new file mode 100644 index 0000000..2886321 --- /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({}); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + it("queries with single where condition", async () => { + await collections.users.insertMany(mockUsers); + + 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); + + 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); + + 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 + expect(users[0].age).toBeUndefined(); + // @ts-expect-error + expect(users[0].isVerified).toBeUndefined(); + }); + + it("omits specific fields", async () => { + await collections.users.insertMany(mockUsers); + + const users = await collections.users.find().omit({ name: true, email: true }); + // @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); + + const limit = 2; + const users = await collections.users.find().limit(limit); + expect(users.length).toBe(limit); + }); + + it("skips query results", async () => { + await collections.users.insertMany(mockUsers); + + const skip = 2; + 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); + + 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); + + 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 new file mode 100644 index 0000000..5d3b34e --- /dev/null +++ b/tests/query/update-hooks.test.ts @@ -0,0 +1,471 @@ +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, + }); + const doc = await db.collections.users.findOne({ _id: res._id }); + 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", + }); + 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, + }); + 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", + }); + 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, + }); + 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", + }); + 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", + }); + 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", + }); + 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, + }); + 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", + }); + 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", + }); + 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", + }); + 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, + }); + 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", + }); + 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, + }); + 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", + }); + 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, + }); + 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", + }); + 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, + }); + 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", + }); + 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, + }); + 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", + }); + 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, + }); + 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", + }); + 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, + }); + 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", + }); + 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..7eebf61 --- /dev/null +++ b/tests/query/update.test.ts @@ -0,0 +1,207 @@ +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({}); + }); + + afterAll(async () => { + await client.close(); + await server.stop(); + }); + + it("finds one and updates", async () => { + await collections.users.insertOne(mockUsers[0]); + + const updatedUser = await collections.users + .findOneAndUpdate( + { email: "anon@gmail.com" }, + { + $set: { + age: 30, + }, + }, + ) + .options({ + returnDocument: "after", + }); + + expect(updatedUser).not.toBe(null); + expect(updatedUser?.age).toBe(30); + }); + + it("updates one document", async () => { + 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); + 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]); + const replaced = await collections.users.replaceOne( + { email: "anon@gmail.com" }, + { + ...original, + name: "New Name", + }, + ); + 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", { + name: string(), + age: number().onUpdate(() => 999), + }); + const db = createDatabase(client.db(), { users: schema }); + + 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); + 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 }); + expect(updatedUser1?.name).toBe("Updated"); + expect(updatedUser1?.age).toBe(999); + + const updatedUser2 = await db.collections.users.findOne({ _id: user2._id }); + 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 }); + 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); + await db.collections.users.updateMany({ age: { $gte: 30 } }, updateObj); + + // Verify all users were updated + const users = await db.collections.users.find({}); + 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 }); + 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" }); + 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 }); + expect(updatedUser1?.name).toBe("Updated"); + expect(updatedUser1?.age).toBe(777); + + const updatedUser2 = await db.collections.users.findOne({ _id: user2._id }); + 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..ae34946 --- /dev/null +++ b/tests/relations/many.test.ts @@ -0,0 +1,163 @@ +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(), + }); + 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 }); + 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(), + }); + 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 }); + 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(), + }); + 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); + 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..7706e63 --- /dev/null +++ b/tests/relations/one.test.ts @@ -0,0 +1,184 @@ +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(), + }); + 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({ + ...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(), + }); + await collections.posts.insertOne({ + title: "Pilot", + contents: "Lorem", + author: user._id, + }); + + const populatedPost = await collections.posts + .findOne({ + title: "Pilot", + }) + .populate({ author: true }); + 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(), + }); + 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, + }); + // 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, + }, + }, + }); + // 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..404ba69 --- /dev/null +++ b/tests/relations/population-options.test.ts @@ -0,0 +1,192 @@ +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(), + }); + 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"); + }); + + 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, + }); + 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(), + }); + 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"); + 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(), + }); + 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"); + 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(), + }); + 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"); + 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..4201423 --- /dev/null +++ b/tests/relations/ref.test.ts @@ -0,0 +1,218 @@ +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, + }); + 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 }); + + 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(), + }); + 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 }); + + 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(), + }); + 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, + }); + 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..9105b3b --- /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 }); + }).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 }); + }).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 55% rename from tests/schema-options.test.ts rename to tests/schema/schema.test.ts index bbab9f8..24b5491 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 () => { @@ -28,15 +28,13 @@ describe("Schema options", async () => { isAdmin: true, }); const db = createDatabase(client.db(), { users: schema }); - const res = await db.collections.users - .insertOne({ - name: "tom", - age: 0, - isAdmin: true, - }) - .exec(); + 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 }).exec(); + const doc = await db.collections.users.findOne({ _id: res._id }); expect(doc).toStrictEqual({ _id: res._id, name: "tom", age: 0 }); }); @@ -49,14 +47,12 @@ describe("Schema options", 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, - }) - .exec(); - const doc = await db.collections.users.findOne({ _id: res._id }).exec(); + 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, name: "tom cruise", @@ -80,14 +76,12 @@ describe("Schema options", 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, - }) - .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", @@ -109,33 +103,31 @@ describe("Schema options", 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, - }) - .exec(); + const res = await db.collections.users.insertOne({ + name: "tom", + age: 0, + isAdmin: true, + }); 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", @@ -152,15 +144,13 @@ describe("Schema options", 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, - }) - .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, + role: 1, + }); + const doc = await db.collections.users.findOne({ _id: res._id }); expect(doc).toStrictEqual({ _id: res._id, name: "tom", @@ -183,43 +173,108 @@ describe("Schema options", 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, - }) - .exec(); - await expect(async () => { - await db.collections.users - .insertOne({ - firstname: "bobby", - surname: "paul", - username: "bobpaul", - age: 0, - }) - .exec(); - }).rejects.toThrow("E11000 duplicate key error"); + }); + }).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, - }) - .exec(); - await expect(async () => { - await db.collections.users - .insertOne({ - firstname: "alice", - surname: "wonder", - username: "allywon", - 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, + }); + expect(product).toStrictEqual({ + _id: "product-123", + name: "Laptop", + price: 999, + }); + + const foundProduct = await db.collections.products.findById("product-123"); + 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, + }); + expect(order).toStrictEqual({ + _id: 12345, + customerId: "cust-001", + total: 150.5, + }); + + const foundOrder = await db.collections.orders.findById(12345); + 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 7d4e965..0000000 --- a/tests/types.test.ts +++ /dev/null @@ -1,590 +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( - "element at index '0' expected 'number' received 'undefined'", - ); - 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}`, - ); - expect(() => Schema.toData(schema, { afterDate: future, beforeDate: future })).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}`); - - expect(() => - Schema.toData(schema, { - afterDate: future.toISOString(), - beforeDate: future.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..76876d0 --- /dev/null +++ b/tests/types/binary.test.ts @@ -0,0 +1,102 @@ +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({}); + }); + + test("accepts Buffer and returns Binary on insert", async () => { + const testBuffer = Buffer.from("hello world"); + + const inserted = await collections.bsonData.insertOne({ + binaryField: testBuffer, + }); + expect(inserted.binaryField).toBeInstanceOf(Binary); + expect(inserted.binaryField!.buffer.toString()).toBe("hello world"); + + 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"); + 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, + }); + expect(inserted.binaryField).toBeInstanceOf(Binary); + expect(inserted.binaryField!.buffer.toString()).toBe("binary data"); + + 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"); + 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..70d50f0 --- /dev/null +++ b/tests/types/decimal128.test.ts @@ -0,0 +1,137 @@ +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({}); + }); + + test("accepts Decimal128 and returns Decimal128", async () => { + const testDecimal = Decimal128.fromString("123456789.123456789123456789"); + + const inserted = await collections.bsonData.insertOne({ + decimalField: testDecimal, + }); + expect(inserted.decimalField).toBeInstanceOf(Decimal128); + expect(inserted.decimalField!.toString()).toBe("123456789.123456789123456789"); + + 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"); + expect(retrieved!.decimalField).toEqual(inserted.decimalField); + }); + + test("accepts string and returns Decimal128", async () => { + const inserted = await collections.bsonData.insertOne({ + decimalField: "999.999999", + }); + expect(inserted.decimalField).toBeInstanceOf(Decimal128); + expect(inserted.decimalField!.toString()).toBe("999.999999"); + + 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"); + expect(retrieved!.decimalField).toEqual(inserted.decimalField); + }); + + test("handles high precision decimals", async () => { + const highPrecision = "99999999999999.999999999999999999"; + const inserted = await collections.bsonData.insertOne({ + decimalField: highPrecision, + }); + expect(inserted.decimalField).toBeInstanceOf(Decimal128); + expect(inserted.decimalField!.toString()).toBe(highPrecision); + + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }); + 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..da2101a --- /dev/null +++ b/tests/types/long.test.ts @@ -0,0 +1,138 @@ +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({}); + }); + + test("accepts Long (large value) and returns Long", async () => { + const testLong = Long.fromString("9223372036854775807"); + + const inserted = await collections.bsonData.insertOne({ + longField: testLong, + }); + expect(Long.isLong(inserted.longField)).toBe(true); + expect((inserted.longField as Long).toString()).toBe("9223372036854775807"); + + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }); + 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, + }); + expect(typeof inserted.longField).toBe("number"); + expect(inserted.longField).toBe(123456789); + + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }); + 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"), + }); + expect(Long.isLong(inserted.longField)).toBe(true); + expect((inserted.longField as Long).toString()).toBe("9223372036854775807"); + + const retrieved = await collections.bsonData.findOne({ _id: inserted._id }); + 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..d44cd94 --- /dev/null +++ b/tests/types/objectid.test.ts @@ -0,0 +1,111 @@ +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(), + }); + + 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({}); + }); + + test("accepts ObjectId and returns ObjectId", async () => { + const testId = new ObjectId(); + + const inserted = await collections.testData.insertOne({ + refId: testId, + }); + expect(inserted.refId).toBeInstanceOf(ObjectId); + expect(inserted.refId?.toString()).toBe(testId.toString()); + + 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()); + 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, + }); + expect(inserted.refId).toBeInstanceOf(ObjectId); + expect(inserted.refId?.toString()).toBe(validId); + + const retrieved = await collections.testData.findOne({ _id: inserted._id }); + 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/todo.md b/todo.md index e9d018e..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,9 +19,35 @@ Here are some features we need to implement. - [] events like on save, on create and more +### API Improvements + +- [] Add batch operations helper methods + +### 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 + +- [] Document population mechanism in detail +- [] 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 ### 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