diff --git a/deno.json b/deno.json index fb475a3..8c977ae 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@valkyr/db", - "version": "2.1.0", + "version": "3.0.0", "exports": { ".": "./src/mod.ts" }, @@ -24,7 +24,7 @@ }, "test": { - "command": "deno test --allow-all ./src", + "command": "deno test --allow-all", "description": "Run all tests using Deno’s built-in test runner." }, diff --git a/deno.lock b/deno.lock index 14c69b2..e592048 100644 --- a/deno.lock +++ b/deno.lock @@ -7,11 +7,11 @@ "npm:@jsr/std__testing@1": "1.0.16", "npm:@jsr/valkyr__event-emitter@1.0.1": "1.0.1", "npm:@jsr/valkyr__testcontainers@2": "2.0.2", + "npm:@types/node@*": "24.2.0", "npm:expect@30.2.0": "30.2.0", "npm:fake-indexeddb@6.2.5": "6.2.5", "npm:idb@8.0.3": "8.0.3", "npm:mingo@7.1.1": "7.1.1", - "npm:sorted-btree@2.1.0": "2.1.0", "npm:zod@4.3.4": "4.3.4" }, "npm": { @@ -428,9 +428,6 @@ "slash@3.0.0": { "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==" }, - "sorted-btree@2.1.0": { - "integrity": "sha512-AtYXy3lL+5jrATpbymC2bM8anN/3maLkmVCd94MzypnKjokfCid/zeS3rvXedv7W6ffSfqKIGdz3UaJPWRBZ0g==" - }, "sparse-bitfield@3.0.3": { "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", "dependencies": [ @@ -491,7 +488,6 @@ "npm:fake-indexeddb@6.2.5", "npm:idb@8.0.3", "npm:mingo@7.1.1", - "npm:sorted-btree@2.1.0", "npm:zod@4.3.4" ] } diff --git a/src/collection.ts b/src/collection.ts index a111939..d397491 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -4,10 +4,10 @@ import type { Modifier } from "mingo/updater"; import type { ZodObject, ZodRawShape } from "zod"; import z from "zod"; +import type { IndexSpec } from "./index/manager.ts"; import { observe, observeOne } from "./observe/mod.ts"; -import type { Index } from "./registrars.ts"; import type { ChangeEvent, QueryOptions, Storage, UpdateResult } from "./storage.ts"; -import type { AnyDocument } from "./types.ts"; +import type { AnyDocument, QueryCriteria } from "./types.ts"; /* |-------------------------------------------------------------------------------- @@ -44,8 +44,8 @@ export class Collection< get primaryKey(): string { for (const index of this.options.indexes ?? []) { - if (index[1]?.primary === true) { - return index[0] as string; + if (index.kind === "primary") { + return index.field; } } throw new Error(`Collection '${this.name}' is missing required primary key assignment.`); @@ -57,7 +57,7 @@ export class Collection< |-------------------------------------------------------------------------------- */ - getPrimaryKeyValue(document: AnyDocument): string | number { + getPrimaryKeyValue(document: AnyDocument): string { const id = document[this.#pkey]; if (id === undefined || typeof id !== "string") { throw new Error( @@ -74,7 +74,9 @@ export class Collection< */ async insert(documents: TSchema[]): Promise { - return this.storage.resolve().then((storage) => storage.insert(documents)); + return this.storage + .resolve() + .then((storage) => storage.insert(documents.map((document) => this.#schema.parse(document)))); } async update( @@ -122,7 +124,7 @@ export class Collection< * Performs a mingo filter search over the collection data and returns * a single document if one was found matching the filter and options. */ - async findOne(condition: Criteria = {}, options: QueryOptions = {}): Promise { + async findOne(condition: QueryCriteria = {}, options: QueryOptions = {}): Promise { return this.findMany(condition, { ...options, limit: 1 }).then(([document]) => document); } @@ -130,7 +132,7 @@ export class Collection< * Performs a mingo filter search over the collection data and returns any * documents matching the provided filter and options. */ - async findMany(condition: Criteria = {}, options?: QueryOptions): Promise { + async findMany(condition: QueryCriteria = {}, options?: QueryOptions): Promise { return this.storage .resolve() .then((storage) => @@ -144,17 +146,17 @@ export class Collection< * Performs a mingo filter search over the collection data and returns * the count of all documents found matching the filter and options. */ - async count(condition?: Criteria): Promise { - return this.storage.resolve().then((storage) => storage.count({ collection: this.options.name, condition })); + async count(condition: Criteria = {}): Promise { + return this.storage.resolve().then((storage) => storage.count(condition)); } /** * Removes all documents from the storage instance. */ - flush(): void { - this.storage.resolve().then((storage) => { + async flush(): Promise { + await this.storage.resolve().then(async (storage) => { + await storage.flush(); storage.broadcast("flush"); - storage.flush(); }); } @@ -216,5 +218,5 @@ type CollectionOptions[]; }; diff --git a/src/databases/indexeddb/database.ts b/src/databases/indexeddb/database.ts index 041eed9..ceda80a 100644 --- a/src/databases/indexeddb/database.ts +++ b/src/databases/indexeddb/database.ts @@ -1,8 +1,9 @@ import { type IDBPDatabase, openDB } from "idb"; import { Collection } from "../../collection.ts"; +import type { IndexSpec } from "../../index/manager.ts"; import type { DBLogger } from "../../logger.ts"; -import type { Index, Registrars } from "../../registrars.ts"; +import type { Registrars } from "../../registrars.ts"; import { IndexedDBStorage } from "./storage.ts"; export class IndexedDB { @@ -12,10 +13,22 @@ export class IndexedDB { constructor(readonly options: TOptions) { this.#db = openDB(options.name, options.version ?? 1, { upgrade: (db: IDBPDatabase) => { - for (const { name, indexes = [] } of options.registrars) { - const store = db.createObjectStore(name); - for (const [keyPath, options] of indexes) { - store.createIndex(keyPath, keyPath, options); + for (const { name, indexes } of options.registrars) { + const store = db.createObjectStore(name, { + keyPath: indexes.find((index: IndexSpec) => index.kind === "primary")?.field, + }); + for (const { field, kind } of indexes) { + switch (kind) { + case "primary": + case "unique": { + store.createIndex(field, field, { unique: true }); + break; + } + case "shared": { + store.createIndex(field, field); + break; + } + } } } }, @@ -48,7 +61,7 @@ export class IndexedDB { name: TName; storage: IndexedDBStorage; schema: TSchema; - indexes: Index[]; + indexes: IndexSpec[]; }> { const collection = this.#collections.get(name); if (collection === undefined) { @@ -73,8 +86,8 @@ export class IndexedDB { } } - close() { - this.#db.then((db) => db.close()); + async close() { + await this.#db.then((db) => db.close()); } } diff --git a/src/databases/indexeddb/storage.ts b/src/databases/indexeddb/storage.ts index 48692e0..90353aa 100644 --- a/src/databases/indexeddb/storage.ts +++ b/src/databases/indexeddb/storage.ts @@ -3,8 +3,8 @@ import { Query, update } from "mingo"; import type { Criteria } from "mingo/types"; import type { Modifier } from "mingo/updater"; +import type { IndexSpec } from "../../index/manager.ts"; import { type DBLogger, InsertLog, QueryLog, RemoveLog, UpdateLog } from "../../logger.ts"; -import type { Index } from "../../registrars.ts"; import { addOptions, type QueryOptions, Storage, type UpdateResult } from "../../storage.ts"; import type { AnyDocument } from "../../types.ts"; import { IndexedDBCache } from "./cache.ts"; @@ -21,7 +21,7 @@ export class IndexedDBStorage extends constructor( name: string, - indexes: Index[], + indexes: IndexSpec[], promise: Promise, readonly log: DBLogger = function log() {}, ) { @@ -29,21 +29,6 @@ export class IndexedDBStorage extends this.#promise = promise; } - async resolve() { - if (this.#db === undefined) { - this.#db = await this.#promise; - } - return this; - } - - async has(id: string): Promise { - const document = await this.db.getFromIndex(this.name, "id", id); - if (document !== undefined) { - return true; - } - return false; - } - get db() { if (this.#db === undefined) { throw new Error("Database not initialized"); @@ -51,6 +36,13 @@ export class IndexedDBStorage extends return this.#db; } + async resolve() { + if (this.#db === undefined) { + this.#db = await this.#promise; + } + return this; + } + /* |-------------------------------------------------------------------------------- | Insert diff --git a/src/databases/memory/database.ts b/src/databases/memory/database.ts index 2b64967..2baee89 100644 --- a/src/databases/memory/database.ts +++ b/src/databases/memory/database.ts @@ -1,5 +1,6 @@ import { Collection } from "../../collection.ts"; -import type { Index, Registrars } from "../../registrars.ts"; +import type { IndexSpec } from "../../index/manager.ts"; +import type { Registrars } from "../../registrars.ts"; import { MemoryStorage } from "./storage.ts"; export class MemoryDatabase { @@ -42,7 +43,7 @@ export class MemoryDatabase { name: TName; storage: MemoryStorage; schema: TSchema; - indexes: Index[]; + indexes: IndexSpec[]; }> { const collection = this.#collections.get(name); if (collection === undefined) { diff --git a/src/databases/memory/storage.ts b/src/databases/memory/storage.ts index dfa8b74..ca25d43 100644 --- a/src/databases/memory/storage.ts +++ b/src/databases/memory/storage.ts @@ -5,18 +5,18 @@ import type { Modifier } from "mingo/updater"; import { IndexManager, type IndexSpec } from "../../index/manager.ts"; import type { UpdateResult } from "../../storage.ts"; import { addOptions, type QueryOptions, Storage } from "../../storage.ts"; -import type { AnyDocument } from "../../types.ts"; +import type { AnyDocument, StringKeyOf } from "../../types.ts"; export class MemoryStorage extends Storage { readonly index: IndexManager; - constructor(name: string, indexes: IndexSpec[]) { + constructor(name: string, indexes: IndexSpec[]) { super(name, indexes); this.index = new IndexManager(indexes); } get documents() { - return this.index.primary.tree; + return this.index.primary.documents; } async resolve() { @@ -30,14 +30,14 @@ export class MemoryStorage extends St this.broadcast("insert", documents); } - async getByIndex(index: string, value: string): Promise { - return this.index.get(index)?.get(value) ?? []; + async getByIndex(field: StringKeyOf, value: string): Promise { + return this.index.getByIndex(field, value); } async find(condition: Criteria = {}, options?: QueryOptions): Promise { - let cursor = new Query(condition).find(this.documents); + const cursor = new Query(condition).find(this.documents); if (options !== undefined) { - cursor = addOptions(cursor, options); + return addOptions(cursor, options).all(); } return cursor.all(); } @@ -58,7 +58,7 @@ export class MemoryStorage extends St if (modified.length > 0) { modifiedCount += 1; documents.push(document); - this.documents.add(document); + this.index.update(document); } } @@ -72,7 +72,7 @@ export class MemoryStorage extends St async remove(condition: Criteria): Promise { const documents = await this.find(condition); for (const document of documents) { - this.documents.delete(document); + this.index.remove(document); } this.broadcast("remove", documents); return documents.length; @@ -83,6 +83,6 @@ export class MemoryStorage extends St } async flush(): Promise { - this.documents.clear(); + this.index.flush(); } } diff --git a/src/databases/memory/tests/storage.test.ts b/src/databases/memory/tests/storage.test.ts deleted file mode 100644 index a04b52c..0000000 --- a/src/databases/memory/tests/storage.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "expect"; - -import { MemoryStorage } from "../storage.ts"; - -/* - |-------------------------------------------------------------------------------- - | Unit Tests - |-------------------------------------------------------------------------------- - */ - -describe("Memory Storage", () => { - it("should insert new records", async () => { - const storage = new MemoryStorage("test", [["id", { primary: true }]]); - - const documents = [ - { - id: "abc", - foo: "bar", - }, - ]; - - await storage.insert(documents); - - console.log(storage); - - expect(storage.documents).toContain(documents); - }); -}); diff --git a/src/index/manager.ts b/src/index/manager.ts index 91e65c9..bf517e3 100644 --- a/src/index/manager.ts +++ b/src/index/manager.ts @@ -1,17 +1,19 @@ import type { Criteria } from "mingo/types"; -import type { AnyDocument } from "../types.ts"; -import { PrimaryIndex } from "./primary.ts"; +import type { AnyDocument, StringKeyOf } from "../types.ts"; +import { PrimaryIndex, type PrimaryKey } from "./primary.ts"; import { SharedIndex } from "./shared.ts"; import { UniqueIndex } from "./unique.ts"; +const EMPTY_SET: ReadonlySet = Object.freeze(new Set()); + export class IndexManager { readonly primary: PrimaryIndex; - readonly unique = new Map(); - readonly shared = new Map(); + readonly unique = new Map, UniqueIndex>(); + readonly shared = new Map, SharedIndex>(); - constructor(specs: IndexSpec[]) { + constructor(readonly specs: IndexSpec[]) { const primary = specs.find((spec) => spec.kind === "primary"); if (primary === undefined) { throw new Error("Primary index is required"); @@ -31,43 +33,165 @@ export class IndexManager { } } + /** + * Atomic insert of the document into the index pools. If any part + * of the operation fails all changes are rolled back to their original + * states. + * + * @param document - Document to insert. + */ insert(document: TSchema) { const pk = document[this.primary.key]; - for (const [field, index] of this.unique) { - index.insert(document[field], pk); + + const insertedUniques: [StringKeyOf, any][] = []; + const insertedShared: [StringKeyOf, any][] = []; + + try { + for (const [field, index] of this.unique) { + index.insert(document[field], pk); + insertedUniques.push([field, document[field]]); + } + for (const [field, index] of this.shared) { + index.insert(document[field], pk); + insertedShared.push([field, document[field]]); + } + this.primary.insert(pk, document); + } catch (err) { + for (const [field, value] of insertedUniques) { + this.unique.get(field)?.delete(value); + } + for (const [field, value] of insertedShared) { + this.shared.get(field)?.delete(value, pk); + } + throw err; } - for (const [field, index] of this.shared) { - index.insert(document[field], pk); - } - this.primary.insert(pk, document); } - getByCondition(condition: Criteria): TSchema[] | undefined { - // const pks = new Set(); - // for (const key in condition) { - // if (this.indexes.includes(key)) { - // if (key === this.primaryKey) { - // pks.add(condition[key]); - // } else { - // const - // } - // } - // } - return []; + getByCondition(condition: Criteria): TSchema[] { + const indexedKeys = Array.from( + new Set([this.primary.key as StringKeyOf, ...this.unique.keys(), ...this.shared.keys()]), + ); + + const candidatePKs: PrimaryKey[] = []; + + // ### Primary Keys + // Collect primary keys for indexed equality conditions + + const pkSets: ReadonlySet[] = []; + + for (const key of indexedKeys) { + const value = (condition as any)[key]; + if (value !== undefined) { + // Use index if available + const pks = this.getPrimaryKeysByIndex(key, value); + pkSets.push(pks); + } + } + + // ### Intersect + // Intersect all sets to find candidates + + if (pkSets.length > 0) { + const sortedSets = pkSets.sort((a, b) => a.size - b.size); + const intersection = new Set(sortedSets[0]); + for (let i = 1; i < sortedSets.length; i++) { + for (const pk of intersection) { + if (!sortedSets[i].has(pk)) { + intersection.delete(pk); + } + } + } + candidatePKs.push(...intersection); + } else { + candidatePKs.push(...this.primary.keys()); // no indexed fields → scan all primary keys + } + + // ### Filter + // Filter candidates by remaining condition + + const results: TSchema[] = []; + for (const pk of candidatePKs) { + const doc = this.primary.get(pk); + if (doc === undefined) { + continue; + } + let match = true; + for (const [field, expected] of Object.entries(condition)) { + if ((doc as any)[field] !== expected) { + match = false; + break; + } + } + if (match) { + results.push(doc); + } + } + + return results; } + /** + * Get all primary keys found for given field => value pair. + * + * @param field - Field to lookup. + * @param value - Value to lookup. + */ + getPrimaryKeysByIndex(field: StringKeyOf, value: any): ReadonlySet { + if (field === this.primary.key) { + if (this.primary.has(value)) { + return new Set([value]); + } + return EMPTY_SET; + } + if (this.unique.has(field)) { + const pk = this.unique.get(field)?.lookup(value); + if (pk === undefined) { + return EMPTY_SET; + } + return new Set([pk]); + } + return this.shared.get(field)?.lookup(value) ?? EMPTY_SET; + } + + /** + * Get document by primary key. + * + * @param pk - Primary key to fetch document for. + */ getByPrimary(pk: string): TSchema | undefined { return this.primary.get(pk); } - getByUnique(field: keyof TSchema, value: any): TSchema | undefined { + /** + * Get a document found for given field => value pair. + * + * @param field - Field to lookup. + * @param value - Value to lookup. + */ + getByUnique(field: StringKeyOf, value: any): TSchema | undefined { const pk = this.unique.get(field)?.lookup(value); if (pk !== undefined) { return this.primary.get(pk); } } - getByIndex(field: keyof TSchema, value: any): TSchema[] { + /** + * Get all documents found for given field => value pair. + * + * @note This method may clean up stale index entries during reads. + * + * @param field - Field to lookup. + * @param value - Value to lookup. + */ + getByIndex(field: StringKeyOf, value: any): TSchema[] { + if (field === this.primary.key) { + const document = this.getByPrimary(value); + if (document === undefined) { + return []; + } + return [document]; + } + if (this.unique.has(field)) { const document = this.getByUnique(field, value); if (document === undefined) { @@ -94,23 +218,94 @@ export class IndexManager { return documents; } - remove(pk: string) { - const document = this.primary.get(pk); - if (document === undefined) { + /** + * Update indexes for given document. + * + * @note If the document does not exist it will be inserted. + * + * @param document - Document to update against current index. + */ + update(document: TSchema) { + const pk = document[this.primary.key]; + const current = this.primary.get(pk); + + if (current === undefined) { + return this.insert(document); + } + + const revertedUniques: [StringKeyOf, any][] = []; + const revertedShared: [StringKeyOf, any][] = []; + + try { + for (const [field, index] of this.unique) { + if (current[field] !== document[field]) { + index.delete(current[field]); + index.insert(document[field], pk); + revertedUniques.push([field, current[field]]); + } + } + for (const [field, index] of this.shared) { + if (current[field] !== document[field]) { + index.delete(current[field], pk); + index.insert(document[field], pk); + revertedShared.push([field, current[field]]); + } + } + this.primary.replace(pk, document); + } catch (err) { + for (const [field, value] of revertedUniques) { + this.unique.get(field)?.insert(value, pk); + this.unique.get(field)?.delete(document[field]); + } + for (const [field, value] of revertedShared) { + this.shared.get(field)?.insert(value, pk); + this.shared.get(field)?.delete(document[field], pk); + } + throw err; + } + } + + /** + * Remove all indexes related to given document. + * + * @param document - Document to remove. + */ + remove(document: TSchema) { + const pk = document[this.primary.key]; + const current = this.primary.get(pk); + if (current === undefined) { return; } for (const [field, index] of this.unique) { - index.delete(document[field]); + index.delete(current[field]); } for (const [field, index] of this.shared) { - index.delete(document[field], pk); + index.delete(current[field], pk); } this.primary.delete(pk); } + + flush() { + this.primary.flush(); + this.unique.clear(); + this.shared.clear(); + for (const spec of this.specs) { + switch (spec.kind) { + case "unique": { + this.unique.set(spec.field, new UniqueIndex()); + break; + } + case "shared": { + this.shared.set(spec.field, new SharedIndex()); + break; + } + } + } + } } -export type IndexSpec = { - field: string; +export type IndexSpec = { + field: StringKeyOf; kind: IndexKind; }; diff --git a/src/index/primary.ts b/src/index/primary.ts index 40ea39e..fe45e8d 100644 --- a/src/index/primary.ts +++ b/src/index/primary.ts @@ -7,6 +7,18 @@ export class PrimaryIndex { constructor(readonly key: string) {} + get documents() { + return Array.from(this.#index.values()); + } + + keys() { + return Array.from(this.#index.keys()); + } + + has(pk: PrimaryKey): boolean { + return this.#index.has(pk); + } + insert(pk: PrimaryKey, document: TSchema) { if (this.#index.has(pk)) { throw new Error(`Duplicate primary key: ${pk}`); @@ -18,7 +30,15 @@ export class PrimaryIndex { return this.#index.get(pk); } + replace(pk: PrimaryKey, document: TSchema) { + this.#index.set(pk, document); + } + delete(pk: PrimaryKey) { this.#index.delete(pk); } + + flush() { + this.#index.clear(); + } } diff --git a/src/index/shared.ts b/src/index/shared.ts index b716006..cf310bf 100644 --- a/src/index/shared.ts +++ b/src/index/shared.ts @@ -1,5 +1,7 @@ import type { PrimaryKey } from "./primary.ts"; +const EMPTY_SET: ReadonlySet = Object.freeze(new Set()); + export class SharedIndex { readonly #index = new Map>(); @@ -23,8 +25,8 @@ export class SharedIndex { * * @param value - Value to lookup a primary key for. */ - lookup(value: any): Set { - return this.#index.get(value) ?? new Set(); + lookup(value: any): ReadonlySet { + return this.#index.get(value) ?? EMPTY_SET; } /** diff --git a/src/observe/observe.ts b/src/observe/observe.ts index 30858eb..76a84ea 100644 --- a/src/observe/observe.ts +++ b/src/observe/observe.ts @@ -13,7 +13,7 @@ export function observe( options: QueryOptions | undefined, onChange: (documents: AnyDocument[], changed: AnyDocument[], type: ChangeEvent["type"]) => void, ): Subscription { - const documents = new Map(); + const cache = new Map(); let debounce: any; @@ -21,6 +21,9 @@ export function observe( // Find the initial documents and send them to the change listener. collection.findMany(condition, options).then(async (documents) => { + for (const document of documents) { + cache.set(collection.getPrimaryKeyValue(document), document); + } onChange(documents, documents, "insert"); }); @@ -37,7 +40,7 @@ export function observe( case "insert": { for (const document of data) { if (isMatch(document, condition)) { - documents.set(collection.getPrimaryKeyValue(document), document); + cache.set(collection.getPrimaryKeyValue(document), document); changed.push(document); } } @@ -46,15 +49,15 @@ export function observe( case "update": { for (const document of data) { const id = collection.getPrimaryKeyValue(document); - if (documents.has(id)) { + if (cache.has(id)) { if (isMatch(document, condition)) { - documents.set(id, document); + cache.set(id, document); } else { - documents.delete(id); + cache.delete(id); } changed.push(document); } else if (isMatch(document, condition)) { - documents.set(id, document); + cache.set(id, document); changed.push(document); } } @@ -63,7 +66,7 @@ export function observe( case "remove": { for (const document of data) { if (isMatch(document, condition)) { - documents.delete(collection.getPrimaryKeyValue(document)); + cache.delete(collection.getPrimaryKeyValue(document)); changed.push(document); } } @@ -73,7 +76,7 @@ export function observe( if (changed.length > 0) { clearTimeout(debounce); debounce = setTimeout(() => { - onChange(applyQueryOptions(Array.from(documents.values()), options), changed, type); + onChange(applyQueryOptions(Array.from(cache.values()), options), changed, type); }, 0); } }), diff --git a/src/registrars.ts b/src/registrars.ts index 7f2f130..045bc04 100644 --- a/src/registrars.ts +++ b/src/registrars.ts @@ -1,6 +1,9 @@ +import type { AnyDocument } from "@valkyr/db"; import type { ZodRawShape } from "zod"; -export type Registrars = { +import type { IndexSpec } from "./index/manager.ts"; + +export type Registrars = { /** * Name of the collection. */ @@ -14,5 +17,5 @@ export type Registrars = { /** * List of custom indexes for the collection. */ - indexes: IndexSpec[]; + indexes: IndexSpec[]; }; diff --git a/src/storage.ts b/src/storage.ts index 69c2f7c..35bde81 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -4,7 +4,7 @@ import type { Criteria } from "mingo/types"; import type { Modifier } from "mingo/updater"; import { BroadcastChannel, type StorageBroadcast } from "./broadcast.ts"; -import type { Index } from "./registrars.ts"; +import type { IndexSpec } from "./index/manager.ts"; import type { AnyDocument } from "./types.ts"; type StorageEvent = "change" | "flush"; @@ -25,10 +25,10 @@ export abstract class Storage { /** * List of indexes to optimize storage lookups. */ - readonly indexes: Index[], + readonly indexes: IndexSpec[], ) { if (primaryIndexCount(indexes) !== 1) { - throw new Error("Storage is missing or has more than 1 defined primaryIndex"); + throw new Error("missing required primary key assignment"); } this.#channel = new BroadcastChannel(`@valkyr/db:${name}`); this.#channel.onmessage = ({ data }: MessageEvent) => { @@ -174,7 +174,7 @@ export function addOptions( cursor: Cursor, options: QueryOptions, ): Cursor { - if (options.sort) { + if (options.sort !== undefined) { cursor.sort(options.sort); } if (options.skip !== undefined) { @@ -186,10 +186,10 @@ export function addOptions( return cursor; } -function primaryIndexCount(indexes: Index[]): number { +function primaryIndexCount(indexes: IndexSpec[]): number { let count = 0; - for (const [, options] of indexes) { - if (options?.primary === true) { + for (const { kind } of indexes) { + if (kind === "primary") { count += 1; } } diff --git a/src/types.ts b/src/types.ts index 2324813..be017ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { Criteria } from "mingo/types"; + /** * Represents an unknown document with global support. */ @@ -5,7 +7,24 @@ export type AnyDocument = { [key: string]: any; }; +export type StringKeyOf = Extract; + /** * Simplifies a complex type. */ export type Prettify = { [K in keyof T]: T[K] } & {}; + +/** + * Extended Criteria type that includes MongoDB logical and comparison operators + */ +export type QueryCriteria = Criteria & { + $and?: QueryCriteria[]; + $or?: QueryCriteria[]; + $nor?: QueryCriteria[]; + $not?: QueryCriteria; + + $exists?: boolean; + $type?: string | number; + + [key: string]: any; +}; diff --git a/tests/cache.test.ts b/tests/cache.test.ts deleted file mode 100644 index b540145..0000000 --- a/tests/cache.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; -import { expect } from "expect"; - -import { IndexedDbCache } from "../src/databases/indexeddb/cache.ts"; -import type { Options } from "../src/storage/storage.ts"; -import type { WithId } from "../src/types.ts"; - -describe("IndexedDbCache", () => { - let cache: IndexedDbCache; - - beforeEach(() => { - cache = new IndexedDbCache(); - }); - - afterEach(() => { - cache.flush(); - }); - - const sampleDocuments: WithId<{ name: string }>[] = [ - { id: "doc1", name: "Document 1" }, - { id: "doc2", name: "Document 2" }, - ]; - - const sampleCriteria = { name: { $eq: "Document 1" } }; - const sampleOptions: Options = { sort: { name: 1 } }; - - it("hash", () => { - const hashCode = cache.hash(sampleCriteria, sampleOptions); - expect(typeof hashCode).toBe("number"); - }); - - it("set and get", () => { - const hashCode = cache.hash(sampleCriteria, sampleOptions); - cache.set(hashCode, sampleDocuments); - const result = cache.get(hashCode); - expect(result).toEqual(sampleDocuments); - }); - - it("get undefined", () => { - const hashCode = cache.hash(sampleCriteria, sampleOptions); - const result = cache.get(hashCode); - expect(result).toBeUndefined(); - }); - - it("flush", () => { - const hashCode = cache.hash(sampleCriteria, sampleOptions); - cache.set(hashCode, sampleDocuments); - cache.flush(); - const result = cache.get(hashCode); - expect(result).toBeUndefined(); - }); -}); diff --git a/tests/collection.test.ts b/tests/collection.test.ts index b29aeef..8593c7d 100644 --- a/tests/collection.test.ts +++ b/tests/collection.test.ts @@ -1,80 +1,951 @@ -import { describe, it } from "@std/testing/bdd"; +import assert from "node:assert"; + +import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; import { expect } from "expect"; +import z from "zod"; + +(globalThis as any).assert = assert; import { Collection } from "../src/collection.ts"; import { MemoryStorage } from "../src/databases/memory/storage.ts"; -import { getUsers, type UserDocument } from "./users.mock.ts"; -/* - |-------------------------------------------------------------------------------- - | Unit Tests - |-------------------------------------------------------------------------------- - */ +const schema = { + id: z.string(), + name: z.string().optional(), + emails: z.array(z.email()), + friends: z.array( + z.object({ + id: z.string(), + type: z.union([z.literal("family"), z.literal("close")]), + }), + ), + age: z.number(), +}; + +type UserSchema = typeof schema; describe("Collection", () => { - it("should successfully create a new collection", () => { - const collection = new Collection("users", new MemoryStorage("users")); - expect(collection.name).toEqual("users"); - collection.storage.destroy(); - }); + let collection: Collection<{ + name: string; + storage: MemoryStorage; + schema: UserSchema; + indexes: [{ field: "id"; kind: "primary" }]; + }>; - describe("when finding document by id", () => { - it("should return model instance if document exists", async () => { - const collection = new Collection("users", new MemoryStorage("users")); - const users = getUsers(); - await collection.insertMany(users); - expect(await collection.findById(users[0].id)).toEqual(users[0]); - collection.storage.destroy(); - }); - - it("should return undefined if document does not exists", async () => { - const collection = new Collection("users", new MemoryStorage("users")); - expect(await collection.findById("user-4")).toBeUndefined(); - collection.storage.destroy(); + beforeEach(() => { + collection = new Collection({ + name: "test", + storage: new MemoryStorage("test", [ + { + field: "id", + kind: "primary", + }, + ]), + schema: { + id: z.string(), + name: z.string().optional(), + fullName: z.string().optional(), + emails: z.array(z.email()), + friends: z.array( + z.object({ + id: z.string(), + type: z.union([z.literal("family"), z.literal("close")]), + }), + ), + age: z.number(), + }, + indexes: [ + { + field: "id", + kind: "primary", + }, + ], }); }); - describe("when finding document by filter", () => { - it("should return model instances when matches are found", async () => { - const collection = new Collection("users", new MemoryStorage("users")); - const users = getUsers(); - await collection.insertMany(users); - expect(await collection.find({ name: "Jane Doe" })).toEqual([users[1]]); - collection.storage.destroy(); + afterEach(async () => { + await collection.flush(); + }); + + describe("Constructor and Properties", () => { + it("should initialize with correct name", () => { + expect(collection.name).toBe("test"); }); - it("should return empty array when no matches are found", async () => { - const collection = new Collection("users", new MemoryStorage("users")); - expect(await collection.find({ name: "Rick Doe" })).toEqual([]); - collection.storage.destroy(); + it("should have correct schema", () => { + expect(collection.schema).toBeDefined(); + expect(collection.schema.id).toBeDefined(); + }); + + it("should have correct storage", () => { + expect(collection.storage).toBeDefined(); + }); + + it("should identify primary key correctly", () => { + expect(collection.primaryKey).toBe("id"); + }); + + it("should throw error when primary key is missing", () => { + expect(() => { + new Collection({ + name: "invalid", + storage: new MemoryStorage("invalid", []), + schema: { id: z.string() }, + indexes: [], + }); + }).toThrow("missing required primary key assignment"); }); }); - describe("when finding single document by filter", () => { - it("should return model instance if document exists", async () => { - const collection = new Collection("users", new MemoryStorage("users")); - const users = getUsers(); - await collection.insertMany(users); - expect(await collection.findOne({ name: "Jane Doe" })).toEqual(users[1]); - collection.storage.destroy(); - }); + describe("Utilities", () => { + describe("getPrimaryKeyValue", () => { + it("should return primary key value from document", () => { + const doc = { id: "123", name: "Test" }; + expect(collection.getPrimaryKeyValue(doc)).toBe("123"); + }); - it("should return undefined if document does not exists", async () => { - const collection = new Collection("users", new MemoryStorage("users")); - expect(await collection.findOne({ name: "Rick Doe" })).toBeUndefined(); - collection.storage.destroy(); + it("should throw error when primary key is missing", () => { + const doc = { name: "Test" }; + expect(() => collection.getPrimaryKeyValue(doc)).toThrow("Missing primary key"); + }); + + it("should throw error when primary key is not a string", () => { + const doc = { id: 123, name: "Test" }; + expect(() => collection.getPrimaryKeyValue(doc)).toThrow("Missing primary key"); + }); }); }); - describe("should count documents by filter", () => { - it("should return correct filter count", async () => { - const collection = new Collection("users", new MemoryStorage("users")); - const users = getUsers(); - await collection.insertMany(users); - expect(await collection.count({ name: "Rick Doe" })).toEqual(0); - expect(await collection.count({ name: "Jane Doe" })).toEqual(1); - expect(await collection.count()).toEqual(2); - collection.storage.destroy(); + describe("Insert Operations", () => { + it("should insert a single document", async () => { + await collection.insert([ + { + id: "1", + name: "Alice", + emails: ["alice@test.com"], + friends: [], + age: 25, + }, + ]); + + const doc = await collection.findOne({ id: "1" }); + expect(doc).toBeDefined(); + expect(doc?.name).toBe("Alice"); + }); + + it("should insert multiple documents", async () => { + await collection.insert([ + { + id: "1", + name: "Alice", + emails: ["alice@test.com"], + friends: [], + age: 25, + }, + { + id: "2", + name: "Bob", + emails: ["bob@test.com"], + friends: [], + age: 30, + }, + ]); + + const count = await collection.count(); + expect(count).toBe(2); + }); + + it("should validate documents against schema on insert", async () => { + await expect( + collection.insert([ + { + id: "1", + name: "Invalid", + emails: ["not-an-email"], + friends: [], + age: 25, + } as any, + ]), + ).rejects.toThrow(); + }); + }); + + describe("Query Operations", () => { + beforeEach(async () => { + await collection.insert([ + { + id: "1", + name: "Alice", + emails: ["alice@test.com"], + friends: [{ id: "2", type: "close" }], + age: 25, + }, + { + id: "2", + name: "Bob", + emails: ["bob@test.com"], + friends: [{ id: "1", type: "family" }], + age: 30, + }, + { + id: "3", + name: "Charlie", + emails: ["charlie@test.com"], + friends: [], + age: 35, + }, + ]); + }); + + describe("findOne", () => { + it("should find document by id", async () => { + const doc = await collection.findOne({ id: "1" }); + expect(doc).toBeDefined(); + expect(doc?.name).toBe("Alice"); + }); + + it("should return undefined when no match found", async () => { + const doc = await collection.findOne({ id: "999" }); + expect(doc).toBeUndefined(); + }); + + it("should find document by field value", async () => { + const doc = await collection.findOne({ name: "Bob" }); + expect(doc).toBeDefined(); + expect(doc?.id).toBe("2"); + }); + + it("should support comparison operators", async () => { + const doc = await collection.findOne({ age: { $gte: 30 } }); + expect(doc).toBeDefined(); + expect(doc?.age).toBeGreaterThanOrEqual(30); + }); + + it("should support empty condition", async () => { + const doc = await collection.findOne(); + expect(doc).toBeDefined(); + }); + }); + + describe("findMany", () => { + it("should find all documents", async () => { + const docs = await collection.findMany(); + expect(docs).toHaveLength(3); + }); + + it("should find documents by condition", async () => { + const docs = await collection.findMany({ age: { $gte: 30 } }); + expect(docs).toHaveLength(2); + expect(docs.every((d) => d.age >= 30)).toBe(true); + }); + + it("should support limit option", async () => { + const docs = await collection.findMany({}, { limit: 2 }); + expect(docs).toHaveLength(2); + }); + + it("should support skip option", async () => { + const docs = await collection.findMany({}, { skip: 1 }); + expect(docs).toHaveLength(2); + }); + + it("should support sort option", async () => { + const docs = await collection.findMany({}, { sort: { age: -1 } }); + expect(docs[0].age).toBe(35); + expect(docs[2].age).toBe(25); + }); + + it("should support complex queries with $and", async () => { + const docs = await collection.findMany({ + $and: [{ age: { $gte: 25 } }, { age: { $lte: 30 } }], + }); + expect(docs).toHaveLength(2); + }); + + it("should support complex queries with $or", async () => { + const docs = await collection.findMany({ + $or: [{ name: "Alice" }, { name: "Charlie" }], + }); + expect(docs).toHaveLength(2); + }); + + it("should support array queries", async () => { + const docs = await collection.findMany({ + emails: { $in: ["alice@test.com"] }, + }); + expect(docs).toHaveLength(1); + expect(docs[0].name).toBe("Alice"); + }); + + it("should support nested object queries", async () => { + const docs = await collection.findMany({ + "friends.type": "close", + }); + expect(docs).toHaveLength(1); + expect(docs[0].name).toBe("Alice"); + }); + }); + + describe("count", () => { + it("should count all documents", async () => { + const count = await collection.count(); + expect(count).toBe(3); + }); + + it("should count documents matching condition", async () => { + const count = await collection.count({ age: { $gte: 30 } }); + expect(count).toBe(2); + }); + + it("should return 0 when no matches", async () => { + const count = await collection.count({ age: { $gte: 100 } }); + expect(count).toBe(0); + }); + }); + }); + + describe("Update Operations", () => { + beforeEach(async () => { + await collection.insert([ + { + id: "100", + name: "John Doe", + emails: ["john.doe@fixture.none"], + friends: [{ id: "201", type: "close" }], + age: 22, + }, + { + id: "200", + name: "Jane Doe", + emails: ["jane.doe@fixture.none"], + friends: [], + age: 28, + }, + ]); + }); + + describe("$set operator", () => { + it("should set top level fields", async () => { + const result = await collection.update( + { id: "100" }, + { + $set: { + age: 32, + emails: ["john.doe@test.none"], + }, + }, + ); + + expect(result).toEqual({ matchedCount: 1, modifiedCount: 1 }); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.age).toBe(32); + expect(doc?.emails).toEqual(["john.doe@test.none"]); + }); + + it("should set nested fields", async () => { + await collection.update( + { id: "100" }, + { + $set: { + "friends.0.type": "family", + }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.friends[0].type).toBe("family"); + }); + + it("should update multiple documents", async () => { + const result = await collection.update( + { age: { $gte: 20 } }, + { + $set: { age: 50 }, + }, + ); + + expect(result.matchedCount).toBe(2); + expect(result.modifiedCount).toBe(2); + }); + }); + + describe("$unset operator", () => { + it("should remove fields", async () => { + await collection.update( + { id: "100" }, + { + $unset: { name: "" }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.name).toBeUndefined(); + }); + }); + + describe("$inc operator", () => { + it("should increment numeric fields", async () => { + await collection.update( + { id: "100" }, + { + $inc: { age: 5 }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.age).toBe(27); + }); + + it("should decrement with negative values", async () => { + await collection.update( + { id: "100" }, + { + $inc: { age: -2 }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.age).toBe(20); + }); + }); + + describe("$mul operator", () => { + it("should multiply numeric fields", async () => { + await collection.update( + { id: "100" }, + { + $mul: { age: 2 }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.age).toBe(44); + }); + }); + + describe("$min and $max operators", () => { + it("should update only if new value is smaller ($min)", async () => { + await collection.update( + { id: "100" }, + { + $min: { age: 20 }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.age).toBe(20); + }); + + it("should not update if current value is smaller ($min)", async () => { + await collection.update( + { id: "100" }, + { + $min: { age: 30 }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.age).toBe(22); + }); + + it("should update only if new value is larger ($max)", async () => { + await collection.update( + { id: "100" }, + { + $max: { age: 30 }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.age).toBe(30); + }); + }); + + // TODO: TypeError: Cannot destructure property 'node' of 'params[key]' as it is undefined. + // describe("$rename operator", () => { + // it("should rename fields", async () => { + // await collection.update( + // { id: "100" }, + // { + // $rename: { name: "fullName" }, + // }, + // ); + + // const doc = await collection.findOne({ id: "100" }); + // expect(doc?.fullName).toBe("John Doe"); + // expect(doc?.name).toBeUndefined(); + // }); + // }); + + describe("Array update operators", () => { + describe("$push", () => { + it("should push item to array", async () => { + await collection.update( + { id: "100" }, + { + $push: { emails: "new@test.com" }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.emails).toContain("new@test.com"); + expect(doc?.emails).toHaveLength(2); + }); + + it("should push object to array", async () => { + await collection.update( + { id: "100" }, + { + $push: { friends: { id: "300", type: "family" } }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.friends).toHaveLength(2); + expect(doc?.friends[1].id).toBe("300"); + }); + }); + + describe("$pull", () => { + it("should pull item from array", async () => { + await collection.update( + { id: "100" }, + { + $pull: { emails: "john.doe@fixture.none" }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.emails).toHaveLength(0); + }); + + it("should pull object from array by condition", async () => { + await collection.update( + { id: "100" }, + { + $pull: { friends: { id: "201" } }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.friends).toHaveLength(0); + }); + }); + + describe("$addToSet", () => { + it("should add unique item to array", async () => { + await collection.update( + { id: "100" }, + { + $addToSet: { emails: "new@test.com" }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.emails).toContain("new@test.com"); + }); + + it("should not add duplicate item to array", async () => { + await collection.update( + { id: "100" }, + { + $addToSet: { emails: "john.doe@fixture.none" }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.emails).toHaveLength(1); + }); + }); + + describe("$pop", () => { + it("should remove last element with 1", async () => { + await collection.update( + { id: "100" }, + { + $push: { emails: "extra@test.com" }, + }, + ); + + await collection.update( + { id: "100" }, + { + $pop: { emails: 1 }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.emails[0]).toBe("john.doe@fixture.none"); + }); + + it("should remove first element with -1", async () => { + await collection.update( + { id: "100" }, + { + $push: { emails: "extra@test.com" }, + }, + ); + + await collection.update( + { id: "100" }, + { + $pop: { emails: -1 }, + }, + ); + + const doc = await collection.findOne({ id: "100" }); + expect(doc?.emails[0]).toBe("extra@test.com"); + }); + }); + }); + + it("should return matched and modified counts", async () => { + const result = await collection.update( + { age: { $gte: 25 } }, + { + $set: { age: 40 }, + }, + ); + + expect(result.matchedCount).toBeGreaterThan(0); + expect(result.modifiedCount).toBe(result.matchedCount); + }); + + it("should return 0 modified when no changes made", async () => { + const result = await collection.update( + { id: "100" }, + { + $set: { age: 22 }, + }, + ); + + expect(result.matchedCount).toBe(1); + expect(result.modifiedCount).toBe(0); + }); + }); + + describe("Remove Operations", () => { + beforeEach(async () => { + await collection.insert([ + { + id: "1", + name: "Alice", + emails: ["alice@test.com"], + friends: [], + age: 25, + }, + { + id: "2", + name: "Bob", + emails: ["bob@test.com"], + friends: [], + age: 30, + }, + { + id: "3", + name: "Charlie", + emails: ["charlie@test.com"], + friends: [], + age: 35, + }, + ]); + }); + + it("should remove single document", async () => { + const removed = await collection.remove({ id: "1" }); + expect(removed).toBe(1); + + const doc = await collection.findOne({ id: "1" }); + expect(doc).toBeUndefined(); + }); + + it("should remove multiple documents", async () => { + const removed = await collection.remove({ age: { $gte: 30 } }); + expect(removed).toBe(2); + + const count = await collection.count(); + expect(count).toBe(1); + }); + + it("should return 0 when no documents match", async () => { + const removed = await collection.remove({ id: "999" }); + expect(removed).toBe(0); + }); + + it("should remove all documents with empty condition", async () => { + const removed = await collection.remove({}); + expect(removed).toBe(3); + + const count = await collection.count(); + expect(count).toBe(0); + }); + }); + + describe("Subscribe Operations", () => { + beforeEach(async () => { + await collection.insert([ + { + id: "1", + name: "Alice", + emails: ["alice@test.com"], + friends: [], + age: 25, + }, + ]); + }); + + it("should subscribe to single document changes", async () => { + let subscription: any; + + try { + const promise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + subscription?.unsubscribe(); + reject(new Error("Timeout waiting for subscription")); + }, 1000); + + subscription = collection.subscribe({ id: "1" }, { limit: 1 }, (doc) => { + if (doc && doc.age === 30) { + clearTimeout(timeout); + expect(doc.name).toBe("Alice"); + subscription.unsubscribe(); + resolve(); + } + }); + + setTimeout(() => { + collection.update({ id: "1" }, { $set: { age: 30 } }); + }, 10); + }); + + await promise; + } finally { + subscription?.unsubscribe(); + } + }); + + it("should subscribe to multiple document changes", async () => { + let subscription: any; + + try { + const promise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + subscription?.unsubscribe(); + reject(new Error("Timeout waiting for subscription")); + }, 1000); + + subscription = collection.subscribe({}, {}, (docs) => { + if (docs.length > 1) { + clearTimeout(timeout); + expect(docs.length).toBeGreaterThan(1); + subscription.unsubscribe(); + resolve(); + } + }); + + setTimeout(() => { + collection.insert([ + { + id: "2", + name: "Bob", + emails: ["bob@test.com"], + friends: [], + age: 30, + }, + ]); + }, 10); + }); + + await promise; + } finally { + subscription?.unsubscribe(); + } + }); + + it("should unsubscribe successfully", async () => { + let callCount = 0; + const subscription = collection.subscribe({}, {}, () => { + callCount++; + }); + + subscription.unsubscribe(); + + await collection.insert([ + { + id: "2", + name: "Bob", + emails: ["bob@test.com"], + friends: [], + age: 30, + }, + ]); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(callCount).toBe(1); + }); + }); + + describe("Event Handlers", () => { + it("should trigger onChange on insert", async () => { + let subscription: any; + + try { + const promise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + subscription?.unsubscribe(); + reject(new Error("Timeout waiting for onChange")); + }, 1000); + + subscription = collection.onChange((event) => { + clearTimeout(timeout); + expect(event.type).toBe("insert"); + subscription.unsubscribe(); + resolve(); + }); + + collection.insert([ + { + id: "1", + name: "Alice", + emails: ["alice@test.com"], + friends: [], + age: 25, + }, + ]); + }); + + await promise; + } finally { + subscription?.unsubscribe(); + } + }); + + it("should trigger onChange on update", async () => { + await collection.insert([ + { + id: "1", + name: "Alice", + emails: ["alice@test.com"], + friends: [], + age: 25, + }, + ]); + + let subscription: any; + + try { + const promise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + subscription?.unsubscribe(); + reject(new Error("Timeout waiting for onChange")); + }, 1000); + + subscription = collection.onChange((event) => { + if (event.type === "update") { + clearTimeout(timeout); + subscription.unsubscribe(); + resolve(); + } + }); + + collection.update({ id: "1" }, { $set: { age: 30 } }); + }); + + await promise; + } finally { + subscription?.unsubscribe(); + } + }); + + it("should trigger onChange on remove", async () => { + await collection.insert([ + { + id: "1", + name: "Alice", + emails: ["alice@test.com"], + friends: [], + age: 25, + }, + ]); + + let subscription: any; + + try { + const promise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + subscription?.unsubscribe(); + reject(new Error("Timeout waiting for onChange")); + }, 1000); + + subscription = collection.onChange((event) => { + if (event.type === "remove") { + clearTimeout(timeout); + subscription.unsubscribe(); + resolve(); + } + }); + + collection.remove({ id: "1" }); + }); + + await promise; + } finally { + subscription?.unsubscribe(); + } + }); + + it("should trigger onFlush when flush is called", async () => { + let subscription: any; + + try { + const promise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + subscription?.unsubscribe(); + reject(new Error("Timeout waiting for onFlush")); + }, 1000); + + subscription = collection.onFlush(() => { + clearTimeout(timeout); + subscription.unsubscribe(); + resolve(); + }); + + collection.flush(); + }); + + await promise; + } finally { + subscription?.unsubscribe(); + } + }); + }); + + describe("Flush Operation", () => { + it("should clear all documents", async () => { + await collection.insert([ + { + id: "1", + name: "Alice", + emails: ["alice@test.com"], + friends: [], + age: 25, + }, + { + id: "2", + name: "Bob", + emails: ["bob@test.com"], + friends: [], + age: 30, + }, + ]); + + await collection.flush(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const count = await collection.count(); + expect(count).toBe(0); }); }); }); diff --git a/tests/hash.test.ts b/tests/hash.test.ts deleted file mode 100644 index 7978764..0000000 --- a/tests/hash.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "expect"; - -import { hashCodeQuery } from "../src/hash.ts"; -import type { Options } from "../src/mod.ts"; - -describe("hashCodeQuery", () => { - const filter = { name: { $eq: "Document 1" } }; - const options: Options = { sort: { name: 1 } }; - - it("return correct hash code", () => { - const hashCode = hashCodeQuery(filter, options); - expect(typeof hashCode).toBe("number"); - }); -}); diff --git a/tests/index-manager.test.ts b/tests/index-manager.test.ts new file mode 100644 index 0000000..4663f79 --- /dev/null +++ b/tests/index-manager.test.ts @@ -0,0 +1,307 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "expect"; + +import { IndexManager, type IndexSpec } from "../src/index/manager.ts"; + +describe("IndexManager", () => { + type User = { + id: string; + email: string; + group: string; + name: string; + }; + + const specs: IndexSpec[] = [ + { field: "id", kind: "primary" }, + { field: "email", kind: "unique" }, + { field: "group", kind: "shared" }, + ]; + + it("should insert and retrieve documents by primary, unique, and shared indexes", () => { + const manager = new IndexManager(specs); + + const user1: User = { id: "u1", email: "a@example.com", group: "staff", name: "Alice" }; + const user2: User = { id: "u2", email: "b@example.com", group: "staff", name: "Bob" }; + const user3: User = { id: "u3", email: "c@example.com", group: "admin", name: "Carol" }; + + // insert + manager.insert(user1); + manager.insert(user2); + manager.insert(user3); + + // primary lookup + expect(manager.getByPrimary("u1")).toEqual(user1); + expect(manager.getByPrimary("u2")).toEqual(user2); + expect(manager.getByPrimary("u3")).toEqual(user3); + + // unique lookup + expect(manager.getByUnique("email", "a@example.com")).toEqual(user1); + expect(manager.getByUnique("email", "b@example.com")).toEqual(user2); + + // shared lookup + const staff = manager.getByIndex("group", "staff"); + expect(staff).toHaveLength(2); + expect(staff).toContainEqual(user1); + expect(staff).toContainEqual(user2); + + const admin = manager.getByIndex("group", "admin"); + expect(admin).toHaveLength(1); + expect(admin[0]).toEqual(user3); + + // unknown lookup + expect(manager.getByPrimary("unknown")).toBeUndefined(); + expect(manager.getByUnique("email", "notfound@example.com")).toBeUndefined(); + expect(manager.getByIndex("group", "none")).toEqual([]); + }); + + it("should enforce unique constraints", () => { + const manager = new IndexManager(specs); + + const user: User = { id: "u1", email: "a@example.com", group: "staff", name: "Alice" }; + manager.insert(user); + + const dupEmail: User = { id: "u2", email: "a@example.com", group: "admin", name: "Bob" }; + expect(() => manager.insert(dupEmail)).toThrow(/Unique constraint violation/); + }); + + it("should remove documents and clean up indexes", () => { + const manager = new IndexManager(specs); + + const user: User = { id: "u1", email: "a@example.com", group: "staff", name: "Alice" }; + manager.insert(user); + + // sanity + expect(manager.getByPrimary("u1")).toEqual(user); + expect(manager.getByUnique("email", "a@example.com")).toEqual(user); + expect(manager.getByIndex("group", "staff")).toContainEqual(user); + + // remove + manager.remove(user); + + expect(manager.getByPrimary("u1")).toBeUndefined(); + expect(manager.getByUnique("email", "a@example.com")).toBeUndefined(); + expect(manager.getByIndex("group", "staff")).toEqual([]); + }); + + it("should update existing documents", () => { + const manager = new IndexManager(specs); + + const user: User = { id: "u1", email: "a@example.com", group: "staff", name: "Alice" }; + manager.insert(user); + + // update email and group + const updated: User = { ...user, email: "a_new@example.com", group: "admin" }; + manager.update(updated); + + // old unique index cleared + expect(manager.getByUnique("email", "a@example.com")).toBeUndefined(); + + // new unique index works + expect(manager.getByUnique("email", "a_new@example.com")).toEqual(updated); + + // old shared index cleared + expect(manager.getByIndex("group", "staff")).toEqual([]); + + // new shared index works + expect(manager.getByIndex("group", "admin")).toContainEqual(updated); + + // primary still points to updated document + expect(manager.getByPrimary("u1")).toEqual(updated); + }); + + it("should perform upsert if primary key does not exist", () => { + const manager = new IndexManager(specs); + + const user: User = { id: "u1", email: "a@example.com", group: "staff", name: "Alice" }; + + // update on non-existent PK acts as insert + manager.update(user); + + expect(manager.getByPrimary("u1")).toEqual(user); + expect(manager.getByUnique("email", "a@example.com")).toEqual(user); + expect(manager.getByIndex("group", "staff")).toContainEqual(user); + }); + + it("should lazily clean up stale shared index references", () => { + const manager = new IndexManager(specs); + + const user: User = { id: "u1", email: "a@example.com", group: "staff", name: "Alice" }; + manager.insert(user); + + // manually delete primary without cleaning shared + manager.primary.delete("u1"); + + // getByIndex should remove stale reference + const result = manager.getByIndex("group", "staff"); + expect(result).toEqual([]); + // after lazy cleanup, lookup should also be empty + expect(manager.getPrimaryKeysByIndex("group", "staff")).toEqual(new Set()); + }); + + describe(".getByCondition", () => { + type User = { + id: string; + email: string; + group: string; + name: string; + active: boolean; + }; + + const specs: IndexSpec[] = [ + { field: "id", kind: "primary" }, + { field: "email", kind: "unique" }, + { field: "group", kind: "shared" }, + ]; + + it("should find documents by primary key", () => { + const manager = new IndexManager(specs); + + const user = { id: "u1", email: "a@example.com", group: "staff", name: "Alice", active: true }; + manager.insert(user); + + const results = manager.getByCondition({ id: "u1" }); + expect(results).toHaveLength(1); + expect(results[0]).toEqual(user); + }); + + it("should find documents by unique index", () => { + const manager = new IndexManager(specs); + + const user = { id: "u1", email: "a@example.com", group: "staff", name: "Alice", active: true }; + manager.insert(user); + + const results = manager.getByCondition({ email: "a@example.com" }); + expect(results).toHaveLength(1); + expect(results[0]).toEqual(user); + }); + + it("should find documents by shared index", () => { + const manager = new IndexManager(specs); + + const user1 = { id: "u1", email: "a@example.com", group: "staff", name: "Alice", active: true }; + const user2 = { id: "u2", email: "b@example.com", group: "staff", name: "Bob", active: false }; + const user3 = { id: "u3", email: "c@example.com", group: "admin", name: "Carol", active: true }; + + manager.insert(user1); + manager.insert(user2); + manager.insert(user3); + + const staff = manager.getByCondition({ group: "staff" }); + expect(staff).toHaveLength(2); + expect(staff).toContainEqual(user1); + expect(staff).toContainEqual(user2); + + const admin = manager.getByCondition({ group: "admin" }); + expect(admin).toHaveLength(1); + expect(admin[0]).toEqual(user3); + }); + + it("should handle multiple fields with intersection", () => { + const manager = new IndexManager(specs); + + const user1 = { id: "u1", email: "a@example.com", group: "staff", name: "Alice", active: true }; + const user2 = { id: "u2", email: "b@example.com", group: "staff", name: "Bob", active: false }; + manager.insert(user1); + manager.insert(user2); + + // Lookup by shared + non-indexed field + const results = manager.getByCondition({ group: "staff", active: true }); + expect(results).toHaveLength(1); + expect(results[0]).toEqual(user1); + }); + + it("should return empty array if no match", () => { + const manager = new IndexManager(specs); + + const user = { id: "u1", email: "a@example.com", group: "staff", name: "Alice", active: true }; + manager.insert(user); + + const results = manager.getByCondition({ group: "admin" }); + expect(results).toEqual([]); + + const results2 = manager.getByCondition({ email: "nonexistent@example.com" }); + expect(results2).toEqual([]); + }); + }); +}); + +describe("IndexManager Performance", () => { + type User = { + id: string; + email: string; + group: string; + name: string; + }; + + const NUM_RECORDS = 100_000; + + const specs: IndexSpec[] = [ + { field: "id", kind: "primary" }, + { field: "email", kind: "unique" }, + { field: "group", kind: "shared" }, + ]; + + it("should insert and query thousands of records efficiently", () => { + const manager = new IndexManager(specs); + + const groups = ["staff", "admin", "guest", "manager"]; + + console.time("Insert 100k records"); + for (let i = 0; i < NUM_RECORDS; i++) { + const user: User = { + id: `user_${i}`, + email: `user_${i}@example.com`, + group: groups[i % groups.length], + name: `User ${i}`, + }; + manager.insert(user); + } + console.timeEnd("Insert 100k records"); + + // Check total number of records + expect(manager.getByPrimary("user_0")?.name).toEqual("User 0"); + expect(manager.getByPrimary(`user_${NUM_RECORDS - 1}`)?.name).toEqual(`User ${NUM_RECORDS - 1}`); + + // Unique lookup + console.time("Unique lookup 10k"); + for (let i = 0; i < 10_000; i++) { + const user = manager.getByUnique("email", `user_${i}@example.com`); + expect(user?.id).toEqual(`user_${i}`); + } + console.timeEnd("Unique lookup 10k"); + + // Shared lookup + console.time("Shared lookup"); + for (const group of groups) { + const users = manager.getByIndex("group", group); + expect(users.length).toBeGreaterThan(0); + } + console.timeEnd("Shared lookup"); + + // Update some users + console.time("Update 10k records"); + for (let i = 0; i < 10_000; i++) { + const user = manager.getByPrimary(`user_${i}`); + if (!user) { + continue; + } + const updated = { ...user, group: groups[(i + 1) % groups.length] }; + manager.update(updated); + } + console.timeEnd("Update 10k records"); + + // Remove some users + console.time("Remove 10k records"); + for (let i = 0; i < 10_000; i++) { + const user = manager.getByPrimary(`user_${i}`); + if (user) { + manager.remove(user); + } + } + console.timeEnd("Remove 10k records"); + + // Spot check + expect(manager.getByPrimary("user_0")).toBeUndefined(); + expect(manager.getByPrimary(`user_${10_001}`)).not.toBeUndefined(); + }); +}); diff --git a/tests/indexeddb-storage.test.ts b/tests/indexeddb-storage.test.ts new file mode 100644 index 0000000..d6f7167 --- /dev/null +++ b/tests/indexeddb-storage.test.ts @@ -0,0 +1,116 @@ +import { afterAll, afterEach, beforeAll, describe, it } from "@std/testing/bdd"; +import { expect } from "expect"; + +import "fake-indexeddb/auto"; + +import z from "zod"; + +import { IndexedDB } from "../src/databases/indexeddb/database.ts"; +import type { DBLogger } from "../src/logger.ts"; + +const log: DBLogger = () => {}; + +describe("IndexedDB Storage Integration", { sanitizeOps: false, sanitizeResources: false }, () => { + let db: IndexedDB<{ name: string; registrars: any[]; log?: DBLogger }>; + + let collection: any; + + beforeAll(async () => { + db = new IndexedDB({ + name: "test-db", + registrars: [ + { + name: "users", + schema: { + id: z.string(), + name: z.string().optional(), + age: z.number().optional(), + }, + indexes: [ + { field: "id", kind: "primary" }, + { field: "name", kind: "unique" }, + ], + }, + ], + log, + }); + + collection = db.collection("users"); + + await collection.storage.resolve(); + await collection.flush(); + }); + + afterEach(async () => { + await db.flush(); + }); + + afterAll(async () => { + await db.close(); + }); + + it("should insert and find documents", async () => { + await collection.storage.insert([ + { id: "1", name: "Alice", age: 30 }, + { id: "2", name: "Bob", age: 25 }, + ]); + + const all = await collection.storage.find({}); + expect(all).toHaveLength(2); + + const alice = await collection.storage.find({ name: "Alice" }); + expect(alice).toHaveLength(1); + expect(alice[0].age).toBe(30); + }); + + it("should get documents by index", async () => { + await collection.storage.insert([{ id: "1", name: "Alice" }]); + const byIndex = await collection.storage.getByIndex("id", "1"); + expect(byIndex).toHaveLength(1); + expect(byIndex[0].name).toBe("Alice"); + }); + + it("should update documents", async () => { + await collection.storage.insert([{ id: "1", name: "Alice", age: 30 }]); + + const result = await collection.storage.update({ id: "1" }, { $set: { age: 31 } }); + expect(result.matchedCount).toBe(1); + expect(result.modifiedCount).toBe(1); + + const updated = await collection.storage.find({ id: "1" }); + expect(updated[0].age).toBe(31); + }); + + it("should remove documents", async () => { + await collection.storage.insert([ + { id: "1", name: "Alice" }, + { id: "2", name: "Bob" }, + ]); + + const removedCount = await collection.storage.remove({ name: "Bob" }); + expect(removedCount).toBe(1); + + const remaining = await collection.storage.find({}); + expect(remaining).toHaveLength(1); + expect(remaining[0].name).toBe("Alice"); + }); + + it("should count documents", async () => { + await collection.storage.insert([ + { id: "1", age: 30 }, + { id: "2", age: 25 }, + { id: "3", age: 30 }, + ]); + + const count = await collection.storage.count({ age: 30 }); + expect(count).toBe(2); + }); + + it("should flush the collection", async () => { + await collection.storage.insert([{ id: "1", name: "Alice" }]); + await collection.flush(); + + const all = await collection.storage.find({}); + expect(all).toHaveLength(0); + }); +}); diff --git a/tests/insert.test.ts b/tests/insert.test.ts deleted file mode 100644 index 4efa964..0000000 --- a/tests/insert.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import "fake-indexeddb/auto"; - -import { describe, it } from "@std/testing/bdd"; -import { expect } from "expect"; - -import { Collection } from "../src/collection.ts"; -import { MemoryStorage } from "../src/databases/memory/storage.ts"; -import { DuplicateDocumentError } from "../src/storage/errors.ts"; -import { getUsers } from "./users.mock.ts"; - -/* - |-------------------------------------------------------------------------------- - | Unit Tests - |-------------------------------------------------------------------------------- - */ - -describe("Storage Insert", () => { - it("should successfully insert a new document", async () => { - const collection = new Collection("users", new MemoryStorage("users")); - const users = getUsers(); - await collection.insertMany(users); - expect(await collection.storage.findById(users[0].id)).toEqual(users[0]); - expect(await collection.storage.findById(users[1].id)).toEqual(users[1]); - collection.storage.destroy(); - }); - - it("should throw an error if the document already exists", async () => { - const collection = new Collection("users", new MemoryStorage("users")); - const users = getUsers(); - try { - await collection.insertOne(users[0]); - } catch (err) { - expect(err instanceof DuplicateDocumentError).toEqual(true); - expect(err).toEqual(new DuplicateDocumentError(users[0], collection.storage)); - } - collection.storage.destroy(); - }); -}); diff --git a/tests/memory-storage.test.ts b/tests/memory-storage.test.ts new file mode 100644 index 0000000..9ecce95 --- /dev/null +++ b/tests/memory-storage.test.ts @@ -0,0 +1,93 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "expect"; + +import { MemoryStorage } from "../src/databases/memory/storage.ts"; + +interface TestDoc { + id: string; + name?: string; + age?: number; + tags?: string[]; +} + +describe("MemoryStorage", () => { + it("should insert new records", async () => { + const storage = new MemoryStorage("test", [{ field: "id", kind: "primary" }]); + const documents: TestDoc[] = [{ id: "abc", name: "Alice", age: 30 }]; + + await storage.insert(documents); + + expect(storage.documents).toHaveLength(1); + expect(storage.documents[0]).toEqual(documents[0]); + }); + + it("should retrieve records by index", async () => { + const storage = new MemoryStorage("test", [{ field: "id", kind: "primary" }]); + await storage.insert([ + { id: "abc", name: "Alice" }, + { id: "def", name: "Bob" }, + ]); + + const result = await storage.getByIndex("id", "abc"); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("Alice"); + }); + + it("should find documents by criteria", async () => { + const storage = new MemoryStorage("test", [{ field: "id", kind: "primary" }]); + await storage.insert([ + { id: "1", name: "Alice", age: 30 }, + { id: "2", name: "Bob", age: 25 }, + { id: "3", name: "Charlie", age: 30 }, + ]); + + const results = await storage.find({ age: 30 }); + expect(results).toHaveLength(2); + expect(results.map((r) => r.name).sort()).toEqual(["Alice", "Charlie"]); + }); + + it("should update documents matching a condition", async () => { + const storage = new MemoryStorage("test", [{ field: "id", kind: "primary" }]); + await storage.insert([{ id: "1", name: "Alice", age: 30 }]); + + const updateResult = await storage.update({ id: "1" }, { $set: { age: 31 } }); + expect(updateResult.matchedCount).toBe(1); + expect(updateResult.modifiedCount).toBe(1); + + const updated = await storage.find({ id: "1" }); + expect(updated[0].age).toBe(31); + }); + + it("should remove documents by condition", async () => { + const storage = new MemoryStorage("test", [{ field: "id", kind: "primary" }]); + await storage.insert([ + { id: "1", name: "Alice" }, + { id: "2", name: "Bob" }, + ]); + + const removedCount = await storage.remove({ name: "Bob" }); + expect(removedCount).toBe(1); + + const remaining = await storage.find({}); + expect(remaining).toHaveLength(1); + expect(remaining[0].name).toBe("Alice"); + }); + + it("should count documents matching a condition", async () => { + const storage = new MemoryStorage("test", [{ field: "id", kind: "primary" }]); + await storage.insert([ + { id: "1", name: "Alice", age: 30 }, + { id: "2", name: "Bob", age: 25 }, + { id: "3", name: "Charlie", age: 30 }, + ]); + + const count = await storage.count({ age: 30 }); + expect(count).toBe(2); + }); + + it("should return itself from resolve", async () => { + const storage = new MemoryStorage("test", [{ field: "id", kind: "primary" }]); + const resolved = await storage.resolve(); + expect(resolved).toBe(storage); + }); +}); diff --git a/tests/remove.test.ts b/tests/remove.test.ts deleted file mode 100644 index 94f9fa0..0000000 --- a/tests/remove.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "expect"; - -import { Collection } from "../src/collection.ts"; -import { MemoryStorage } from "../src/databases/memory/storage.ts"; -import { RemoveResult } from "../src/storage/operators/remove.ts"; -import { getUsers } from "./users.mock.ts"; - -/* - |-------------------------------------------------------------------------------- - | Unit Tests - |-------------------------------------------------------------------------------- - */ - -describe("Storage Remove", () => { - it("should successfully delete document", async () => { - const collection = new Collection("users", new MemoryStorage("users")); - const users = getUsers(); - await collection.insertMany(users); - expect(await collection.remove({ id: "user-1" })).toEqual(new RemoveResult(1)); - collection.storage.destroy(); - }); -}); diff --git a/tests/update.test.ts b/tests/update.test.ts deleted file mode 100644 index 3c3b5d5..0000000 --- a/tests/update.test.ts +++ /dev/null @@ -1,1446 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "expect"; -import z from "zod"; - -import { Collection } from "../src/collection.ts"; -import { MemoryStorage } from "../src/databases/memory/storage.ts"; - -/** - * @see https://www.mongodb.com/docs/manual/reference/operator/update-field/#field-update-operators - */ -describe("Field Update Operators", () => { - describe("$set", () => { - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/set/#set-top-level-fields - */ - it("should set top level fields", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id" as const, - schema: { - id: z.string(), - quantity: z.number(), - instock: z.boolean(), - reorder: z.boolean(), - details: z.object({ - model: z.string(), - make: z.string(), - }), - tags: z.array(z.string()), - ratings: z.array( - z.object({ - by: z.string(), - rating: z.number(), - }), - ), - }, - }); - - await collection.insertOne({ - id: "100", - quantity: 250, - instock: true, - reorder: false, - details: { model: "14QQ", make: "Clothes Corp" }, - tags: ["apparel", "clothing"], - ratings: [{ by: "Customer007", rating: 4 }], - }); - - expect( - await collection.updateOne( - { - id: "100", - }, - { - $set: { - quantity: 500, - details: { model: "2600", make: "Fashionaires" }, - tags: ["coats", "outerwear", "clothing"], - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("100")).toEqual({ - id: "100", - quantity: 500, - instock: true, - reorder: false, - details: { model: "2600", make: "Fashionaires" }, - tags: ["coats", "outerwear", "clothing"], - ratings: [{ by: "Customer007", rating: 4 }], - }); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/set/#set-fields-in-embedded-documents - */ - it("should set fields in the embedded documents", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - quantity: z.number(), - instock: z.boolean(), - reorder: z.boolean(), - details: z.object({ - model: z.string(), - make: z.string(), - }), - tags: z.array(z.string()), - ratings: z.array( - z.object({ - by: z.string(), - rating: z.number(), - }), - ), - }, - }); - - await collection.insertOne({ - id: "100", - quantity: 500, - instock: true, - reorder: false, - details: { model: "2600", make: "Fashionaires" }, - tags: ["coats", "outerwear", "clothing"], - ratings: [{ by: "Customer007", rating: 4 }], - }); - - expect( - await collection.updateOne( - { - id: "100", - }, - { - $set: { - "details.make": "Kustom Kidz", - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("100")).toEqual({ - id: "100", - quantity: 500, - instock: true, - reorder: false, - details: { model: "2600", make: "Kustom Kidz" }, - tags: ["coats", "outerwear", "clothing"], - ratings: [{ by: "Customer007", rating: 4 }], - }); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/set/#set-elements-in-arrays - */ - it("should set elements in arrays", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - quantity: z.number(), - instock: z.boolean(), - reorder: z.boolean(), - details: z.object({ - model: z.string(), - make: z.string(), - }), - tags: z.array(z.string()), - ratings: z.array( - z.object({ - by: z.string(), - rating: z.number(), - }), - ), - }, - }); - - await collection.insertOne({ - id: "100", - quantity: 500, - instock: true, - reorder: false, - details: { model: "2600", make: "Kustom Kidz" }, - tags: ["coats", "outerwear", "clothing"], - ratings: [{ by: "Customer007", rating: 4 }], - }); - - expect( - await collection.updateOne( - { - id: "100", - }, - { - $set: { - "tags.1": "rain gear", - "ratings.0.rating": 2, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("100")).toEqual({ - id: "100", - quantity: 500, - instock: true, - reorder: false, - details: { model: "2600", make: "Kustom Kidz" }, - tags: ["coats", "rain gear", "clothing"], - ratings: [{ by: "Customer007", rating: 2 }], - }); - - collection.storage.destroy(); - }); - }); - - describe("$unset", () => { - it("should unset keys", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - item: z.string(), - sku: z.string(), - quantity: z.number(), - instock: z.boolean(), - }, - }); - - await collection.insertMany([ - { id: "1", item: "chisel", sku: "C001", quantity: 4, instock: true }, - { id: "2", item: "hammer", sku: "unknown", quantity: 3, instock: true }, - { id: "3", item: "nails", sku: "unknown", quantity: 100, instock: true }, - ]); - - expect( - await collection.updateOne( - { - sku: "unknown", - }, - { - $unset: { - quantity: "", - instock: "", - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.find()).toEqual([ - { - id: "1", - item: "chisel", - sku: "C001", - quantity: 4, - instock: true, - }, - { - id: "2", - item: "hammer", - sku: "unknown", - }, - { - id: "3", - item: "nails", - sku: "unknown", - quantity: 100, - instock: true, - }, - ]); - - collection.storage.destroy(); - }); - }); -}); - -/** - * @see https://www.mongodb.com/docs/manual/reference/operator/update-array/#array-update-operators - */ -describe("Array Update Operators", () => { - describe("$(update)", () => { - it("should replace a object in an array", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - grades: z.array( - z.object({ - id: z.string(), - value: z.number(), - }), - ), - }, - }); - - await collection.insertOne({ - id: "1", - grades: [ - { - id: "1", - value: 10, - }, - { - id: "2", - value: 10, - }, - ], - }); - - expect( - await collection.updateOne( - { - id: "1", - }, - { - $set: { - "grades.$[element]": { - id: "1", - value: 15, - }, - }, - }, - [ - { - "element.id": "1", - }, - ], - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.find()).toEqual([ - { - id: "1", - grades: [ - { - id: "1", - value: 15, - }, - { - id: "2", - value: 10, - }, - ], - }, - ]); - - collection.storage.destroy(); - }); - - /** - * NOTE! Not supported in mingo because of the lack of $ support - * @see https://www.mongodb.com/docs/manual/reference/operator/update/positional/#update-values-in-an-array - */ - // it("should update values in an array", async () => { - // const collection = new Collection<"id", { grades: number[] }>("students", new MemoryStorage("students", "id")); - - // await collection.insertMany([ - // { id: "1", grades: [85, 80, 80] }, - // { id: "2", grades: [88, 90, 92] }, - // { id: "3", grades: [85, 100, 90] }, - // ]); - - // expect( - // await collection.updateOne( - // { - // id: "1", - // grades: 80, - // }, - // { - // $set: { - // "grades.$[value]": 82, - // }, - // }, - // ), - // ).toEqual({ - // matched: 1, - // modified: 1, - // }); - - // expect(await collection.find()).toEqual([ - // { id: "1", grades: [85, 82, 80] }, - // { id: "2", grades: [88, 90, 92] }, - // { id: "3", grades: [85, 100, 90] }, - // ]); - - // collection.storage.destroy(); - // }); - - /** - * NOTE! Not supported in mingo because of the lack of $ support - * @see https://www.mongodb.com/docs/manual/reference/operator/update/positional/#update-documents-in-an-array - */ - // it("should update documents in an array", async () => { - // const collection = new Collection<"id", { grades: { grade: number; mean: number; std: number }[] }>( - // "students", - // new MemoryStorage("students", "id"), - // ); - - // await collection.insertOne({ - // id: "4", - // grades: [ - // { grade: 80, mean: 75, std: 8 }, - // { grade: 85, mean: 90, std: 5 }, - // { grade: 85, mean: 85, std: 8 }, - // ], - // }); - - // expect( - // await collection.updateOne( - // { - // id: "4", - // "grades.grade": 85, - // }, - // { - // $set: { - // "grades.$.std": 6, - // }, - // }, - // ), - // ).toEqual({ - // matched: 1, - // modified: 1, - // }); - - // expect(await collection.findById("4")).toEqual({ - // id: "4", - // grades: [ - // { grade: 80, mean: 75, std: 8 }, - // { grade: 85, mean: 90, std: 6 }, - // { grade: 85, mean: 85, std: 8 }, - // ], - // }); - - // collection.storage.destroy(); - // }); - - /** - * NOTE! Not supported in mingo because of the lack of $ support - * @see https://www.mongodb.com/docs/manual/reference/operator/update/positional/#update-embedded-documents-using-multiple-field-matches - */ - // it("should update embedded documents using multiple field matches", async () => { - // const collection = new Collection<"id", { grades: { grade: number; mean: number; std: number }[] }>( - // "students", - // new MemoryStorage("students", "id"), - // ); - - // await collection.insertOne({ - // id: "5", - // grades: [ - // { grade: 80, mean: 75, std: 8 }, - // { grade: 85, mean: 90, std: 5 }, - // { grade: 90, mean: 85, std: 3 }, - // ], - // }); - - // expect( - // await collection.updateOne( - // { - // id: "5", - // grades: { - // $elemMatch: { - // grade: { - // $lte: 90, - // }, - // mean: { - // $gt: 80, - // }, - // }, - // }, - // }, - // { - // $set: { - // "grades.$.std": 6, - // }, - // }, - // ), - // ).toEqual({ - // matched: 1, - // modified: 1, - // }); - - // expect(await collection.findById("5")).toEqual({ - // id: "5", - // grades: [ - // { grade: 80, mean: 75, std: 8 }, - // { grade: 85, mean: 90, std: 6 }, - // { grade: 90, mean: 85, std: 3 }, - // ], - // }); - - // collection.storage.destroy(); - // }); - }); - - describe("$pull", () => { - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/pull/#remove-all-items-that-equal-a-specified-value - */ - it("should remove all items that equal a specified value", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - fruits: z.array(z.string()), - vegetables: z.array(z.string()), - }, - }); - - await collection.insertMany([ - { - id: "1", - fruits: ["apples", "pears", "oranges", "grapes", "bananas"], - vegetables: ["carrots", "celery", "squash", "carrots"], - }, - { - id: "2", - fruits: ["plums", "kiwis", "oranges", "bananas", "apples"], - vegetables: ["broccoli", "zucchini", "carrots", "onions"], - }, - ]); - - expect( - await collection.updateMany( - {}, - { - $pull: { - fruits: { - $in: ["apples", "oranges"], - }, - vegetables: "carrots", - }, - }, - ), - ).toEqual({ - matched: 2, - modified: 2, - }); - - expect(await collection.find()).toEqual([ - { - id: "1", - fruits: ["pears", "grapes", "bananas"], - vegetables: ["celery", "squash"], - }, - { - id: "2", - fruits: ["plums", "kiwis", "bananas"], - vegetables: ["broccoli", "zucchini", "onions"], - }, - ]); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/pull/#remove-all-items-that-match-a-specified--pull-condition - */ - it("should remove all items that match a specific $pull condition", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - votes: z.array(z.number()), - }, - }); - - await collection.insertOne({ id: "1", votes: [3, 5, 6, 7, 7, 8] }); - - expect( - await collection.updateOne( - { id: "1" }, - { - $pull: { - votes: { - $gte: 6, - }, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("1")).toEqual({ id: "1", votes: [3, 5] }); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/pull/#remove-items-from-an-array-of-documents - */ - it("should remove items from an array of documents", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - results: z.array( - z.object({ - item: z.string(), - score: z.number(), - }), - ), - }, - }); - - await collection.insertMany([ - { - id: "1", - results: [ - { item: "A", score: 5 }, - { item: "B", score: 8 }, - ], - }, - { - id: "2", - results: [ - { item: "C", score: 8 }, - { item: "B", score: 4 }, - ], - }, - ]); - - expect(await collection.updateMany({}, { $pull: { results: { score: 8, item: "B" } } })).toEqual({ - matched: 2, - modified: 1, - }); - - expect(await collection.find()).toEqual([ - { - id: "1", - results: [{ item: "A", score: 5 }], - }, - { - id: "2", - results: [ - { item: "C", score: 8 }, - { item: "B", score: 4 }, - ], - }, - ]); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/pull/#remove-documents-from-nested-arrays - */ - it("should remove documents from nested arrays", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - results: z.array( - z.object({ - item: z.string(), - score: z.number(), - answers: z.array( - z.object({ - q: z.number(), - a: z.number(), - }), - ), - }), - ), - }, - }); - - await collection.insertMany([ - { - id: "1", - results: [ - { - item: "A", - score: 5, - answers: [ - { q: 1, a: 4 }, - { q: 2, a: 6 }, - ], - }, - { - item: "B", - score: 8, - answers: [ - { q: 1, a: 8 }, - { q: 2, a: 9 }, - ], - }, - ], - }, - { - id: "2", - results: [ - { - item: "C", - score: 8, - answers: [ - { q: 1, a: 8 }, - { q: 2, a: 7 }, - ], - }, - { - item: "B", - score: 4, - answers: [ - { q: 1, a: 0 }, - { q: 2, a: 8 }, - ], - }, - ], - }, - ]); - - expect( - await collection.updateMany( - {}, - { - $pull: { - results: { - answers: { $elemMatch: { q: 2, a: { $gte: 8 } } }, - }, - }, - }, - ), - ).toEqual({ - matched: 2, - modified: 2, - }); - - expect(await collection.find()).toEqual([ - { - id: "1", - results: [ - { - item: "A", - score: 5, - answers: [ - { q: 1, a: 4 }, - { q: 2, a: 6 }, - ], - }, - ], - }, - { - id: "2", - results: [ - { - item: "C", - score: 8, - answers: [ - { q: 1, a: 8 }, - { q: 2, a: 7 }, - ], - }, - ], - }, - ]); - - collection.storage.destroy(); - }); - }); - - describe("$push", () => { - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/push/#append-a-value-to-an-array - */ - it("should append a value to an array", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - scores: z.array(z.number()), - }, - }); - - await collection.insertOne({ id: "1", scores: [44, 78, 38, 80] }); - - expect( - await collection.updateOne( - { id: "1" }, - { - $push: { - scores: 89, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findOne({ id: "1" })).toEqual({ id: "1", scores: [44, 78, 38, 80, 89] }); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/push/#append-a-value-to-arrays-in-multiple-documents - */ - it("should append a value to arrays in multiple documents", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - scores: z.array(z.number()), - }, - }); - - await collection.insertMany([ - { id: "1", scores: [44, 78, 38, 80, 89] }, - { id: "2", scores: [45, 78, 38, 80, 89] }, - { id: "3", scores: [46, 78, 38, 80, 89] }, - { id: "4", scores: [47, 78, 38, 80, 89] }, - ]); - - expect( - await collection.updateMany( - {}, - { - $push: { - scores: 95, - }, - }, - ), - ).toEqual({ - matched: 4, - modified: 4, - }); - - expect(await collection.find()).toEqual([ - { id: "1", scores: [44, 78, 38, 80, 89, 95] }, - { id: "2", scores: [45, 78, 38, 80, 89, 95] }, - { id: "3", scores: [46, 78, 38, 80, 89, 95] }, - { id: "4", scores: [47, 78, 38, 80, 89, 95] }, - ]); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/push/#append-multiple-values-to-an-array - */ - it("should append multiple values to an array", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - name: z.string(), - scores: z.array(z.number()), - }, - }); - - await collection.insertOne({ id: "1", name: "Joe", scores: [44, 78] }); - - expect( - await collection.updateOne( - { name: "Joe" }, - { - $push: { - scores: { - $each: [90, 92, 85], - }, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findOne({ id: "1" })).toEqual({ id: "1", name: "Joe", scores: [44, 78, 90, 92, 85] }); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/push/#use--push-operator-with-multiple-modifiers - */ - it("should use $push operator with multiple modifiers", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - quizzes: z.array( - z.object({ - wk: z.number(), - score: z.number(), - }), - ), - }, - }); - - await collection.insertOne({ - id: "5", - quizzes: [ - { wk: 1, score: 10 }, - { wk: 2, score: 8 }, - { wk: 3, score: 5 }, - { wk: 4, score: 6 }, - ], - }); - - expect( - await collection.updateOne( - { id: "5" }, - { - $push: { - quizzes: { - $each: [ - { wk: 5, score: 8 }, - { wk: 6, score: 7 }, - { wk: 7, score: 6 }, - ], - $sort: { score: -1 }, - $slice: 3, - }, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("5")).toEqual({ - id: "5", - quizzes: [ - { wk: 1, score: 10 }, - { wk: 2, score: 8 }, - { wk: 5, score: 8 }, - ], - }); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/slice/#slice-from-the-end-of-the-array - */ - it("should slice from the end of the array", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - scores: z.array(z.number()), - }, - }); - - await collection.insertOne({ id: "1", scores: [40, 50, 60] }); - - expect( - await collection.updateOne( - { id: "1" }, - { - $push: { - scores: { - $each: [80, 78, 86], - $slice: -5, - }, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("1")).toEqual({ id: "1", scores: [50, 60, 80, 78, 86] }); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/slice/#slice-from-the-front-of-the-array - */ - it("should slice from the front of the array", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - scores: z.array(z.number()), - }, - }); - - await collection.insertOne({ id: "2", scores: [89, 90] }); - - expect( - await collection.updateOne( - { id: "2" }, - { - $push: { - scores: { - $each: [100, 20], - $slice: 3, - }, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("2")).toEqual({ id: "2", scores: [89, 90, 100] }); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/slice/#update-array-using-slice-only - */ - it("should update array using slice only", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - scores: z.array(z.number()), - }, - }); - - await collection.insertOne({ id: "3", scores: [89, 70, 100, 20] }); - - expect( - await collection.updateOne( - { id: "3" }, - { - $push: { - scores: { - $each: [], - $slice: -3, - }, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("3")).toEqual({ id: "3", scores: [70, 100, 20] }); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/position/#add-elements-at-the-start-of-the-array - */ - it("should add elements to the start of the array", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - scores: z.array(z.number()), - }, - }); - - await collection.insertOne({ id: "1", scores: [100] }); - - expect( - await collection.updateOne( - { id: "1" }, - { - $push: { - scores: { - $each: [50, 60, 70], - $position: 0, - }, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("1")).toEqual({ id: "1", scores: [50, 60, 70, 100] }); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/position/#add-elements-to-the-middle-of-the-array - */ - it("should add elements to the middle of the array", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - scores: z.array(z.number()), - }, - }); - - await collection.insertOne({ id: "2", scores: [50, 60, 70, 100] }); - - expect( - await collection.updateOne( - { id: "2" }, - { - $push: { - scores: { - $each: [20, 30], - $position: 2, - }, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("2")).toEqual({ id: "2", scores: [50, 60, 20, 30, 70, 100] }); - - collection.storage.destroy(); - }); - - it("should use a negative index to add elements to the array", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - scores: z.array(z.number()), - }, - }); - - await collection.insertOne({ id: "3", scores: [50, 60, 20, 30, 70, 100] }); - - expect( - await collection.updateOne( - { id: "3" }, - { - $push: { - scores: { - $each: [90, 80], - $position: -2, - }, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("3")).toEqual({ id: "3", scores: [50, 60, 20, 30, 90, 80, 70, 100] }); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/sort/#sort-array-of-documents-by-a-field-in-the-documents - */ - it("should sort array of documents by a field in the documents", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - quizzes: z.array( - z.object({ - id: z.number(), - score: z.number(), - }), - ), - }, - }); - - await collection.insertOne({ - id: "1", - quizzes: [ - { id: 1, score: 6 }, - { id: 2, score: 9 }, - ], - }); - - expect( - await collection.updateOne( - { id: "1" }, - { - $push: { - quizzes: { - $each: [ - { id: 3, score: 8 }, - { id: 4, score: 7 }, - { id: 5, score: 6 }, - ], - $sort: { score: 1 }, - }, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("1")).toEqual({ - id: "1", - quizzes: [ - { id: 1, score: 6 }, - { id: 5, score: 6 }, - { id: 4, score: 7 }, - { id: 3, score: 8 }, - { id: 2, score: 9 }, - ], - }); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/sort/#sort-array-elements-that-are-not-documents - */ - it("should sort array elements that are not documents", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - tests: z.array(z.number()), - }, - }); - - await collection.insertOne({ id: "2", tests: [89, 70, 89, 50] }); - - expect( - await collection.updateOne( - { - id: "2", - }, - { - $push: { - tests: { - $each: [40, 60], - $sort: 1, - }, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("2")).toEqual({ id: "2", tests: [40, 50, 60, 70, 89, 89] }); - - collection.storage.destroy(); - }); - - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/sort/#update-array-using-sort-only - */ - it("should update array using sort only", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - tests: z.array(z.number()), - }, - }); - - await collection.insertOne({ id: "3", tests: [89, 70, 100, 20] }); - - expect( - await collection.updateOne( - { - id: "3", - }, - { - $push: { - tests: { - $each: [], - $sort: -1, - }, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("3")).toEqual({ id: "3", tests: [100, 89, 70, 20] }); - - collection.storage.destroy(); - }); - }); - - describe("$inc", () => { - /** - * @see https://www.mongodb.com/docs/manual/reference/operator/update/inc - */ - it("should increment and decrement values", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - sku: z.string(), - quantity: z.number(), - metrics: z.object({ - orders: z.number(), - ratings: z.number(), - }), - }, - }); - - await collection.insertOne({ id: "1", sku: "abc123", quantity: 10, metrics: { orders: 2, ratings: 3.5 } }); - - expect( - await collection.updateOne( - { - id: "1", - }, - { - $inc: { - quantity: -2, - "metrics.orders": 1, - }, - }, - ), - ).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("1")).toEqual({ - id: "1", - sku: "abc123", - quantity: 8, - metrics: { orders: 3, ratings: 3.5 }, - }); - - collection.storage.destroy(); - }); - - it("should increment value of an array element with array index", async () => { - const collection = new Collection({ - name: "test", - adapter: new MemoryStorage("tests"), - primaryKey: "id", - schema: { - id: z.string(), - details: z.array( - z.object({ - id: z.number(), - quantity: z.number(), - }), - ), - }, - }); - - await collection.insertOne({ - id: "3", - details: [ - { - id: 1, - quantity: 4, - }, - { - id: 2, - quantity: 3, - }, - { - id: 3, - quantity: 2, - }, - { - id: 4, - quantity: 7, - }, - ], - }); - - expect(await collection.updateOne({ id: "3" }, { $inc: { "details.0.quantity": 10 } })).toEqual({ - matched: 1, - modified: 1, - }); - - expect(await collection.findById("3")).toEqual({ - id: "3", - details: [ - { - id: 1, - quantity: 14, - }, - { - id: 2, - quantity: 3, - }, - { - id: 3, - quantity: 2, - }, - { - id: 4, - quantity: 7, - }, - ], - }); - - collection.storage.destroy(); - }); - }); -}); diff --git a/tests/users.mock.ts b/tests/users.mock.ts deleted file mode 100644 index 22843cb..0000000 --- a/tests/users.mock.ts +++ /dev/null @@ -1,43 +0,0 @@ -const users: UserDocument[] = [ - { - id: "user-1", - name: "John Doe", - email: "john.doe@test.none", - friends: [ - { - id: "user-2", - alias: "Jane", - }, - ], - interests: ["movies", "tv", "sports"], - }, - { - id: "user-2", - name: "Jane Doe", - email: "jane.doe@test.none", - friends: [ - { - id: "user-1", - alias: "John", - }, - ], - interests: ["movies", "fitness", "dance"], - }, -]; - -export function getUsers(): UserDocument[] { - return JSON.parse(JSON.stringify(users)); -} - -export type UserDocument = { - id: string; - name: string; - email: string; - friends: Friend[]; - interests: string[]; -}; - -type Friend = { - id: string; - alias: string; -};