From a209d105661d0939cb2397ada0e480d62c312c7b Mon Sep 17 00:00:00 2001 From: nvms Date: Wed, 28 Aug 2024 09:08:11 -0400 Subject: [PATCH] degit arc --- packages/arc | 1 - packages/arc-degit/.gitignore | 2 + packages/arc-degit/.npmignore | 4 + packages/arc-degit/README.md | 1039 +++++++++++++++++ packages/arc-degit/bump.config.ts | 7 + packages/arc-degit/bun.lockb | Bin 0 -> 95384 bytes packages/arc-degit/package.json | 34 + packages/arc-degit/src/adapter/enc_fs.ts | 100 ++ packages/arc-degit/src/adapter/fs.ts | 85 ++ packages/arc-degit/src/adapter/index.ts | 14 + .../arc-degit/src/adapter/localStorage.ts | 27 + packages/arc-degit/src/append_props.ts | 55 + packages/arc-degit/src/change_props.ts | 67 ++ packages/arc-degit/src/collection.ts | 528 +++++++++ packages/arc-degit/src/find.ts | 121 ++ packages/arc-degit/src/ids.ts | 66 ++ packages/arc-degit/src/index.ts | 6 + .../arc-degit/src/operators/boolean/and.ts | 29 + .../arc-degit/src/operators/boolean/fn.ts | 28 + .../arc-degit/src/operators/boolean/gtlt.ts | 63 + .../arc-degit/src/operators/boolean/has.ts | 30 + .../arc-degit/src/operators/boolean/hasAny.ts | 18 + .../src/operators/boolean/includes.ts | 26 + .../arc-degit/src/operators/boolean/length.ts | 25 + .../arc-degit/src/operators/boolean/not.ts | 35 + .../arc-degit/src/operators/boolean/oneOf.ts | 26 + .../arc-degit/src/operators/boolean/or.ts | 28 + .../arc-degit/src/operators/boolean/re.ts | 14 + .../arc-degit/src/operators/boolean/xor.ts | 39 + packages/arc-degit/src/operators/index.ts | 79 ++ .../src/operators/mutation/change.ts | 43 + .../src/operators/mutation/filter.ts | 48 + .../arc-degit/src/operators/mutation/map.ts | 19 + .../arc-degit/src/operators/mutation/math.ts | 221 ++++ .../arc-degit/src/operators/mutation/merge.ts | 18 + .../arc-degit/src/operators/mutation/push.ts | 30 + .../arc-degit/src/operators/mutation/set.ts | 37 + .../arc-degit/src/operators/mutation/unset.ts | 26 + .../src/operators/mutation/unshift.ts | 33 + packages/arc-degit/src/query_options.ts | 244 ++++ packages/arc-degit/src/return_found.ts | 174 +++ packages/arc-degit/src/sharded_collection.ts | 141 +++ packages/arc-degit/src/transaction.ts | 124 ++ packages/arc-degit/src/update.ts | 82 ++ packages/arc-degit/src/utils.ts | 57 + packages/arc-degit/tests/common.ts | 68 ++ packages/arc-degit/tests/index.ts | 58 + .../tests/specs/core/appendProps.test.ts | 69 ++ .../tests/specs/core/changeProps.test.ts | 112 ++ packages/arc-degit/tests/specs/core/index.ts | 10 + .../tests/specs/core/returnFound.test.ts | 81 ++ .../specs/encrypted_adapter/adapter.test.ts | 32 + .../tests/specs/encrypted_adapter/index.ts | 5 + .../tests/specs/filter/basic.test.ts | 26 + .../arc-degit/tests/specs/filter/index.ts | 7 + .../tests/specs/finding/basic.test.ts | 131 +++ .../arc-degit/tests/specs/finding/index.ts | 7 + .../arc-degit/tests/specs/from/from.test.ts | 17 + packages/arc-degit/tests/specs/from/index.ts | 7 + packages/arc-degit/tests/specs/index.test.ts | 81 ++ .../tests/specs/insert/basic.test.ts | 29 + .../arc-degit/tests/specs/insert/index.ts | 7 + .../tests/specs/operators/boolean/and.test.ts | 80 ++ .../tests/specs/operators/boolean/fn.test.ts | 59 + .../specs/operators/boolean/gtlt.test.ts | 142 +++ .../tests/specs/operators/boolean/has.test.ts | 39 + .../specs/operators/boolean/hasAny.test.ts | 51 + .../specs/operators/boolean/includes.test.ts | 55 + .../tests/specs/operators/boolean/index.ts | 18 + .../specs/operators/boolean/length.test.ts | 17 + .../tests/specs/operators/boolean/not.test.ts | 223 ++++ .../specs/operators/boolean/oneOf.test.ts | 36 + .../tests/specs/operators/boolean/or.test.ts | 53 + .../tests/specs/operators/boolean/re.test.ts | 30 + .../tests/specs/operators/boolean/xor.test.ts | 75 ++ .../specs/operators/mutation/change.test.ts | 57 + .../specs/operators/mutation/filter.test.ts | 26 + .../tests/specs/operators/mutation/index.ts | 15 + .../specs/operators/mutation/map.test.ts | 25 + .../specs/operators/mutation/math.test.ts | 270 +++++ .../specs/operators/mutation/merge.test.ts | 41 + .../specs/operators/mutation/push.test.ts | 73 ++ .../specs/operators/mutation/set.test.ts | 58 + .../specs/operators/mutation/unset.test.ts | 46 + .../specs/operators/mutation/unshift.test.ts | 73 ++ .../tests/specs/options/ifEmpty.test.ts | 48 + .../tests/specs/options/ifNull.test.ts | 70 ++ .../tests/specs/options/ifNullOrEmpty.test.ts | 35 + .../arc-degit/tests/specs/options/index.ts | 14 + .../tests/specs/options/integerIds.test.ts | 21 + .../tests/specs/options/join.test.ts | 318 +++++ .../tests/specs/options/project.test.ts | 221 ++++ .../tests/specs/options/skip_take.test.ts | 37 + .../tests/specs/options/sort.test.ts | 57 + .../tests/specs/remove/basic.test.ts | 24 + .../arc-degit/tests/specs/remove/index.ts | 7 + .../specs/sharded_collection/basic.test.ts | 30 + .../tests/specs/sharded_collection/index.ts | 7 + .../tests/specs/transactions/basic.test.ts | 180 +++ .../tests/specs/transactions/index.ts | 5 + .../arc-degit/tests/specs/upsert/index.ts | 5 + .../tests/specs/upsert/upsert.test.ts | 44 + packages/arc-degit/tests/specs/utils/index.ts | 6 + .../specs/utils/stripBooleanModifiers.test.ts | 38 + packages/arc-degit/tsconfig.json | 11 + packages/arc-degit/tsup.config.ts | 11 + 106 files changed, 7390 insertions(+), 1 deletion(-) delete mode 160000 packages/arc create mode 100644 packages/arc-degit/.gitignore create mode 100644 packages/arc-degit/.npmignore create mode 100644 packages/arc-degit/README.md create mode 100644 packages/arc-degit/bump.config.ts create mode 100755 packages/arc-degit/bun.lockb create mode 100644 packages/arc-degit/package.json create mode 100644 packages/arc-degit/src/adapter/enc_fs.ts create mode 100644 packages/arc-degit/src/adapter/fs.ts create mode 100644 packages/arc-degit/src/adapter/index.ts create mode 100644 packages/arc-degit/src/adapter/localStorage.ts create mode 100644 packages/arc-degit/src/append_props.ts create mode 100644 packages/arc-degit/src/change_props.ts create mode 100644 packages/arc-degit/src/collection.ts create mode 100644 packages/arc-degit/src/find.ts create mode 100644 packages/arc-degit/src/ids.ts create mode 100644 packages/arc-degit/src/index.ts create mode 100644 packages/arc-degit/src/operators/boolean/and.ts create mode 100644 packages/arc-degit/src/operators/boolean/fn.ts create mode 100644 packages/arc-degit/src/operators/boolean/gtlt.ts create mode 100644 packages/arc-degit/src/operators/boolean/has.ts create mode 100644 packages/arc-degit/src/operators/boolean/hasAny.ts create mode 100644 packages/arc-degit/src/operators/boolean/includes.ts create mode 100644 packages/arc-degit/src/operators/boolean/length.ts create mode 100644 packages/arc-degit/src/operators/boolean/not.ts create mode 100644 packages/arc-degit/src/operators/boolean/oneOf.ts create mode 100644 packages/arc-degit/src/operators/boolean/or.ts create mode 100644 packages/arc-degit/src/operators/boolean/re.ts create mode 100644 packages/arc-degit/src/operators/boolean/xor.ts create mode 100644 packages/arc-degit/src/operators/index.ts create mode 100644 packages/arc-degit/src/operators/mutation/change.ts create mode 100644 packages/arc-degit/src/operators/mutation/filter.ts create mode 100644 packages/arc-degit/src/operators/mutation/map.ts create mode 100644 packages/arc-degit/src/operators/mutation/math.ts create mode 100644 packages/arc-degit/src/operators/mutation/merge.ts create mode 100644 packages/arc-degit/src/operators/mutation/push.ts create mode 100644 packages/arc-degit/src/operators/mutation/set.ts create mode 100644 packages/arc-degit/src/operators/mutation/unset.ts create mode 100644 packages/arc-degit/src/operators/mutation/unshift.ts create mode 100644 packages/arc-degit/src/query_options.ts create mode 100644 packages/arc-degit/src/return_found.ts create mode 100644 packages/arc-degit/src/sharded_collection.ts create mode 100644 packages/arc-degit/src/transaction.ts create mode 100644 packages/arc-degit/src/update.ts create mode 100644 packages/arc-degit/src/utils.ts create mode 100644 packages/arc-degit/tests/common.ts create mode 100644 packages/arc-degit/tests/index.ts create mode 100644 packages/arc-degit/tests/specs/core/appendProps.test.ts create mode 100644 packages/arc-degit/tests/specs/core/changeProps.test.ts create mode 100644 packages/arc-degit/tests/specs/core/index.ts create mode 100644 packages/arc-degit/tests/specs/core/returnFound.test.ts create mode 100644 packages/arc-degit/tests/specs/encrypted_adapter/adapter.test.ts create mode 100644 packages/arc-degit/tests/specs/encrypted_adapter/index.ts create mode 100644 packages/arc-degit/tests/specs/filter/basic.test.ts create mode 100644 packages/arc-degit/tests/specs/filter/index.ts create mode 100644 packages/arc-degit/tests/specs/finding/basic.test.ts create mode 100644 packages/arc-degit/tests/specs/finding/index.ts create mode 100644 packages/arc-degit/tests/specs/from/from.test.ts create mode 100644 packages/arc-degit/tests/specs/from/index.ts create mode 100644 packages/arc-degit/tests/specs/index.test.ts create mode 100644 packages/arc-degit/tests/specs/insert/basic.test.ts create mode 100644 packages/arc-degit/tests/specs/insert/index.ts create mode 100644 packages/arc-degit/tests/specs/operators/boolean/and.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/boolean/fn.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/boolean/gtlt.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/boolean/has.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/boolean/hasAny.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/boolean/includes.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/boolean/index.ts create mode 100644 packages/arc-degit/tests/specs/operators/boolean/length.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/boolean/not.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/boolean/oneOf.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/boolean/or.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/boolean/re.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/boolean/xor.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/mutation/change.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/mutation/filter.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/mutation/index.ts create mode 100644 packages/arc-degit/tests/specs/operators/mutation/map.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/mutation/math.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/mutation/merge.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/mutation/push.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/mutation/set.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/mutation/unset.test.ts create mode 100644 packages/arc-degit/tests/specs/operators/mutation/unshift.test.ts create mode 100644 packages/arc-degit/tests/specs/options/ifEmpty.test.ts create mode 100644 packages/arc-degit/tests/specs/options/ifNull.test.ts create mode 100644 packages/arc-degit/tests/specs/options/ifNullOrEmpty.test.ts create mode 100644 packages/arc-degit/tests/specs/options/index.ts create mode 100644 packages/arc-degit/tests/specs/options/integerIds.test.ts create mode 100644 packages/arc-degit/tests/specs/options/join.test.ts create mode 100644 packages/arc-degit/tests/specs/options/project.test.ts create mode 100644 packages/arc-degit/tests/specs/options/skip_take.test.ts create mode 100644 packages/arc-degit/tests/specs/options/sort.test.ts create mode 100644 packages/arc-degit/tests/specs/remove/basic.test.ts create mode 100644 packages/arc-degit/tests/specs/remove/index.ts create mode 100644 packages/arc-degit/tests/specs/sharded_collection/basic.test.ts create mode 100644 packages/arc-degit/tests/specs/sharded_collection/index.ts create mode 100644 packages/arc-degit/tests/specs/transactions/basic.test.ts create mode 100644 packages/arc-degit/tests/specs/transactions/index.ts create mode 100644 packages/arc-degit/tests/specs/upsert/index.ts create mode 100644 packages/arc-degit/tests/specs/upsert/upsert.test.ts create mode 100644 packages/arc-degit/tests/specs/utils/index.ts create mode 100644 packages/arc-degit/tests/specs/utils/stripBooleanModifiers.test.ts create mode 100644 packages/arc-degit/tsconfig.json create mode 100644 packages/arc-degit/tsup.config.ts diff --git a/packages/arc b/packages/arc deleted file mode 160000 index 595e0b4..0000000 --- a/packages/arc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 595e0b41961a6504b957173fa6470e4e21d16296 diff --git a/packages/arc-degit/.gitignore b/packages/arc-degit/.gitignore new file mode 100644 index 0000000..e1f044d --- /dev/null +++ b/packages/arc-degit/.gitignore @@ -0,0 +1,2 @@ +.test +benchmark diff --git a/packages/arc-degit/.npmignore b/packages/arc-degit/.npmignore new file mode 100644 index 0000000..37b88ac --- /dev/null +++ b/packages/arc-degit/.npmignore @@ -0,0 +1,4 @@ +node_modules +tests +.test +src diff --git a/packages/arc-degit/README.md b/packages/arc-degit/README.md new file mode 100644 index 0000000..dfb537a --- /dev/null +++ b/packages/arc-degit/README.md @@ -0,0 +1,1039 @@ +# arc + +[![NPM version](https://img.shields.io/npm/v/@prsm/arc?color=a1b858&label=)](https://www.npmjs.com/package/@prsm/arc) + +This is a lightweight, in-memory, optionally persistent, and fully JavaScript-based document database. You can use it with node, in a browser using the localStorage adapter, or as an embedded database solution for your electron app. + +_Please note that this library is currently under active development and its API may evolve as new features are added. However, it is unlikely that any breaking changes will be introduced._ + + + +* [Installation](#installation) +* [Quick note before you read on](#quick-note-before-you-read-on) + * [@prsm/arc-server](#prsmarc-server) + * [@prsm/arc-client](#prsmarc-client) +* [API overview](#api-overview) + * [Creating a collection](#creating-a-collection) + * [Persistence](#persistence) + * [Storage adapters](#storage-adapters) + * [Using another adapter](#using-another-adapter) + * [Auto sync](#auto-sync) + * [Indexing](#indexing) + * [Index limitations](#index-limitations) + * [Inserting](#inserting) + * [Finding](#finding) + * [Boolean operators](#boolean-operators) + * [$and](#and) + * [$not](#not) + * [$or](#or) + * [$xor](#xor) + * [$has](#has) + * [$hasAny](#hasany) + * [$includes](#includes) + * [$length](#length) + * [$oneOf](#oneof) + * [$re](#re) + * [$fn](#fn) + * [$gt, $gte, $lt, $lte](#gt-gte-lt-lte) + * [Updating](#updating) + * [Mutation operators](#mutation-operators) + * [$set](#set) + * [$unset](#unset) + * [$change](#change) + * [$push](#push) + * [$unshift](#unshift) + * [$merge](#merge) + * [$map](#map) + * [$inc, $dev, $mult, $div](#inc-dev-mult-div) + * [Filtering](#filtering) + * [Removing](#removing) + * [Query options](#query-options) + * [ifNull](#ifnull) + * [ifEmpty](#ifempty) + * [ifNullOrEmpty](#ifnullorempty) + * [Sorting](#sorting) + * [Skip & take (i.e. LIMIT)](#skip--take-ie-limit) + * [Projection](#projection) + * [Implicit exclusion](#implicit-exclusion) + * [Implicit inclusion](#implicit-inclusion) + * [Explicit](#explicit) + * [Aggregation](#aggregation) + * [Joining](#joining) + * [Misc](#misc) + * [Builtin property name defaults](#builtin-property-name-defaults) + * [Documents](#documents) + + + +# Installation + +```bash +npm i @prsm/arc +``` + +# Quick note before you read on + +arc runs entirely in-process — not as a port-bound service. [`@prsm/arc-server`](https://github.com/node-prism/arc-server) fills this gap. + +## @prsm/arc-server + +[https://github.com/node-prism/arc-server](https://github.com/node-prism/arc-server) + +If you'd prefer to run this as a self-contained service within your stack that accepts connections over TCP, authenticates them, and receives and responds to queries in this manner, you may want to use `@prsm/arc-server`. It's currently moderately opinionated in the way that it handles authentication (no RBAC or Collection-level permissions), so if it does not meet your expectations, you may consider developing your own service-based solution. + +## @prsm/arc-client + +[https://github.com/node-prism/arc-client](https://github.com/node-prism/arc-client) + +If you decide to use [`@prsm/arc-server`](https://github.com/node-prism/arc-server), you most likely also want to use [`@prsm/arc-client`](https://github.com/node-prism/arc-client) as a means of simplifying communication with `@prsm/arc-server` + +# API overview + +For a comprehensive API reference, please refer to the [tests](./tests/specs/) in this repository. + +## Creating a collection + +A collection is just a `.json` file when you're using the default `FSAdapter`. + +```typescript +import { Collection, FSAdapter } from "@prsm/arc"; + +type Planet = { + planet: { + name: string; + population?: number; + moons: string[]; + temp: { + avg: number; + }; + composition: { + type: "gas" | "molten" | "ice"; + }; + }; +}; + +// from `./.data` load or create `planets.json` +const collection = new Collection({ + adapter: new FSAdapter({ storagePath: ".data", name: "planets" }), +}); +``` + +## Persistence + +### Storage adapters + +The method of data retrieval and storage depends on the `StorageAdapter` used by the collection. The default storage adapter is `FSAdapter`, which reads and writes data to a file. To achieve persistence in a browser environment, you may use the included `LocalStorageAdapter`. Alternatively, you can create a custom adapter by implementing the `StorageAdapter` interface. Additionally, an `EncryptedFSAdapter` is available that encrypts data before writing and decrypts it before reading. + +### Using another adapter + +```typescript +import { EncryptedFSAdapter } from "@prsm/arc"; + +process.env.ARC_ENCFS_KEY = "Mahpsee2X7TKLe1xwJYmar91pCSaZIY7"; + +new Collection({ + autosync: false, + adapter: new EncryptedFSAdapter({ storagePath: ".data", name: "planets" }), +}); +``` + +### Auto sync + +By default, any operation that modifies data is followed by a synchronization using the adapter with which the collection was initialized. You have the option to disable this `autosync` feature during collection creation: + +```typescript +new Collection({ + autosync: false, + adapter: new FSAdapter({ storagePath: ".data", name: "planets" }), +}); +``` + +When `autosync` is disabled, you must call `collection.sync()` to persist, which calls the in-use adapter's `write` method. + +## Indexing + +- Indexes can be deeply nested properties, e.g.: `createIndex({ key: "planet.composition.type" })` + + When defining indexes using dot notation, the performance benefit of using indexes is the same whether you choose to find documents by using dot notation syntax or object syntax. In other words, the queries below provide the same performance benefit. + + ```typescript + find({ "planet.composition.type": "gas" }); + find({ planet: { composition: { type: "gas" } } }); + ``` + +- The value of the key must be a type that can be converted to a string using `String(value)`. +- Indexes can optionally enforce a unique constraint, e.g.: `createIndex({ key: "planet.life.dominant_species", unique: true })` +- You can create an index at any time, even if your database has existing records with the index key provided, although ideally they are defined at the point of database creation. + +In large databases, **_especially_** with complex documents, you will see a noticeable performance boost when making practical use of indexes: + +In a collection made up of 1,000,000 `Planet` documents: + +- Without an index on `planet.composition.type`, a `find({ "planet.composition.type": "gas" })` takes an average of 2s. +- With an index on `planet.composition.type`, a `find({ "planet.composition.type": "gas" })` takes an average of 25ms, which is **_80x faster_**. + +These numbers were seen while benchmarking on a 2022 M1. YMMV. + +### Index limitations + +You can't combine boolean expressions with indexes, because the result of the expression isn't known until the expression is evaluated, which defeats the purpose of an index entirely. In other words, the following would be true assuming you had an index key defined at "planet.composition.type": + +```typescript +// This bypasses known index records for the key "planet.composition.type", +// because the documents that match the provided expression cannot be known +// until the `$oneOf` expression is evaluated. +find({ "planet.composition.type": { $oneOf: ["gas", "molten"] } }); + +// Instead, if performance was a concern for this query, you'd be better off +// doing something like this: +const gas = find({ "planet.composition.type": "gas" }); // index hit +const molten = find({ "planet.composition.type": "molten" }); // index hit +``` + +## Inserting + +See the [inserting tests](tests/specs/insert/basic.test.ts) for more examples. + +```typescript +insert({ + planet: { + name: "Mercury", + moons: [], + temp: { avg: 475 }, + composition: { + type: "molten", + }, + }, +}); +insert([ + { + planet: { + name: "Venus", + moons: [], + temp: { avg: 737_000 }, + composition: { + type: "molten", + }, + }, + }, + { + planet: { + name: "Earth", + population: 8_000_000_000, + moons: ["Luna"], + temp: { avg: 13 }, + composition: { + type: "molten", + }, + }, + }, + { + planet: { + name: "Jupiter", + moons: ["Io", "Europa", "Ganymede"], + temp: { avg: -145 }, + composition: { + type: "gas", + }, + }, + }, +]); +``` + +## Finding + +arc's query syntax is uncomplicated and, with the many builtin boolean operators, enables the creation of complex yet intelligible queries. These boolean operators, described below, may seem familiar to those who have experience with either [MongoDB](https://www.mongodb.com) or [NeDB](https://github.com/louischatriot/nedb). + +See the [finding tests](tests/specs/finding/basic.test.ts) for more examples. + +Here's a brief overview: + +```typescript +find({ avg: -145 }); // implicit deep searching +find({ planet: { temp: { avg: -145 } } }); // explicit deep searching +find({ "planet.temp.avg": -145 }); // dot notation +find({ avg: { $gt: 12_000 } }); +find({ temp: { avg: { $lt: 1_000 } } }); +find({ "planet.temp.avg": { $lt: 1_000 } }); +find({ $and: [{ avg: { $gt: 100 } }, { avg: { $lt: 10_000 } }] }); +find({ + $and: [{ $not: { $has: "planet.population" } }, { moons: { $gt: 1 } }], +}); +find({ $and: [{ "planet.temp.avg": { $gt: 100 } }, { avg: { $lt: 10_000 } }] }); +find({ planet: { name: { $length: { $gt: 7 } } } }); // string length +find({ "planet.moons": { $length: 1 } }); // array length +find({ "planet.composition.type": { $oneOf: ["molten", "gas"] } }); + +// etc. +find({ $not: { a: 1, b: 2 } }); +find({ $not: { "planet.temp.avg": { $gt: 10_000 } } }); +find({ $and: [{ $not: { a: { $lte: 2 } } }, { $not: { a: { $gte: 5 } } }] }); +find({ $xor: [{ planet: { $includes: "art" } }, { num: { $lt: 9 } }] }); +``` + +### Boolean operators + +#### $and + +[Read tests](tests/specs/operators/boolean/and.test.ts) + +```typescript +find({ $and: [{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }] }); +find({ + $and: [ + { "planet.name": { $includes: "Ea" } }, + { population: { $gt: 1_000_000 } }, + ], +}); +find({ + $and: [ + { "planet.composition.type": "gas" }, + { planet: { moons: { $length: { $gt: 5 } } } }, + ], +}); +``` + +#### $not + +[Read tests](tests/specs/operators/boolean/not.test.ts) + +```typescript +// { a: 1, b: 2 } +// { nested: { a: 1 } } +find({ $not: { a: 1 } }); // Won't return either of the above +find({ $not: { "nested.a": 1 } }); // Returns the first document + +// $not is commonly used with other boolean operators, like $and: +// { nested: { a: 1, b: 2 } } +find({ + $and: [ + { $not: { "nested.a": 1 } }, + { $not: { "nested.b": 2 } }, + ], +}); + +// or $includes: +find({ $not: { moons: { $includes: "Io" } } }); +find({ $not: { planet: { moons: { $includes: ["Io", "Ganymede"] } } } }); +``` + +#### $or + +[Read tests](tests/specs/operators/boolean/or.test.ts) + +```typescript +find({ + $or: [ + { planet: { temp: { avg: { $lt: 100 } } } }, + { "planet.temp.avg": { $gt: 5_000 } }, + ], +}); +find({ $or: [{ a: 1 }, { a: 2 }, { a: 3 }, { a: 4 }] }); +``` + +#### $xor + +[Read tests](tests/specs/operators/boolean/xor.test.ts) + +```typescript +find({ $xor: [{ a: 1 }, { b: 2 }] }); +find({ + $xor: [{ $has: "planet.population" }, { "planet.moons": { $length: 0 } }], +}); +``` + +#### $has + +`$has` returns documents that have the provided properties, and expects property references to be provided in dot notation. + +[Read tests](tests/specs/operators/boolean/has.test.ts) + +```typescript +find({ $has: "planet.population" }); +find({ $has: ["planet.population", "planet.temp.avg"] }); // documents that have BOTH of these properties +find({ $not: { $has: "planet.temp.avg" } }); +find({ $not: { "planet.temp": { $has: "avg" } } }); +``` + +#### $hasAny + +`$hasAny` returns documents that have any of the provided properties, and expects property references to be provided in dot notation. + +[Read tests](tests/specs/operators/boolean/hasAny.test.ts) + +```typescript +find({ $hasAny: ["planet.population", "planet.temp.avg"] }); // documents that have EITHER of these properties +find({ planet: { $hasAny: ["population", "temp.avg"] } }); // effectively the same as above +find({ $not: { $hasAny: ["planet.population", "planet.temp.avg"] } }); +find({ $not: { "planet.temp": { $hasAny: ["max", "avg"] } } }); +``` + +#### $includes + +For an "excludes" query, prefix this with `$not`. + +[Read tests](tests/specs/operators/boolean/includes.test.ts) + +```typescript +find({ planet: { moons: { $includes: "Io" } } }); // Array.includes, because planet.moons is an array +find({ planet: { name: { $includes: "Ear" } } }); // String.includes, because planet.name is a string +find({ "planet.moons": { $includes: ["Io", "Europa"] } }); // match when ALL of the provided values are included in the document array +find({ $not: { "planet.moons": { $includes: "Io" } } }); // planets that do not have a moon named "Io" +``` + +#### $length + +[Read tests](tests/specs/operators/boolean/length.test.ts) + +```typescript +find({ "planet.name": { $length: 5 } }); // String.length +find({ "planet.moons": { $length: 0 } }); // Array.length +``` + +#### $oneOf + +[Read tests](tests/specs/operators/boolean/oneOf.test.ts) + +```typescript +find({ "planet.composition.type": { $oneOf: ["gas", "molten", "rock"] } }); +``` + +#### $re + +[Read tests](tests/specs/operators/boolean/re.test.ts) + +```typescript +find({ "visitor.ip": { $re: IP_REGEX } }); +``` + +#### $fn + +[Read tests](tests/specs/operators/boolean/fn.test.ts) + +```typescript +const populated = (v) => v > 1_000_000; +const notOverlyPopulated = (v) => v < 2_000_000; + +find({ planet: { population: { $fn: populated } } }); +find({ planet: { population: { $fn: [populated, notOverlyPopulated] } } }); +``` + +#### $gt, $gte, $lt, $lte + +When used against a number, does a numeric comparison. When used against a string, does a lexicographical comparison. When used against an array, does an array length comparison. + +- [Read $gt tests](tests/specs/operators/boolean/gt.test.ts) +- [Read $gte tests](tests/specs/operators/boolean/gte.test.ts) +- [Read $lt tests](tests/specs/operators/boolean/lt.test.ts) +- [Read $lte tests](tests/specs/operators/boolean/lte.test.ts) + +```typescript +find({ "planet.temp.avg": { $lt: 500 } }); // numeric comparison +find({ planet: { name: { $gt: "Earth" } } }); // lexicographical comparison +find({ "planet.moons": { $gt: 2 } }); // array length comparison; planets with more than two moons +``` + +## Updating + +Any queries that work with `Collection.find` will also work with `Collection.update`. + +Updating documents involves applying various mutation operators to whichever documents match the provided query, i.e.: `update(query, mutations)`. + +The following mutation operators are available, and should support most, if not all, use cases: + +### Mutation operators + +#### $set + +[Read tests](tests/specs/operators/mutation/set.test.ts) + +```typescript +// given +// { a: 1, b: { c: 2 } } +update({ a: 1 }, { $set: { a: 2 }}) // -> { a: 2 } +update({ c: 2 }, { $set: { d: 3 }}) // -> { a: 1, b: { c: 2 }, d: 3 } +update({ c: 2 }, { $set: { b: { c: 3 }}) // -> { a: 1, b: { c: 3 } } +update({ c: 2 }, { $set: { "b.c": 3 }}) // -> { a: 1, b: { c: 3 } } +update({ a: 1 }, { $set: { ...someObject }}) // -> { a: 1, ...someObject } +``` + +#### $unset + +[Read tests](tests/specs/operators/mutation/unset.test.ts) + +```typescript +// given +// { a: 1, b: { c: 2 } } +update({ a: 1 }, { $unset: "b.c" }); // -> { a: 1 } +update({ a: 1 }, { $unset: ["a", "b.c"] }); // -> {} +// given +// { a: 1, b: [{ c: 1, d: 1 }, { c: 2, d: 2 }] } +update({ a: 1 }, { $unset: "b.*.c" }); // -> { a: 1, b: [{ d: 1 }, { d: 2 }] } +``` + +#### $change + +[Read tests](tests/specs/operators/mutation/change.test.ts) + +Like `$set`, but refuses to create new properties. + +```typescript +// given +// { a: 1 } +update({ a: 1 }, { $change: { a: 2 } }); // -> { a: 2 } +update({ a: 1 }, { $change: { b: 2 } }); // -> { a: 1 }, no property created +``` + +#### $push + +[Read tests](tests/specs/operators/mutation/push.test.ts) + +Push will concat an item or items to an array. It refuses to create the target array if it does not exist. + +```typescript +// given +// { a: 1, b: [1] } +update({ a: 1 }, { $push: { b: 2 } }); // -> { a: 1, b: [1, 2] } +update({ a: 1 }, { $push: { b: [2, 3] } }); // -> { a: 1, b: [1, 2, 3] } +// given +// { a: 1 } +update({ a: 1 }, { $push: { b: 2 } }); // -> { a: 1 }, no property created +// given +// { a: 1, b: { c: [] } } +update({ $has: "b.c" }, { $push: { "b.c": 1 } }); // -> { a: 1, b: { c: [1] } } +``` + +#### $unshift + +[Read tests](tests/specs/operators/mutation/unshift.test.ts) + +Unshift will insert new elements to the start of the target array. It refuses to create the target array if it does not exist. + +```typescript +// given +// { a: 1, b: [1] } +update({ a: 1 }, { $unshift: { b: 2 } }); // -> { a: 1, b: [2, 1] } +update({ a: 1 }, { $unshift: { b: [2, 3] } }); // -> { a: 1, b: [2, 3, 1] } +// given +// { a: 1 } +update({ a: 1 }, { $unshift: { b: 2 } }); // -> { a: 1 }, no property created +// given +// { a: 1, b: { c: [] } } +update({ $has: "b.c" }, { $unshift: { "b.c": 1 } }); // -> { a: 1, b: { c: [1] } } +``` + +#### $merge + +[Read tests](tests/specs/operators/mutation/merge.test.ts) + +Merge the provided object into the documents that match the query. + +```typescript +// given +// { a: 1, b: { c: 5 }} +update({ a: 1 }, { $merge: { a: 2, b: { d: 6 } } }); // -> { a: 2, b: { c: 5, d: 6 } } +update({ c: 5 }, { $merge: { a: 2 } }); // -> { a: 1, b: { c: 5, a: 2 }} +update({ c: 5 }, { $merge: { ...someObject } }); // -> { a: 1, b: { c: 5, ...someObject }} +``` + +#### $map + +[Read tests](tests/specs/operators/mutation/map.test.ts) + +Effectively `Array.map` against only the documents that match the query. + +```typescript +// given +// { a: 1 } +// { a: 2 } +update({ a: 1 }, { $map: (doc) => ({ ...doc, d: 1 }) }); // -> { a: 1, d: 1 }, { a: 2 } +``` + +#### $inc, $dev, $mult, $div + +[Read tests](tests/specs/operators/mutation/math.test.ts) + +```typescript +// increase population, creating the property if it doesn't exist. +update({ planet: { name: "Earth" } }, { $inc: { planet: { population: 1 } } }); +update({ name: "Earth" }, { $inc: { "planet.population": 1 } }); +update({ planet: { population: { $gt: 0 } } }, { $inc: 1 }); +``` + +When one of these operators is given in the format `{ $inc: 5 }` without a property specified, we implicitly apply the operator to the properties defined in the query that was used to find the document. For example: + +```typescript +update( + { planet: { name: { $includes: "a" }, $has: "population" } }, + { $inc: 1 } +); +// Implicitly increases the property "planet.population" by 1 if it exists. +// Doesn't try to add `1` to "planet.name" because it is a string. +// Doesn't increase the population of Mars, because it has no "planet.population" property. + +update({ a: { $hasAny: ["b", "c"] } }, { $inc: 1 }); +// If "a.b" or "a.c" exists, and it is a number, it has the modifier applied to it. +``` + +## Filtering + +There's also a `filter` method on the collection for when the provided update operations don't support your use case. + +```typescript +// given +// { a: 1 } +// { a: 2 } +filter((doc) => doc.a > 1); // -> { a: 2 } +``` + +## Removing + +See the [remove tests](tests/specs/remove/basic.test.ts) for more examples. + +Any queries that work with `Collection.find` work with `Collection.remove`. + +```typescript +// remove every planet except Earth +remove({ $not: { planet: "Earth" } }); +``` + +## Query options + +`find`, `update` and `remove` accept a `QueryOptions` object. + +When providing query options, the documents are not actually mutated in the database. The aggregation effect that they have is only applied to the returned documents. In other words, the primary function of query options is aggregation. + +```typescript +{ + /** When true, attempts to deeply match the query against documents. */ + deep: boolean; + + /** Provide fallback values for null or undefined properties */ + ifNull: Record; + + /** Provide fallback values for 'empty' properties ([], {}, "") */ + ifEmpty: Record; + + /** Provide fallback values for null, undefined, or 'empty' properties. */ + ifNullOrEmpty: Record; + + /** + * -1 || 0: descending + * 1: ascending + */ + sort: { [property: string]: -1 | 0 | 1 }; + + /** + * Particularly useful when sorting, `skip` defines the number of documents + * to ignore from the beginning of the result set. + */ + skip: number; + + /** Determines the number of documents returned. */ + take: number; + + /** + * 1: property included in result document + * 0: property excluded from result document + */ + project: { + [property: string]: 0 | 1; + }; + + aggregate: { + [property: string]: + Record<"$floor", string> | + Record<"$ceil", string> | + Record<"$sub", (string|number)[]> | + Record<"$mult", (string|number)[]> | + Record<"$div", (string|number)[]> | + Record<"$add", (string|number)[]> | + Record<"$fn", (document) => unknown>; + }; + + join: Array<{ + /** The collection to join on. */ + collection: Collection; + + /** The property containing the foreign key(s). */ + from: string; + + /** The property on the joining collection that the foreign key should point to. */ + on: string; + + /** The name of the property to be created while will contain the joined documents. */ + as: string; + + /** QueryOptions that will be applied to the joined collection. */ + options?: QueryOptions; + }>; +} +``` + +### ifNull + +Given an object path or dot notation path, assigns a value to the property at that path, only if that property is null or undefined. + +See the [ifNull tests](tests/specs/options/ifNull.test.ts) for more examples. + +```typescript +// [ +// { a: 1, b: 2, c: 3 }, +// { a: 1, b: 2, c: 3, d: null }, +// ]; + +find({ a: 1 }, { ifNull: { d: 4 } }); + +// [ +// { a: 1, b: 2, c: 3, d: 4 }, +// { a: 1, b: 2, c: 3, d: 4 }, +// ]; +``` + +### ifEmpty + +Given an object path or dot notation path, assigns a value to the property at that path, only if that property is "empty". "Empty" here means an empty string (""), an empty array ([]) or an empty object ({}). + +Does not create properties if they do not already exist. + +See the [ifEmpty tests](tests/specs/options/ifEmpty.test.ts) for more examples. + +```typescript +// [ +// { a: 1, b: 2, c: 3, d: " " }, +// { a: 1, b: 2, c: 3, d: [] }, +// { a: 1, b: 2, c: 3, d: {} }, +// { a: 1, b: 2, c: 3 }, +// ]; + +find({}, { ifEmpty: { d: 4 } }); + +// [ +// { a: 1, b: 2, c: 3, d: 4 }, +// { a: 1, b: 2, c: 3, d: 4 }, +// { a: 1, b: 2, c: 3, d: 4 }, +// { a: 1, b: 2, c: 3 }, +// ]; +``` + +### ifNullOrEmpty + +See the [ifNullOrEmpty tests](tests/specs/options/ifNullOrEmpty.test.ts) for more examples. + +### Sorting + +See the [sort tests](tests/specs/options/sort.test.ts) for more examples. + +```typescript +// [ +// { name: "Deanna Troi", age: 28 }, +// { name: "Worf", age: 24 }, +// { name: "Xorf", age: 24 }, +// { name: "Zorf", age: 24 }, +// { name: "Jean-Luc Picard", age: 59 }, +// { name: "William Riker", age: 29 }, +// ]; + +find({ age: { $gt: 1 } }, { sort: { age: 1, name: -1 } }); +// └─ asc └─ desc + +// [ +// { name: "Zorf", age: 24 }, +// { name: "Xorf", age: 24 }, +// { name: "Worf", age: 24 }, +// { name: "Deanna Troi", age: 28 }, +// { name: "William Riker", age: 29 }, +// { name: "Jean-Luc Picard", age: 59 }, +// ]; +``` + +### Skip & take (i.e. LIMIT) + +See the [skip & take tests](tests/specs/options/skip_take.test.ts) for more examples. + +Mostly useful when paired with `sort`. + +```typescript +// [ +// { a: 1, b: 1, c: 1 }, +// { a: 2, b: 2, c: 2 }, +// { a: 3, b: 3, c: 3 }, +// ]; + +find({}, { skip: 1, take: 1 }); + +// [ +// { a: 2, b: 2, c: 2 }, +// ]; +``` + +### Projection + +See the [projection tests](tests/specs/options/project.test.ts) for more examples. + +The ID property of a document is always included unless explicitly excluded. + +#### Implicit exclusion + +When all projected properties have a value of `1`, this is "implicit exclusion" mode. + +In this mode, all document properties that are not defined in the projection are excluded from the result document. + +```typescript +// [ +// { a: 1, b: 1, c: 1 }, +// ]; + +find({ a: 1 }, { project: { b: 1 } }); + +// [ +// { b: 1 }, +// ]; +``` + +#### Implicit inclusion + +When all projected properties have a value of `0`, this is "implicit inclusion" mode. + +In this mode, all document properties that are not defined in the projection are included from the result document. + +```typescript +// [ +// { a: 1, b: 1, c: 1 }, +// ]; + +find({ a: 1 }, { project: { b: 0 } }); + +// [ +// { _id: .., a: 1, c: 1 }, +// ]; +``` + +#### Explicit + +In the only remaining case (a mixture of 1s and 0s), all document properties are included unless explicitly removed with a `0`. + +This is effectively the same behavior as implicit inclusion. + +```typescript +// [ +// { a: 1, b: 1, c: 1 }, +// ]; + +find({ a: 1 }, { project: { b: 1, c: 0 } }); + +// [ +// { _id: .., a: 1, b: 1 }, +// ]; +``` + +### Aggregation + +See the [project tests](tests/specs/options/project.test.ts) for more examples. + +You can use the `aggregate` object to create intermediate properties derived from other document properties, and then project those intermediate properties out of the result set. + +The provided `aggregate` helpers are: `$add`, `$sub`, `$mult`, `$div`, `$floor`, `$ceil` and `$fn`. + +Aggregation happens before projection. This means that you can define as many intermediate properties during the aggregation step as you wish, before ultimately projecting them out of the result documents. In the example below, `total` is created and used in subsequent aggregation steps before ultimately being projected out of the result. + +```typescript +// [ +// { math: 72, english: 82, science: 92 }, +// { math: 60, english: 70, science: 80 }, +// { math: 90, english: 72, science: 84 } +// ] + +find( + {}, + { + aggregate: { + // Create an intermediate property named `total`. + total: { $add: ["math", "english", "science"] }, + // Use the intermediate `total` to create an `average` property. + average: { $div: ["total", 3] }, + }, + // Project out the intermediate `total` property, leaving + // only the original scores and the aggregate `average`. + project: { _id: 0, total: 0 }, + } +); + +// [ +// { math: 72, english: 82, science: 92, average: 82 }, +// { math: 60, english: 70, science: 80, average: 70 }, +// { math: 90, english: 72, science: 84, average: 82 }, +// ] +``` + +You can also use dot notation to reference deeply nested properties, e.g.: + +```typescript +find( + {}, + aggregate: { + // ... + total: { $add: ["scores.math", "scores.english", "scores.science" ] }, + // ... + } +); +``` + +Using `$fn`, you can provide a function which receives the document and returns some value which is then assigned to the intermediate aggregate property. + +```typescript +find( + { $has: ["first", "last"] }, + { + aggregate: { + // Create an aggregate `fullName` property by defining a function + // that receives the document and returns a string of + // `doc.first` + `doc.last`. + fullName: { $fn: (doc) => `${doc.first} ${doc.last}` }, + }, + } +); +``` + +### Joining + +See the [join.test.ts](tests/specs/options/join.test.ts) for more examples. + +Joining allows you to join data from other collections. + +```typescript +// "users" collection + +// [ +// { name: "Alice", purchasedTicketIds: [1, 2] }, +// ]; + +// "tickets" collection + +// [ +// { _id: 0, seat: "A1" }, +// { _id: 1, seat: "B1" }, +// { _id: 2, seat: "C1" }, +// { _id: 3, seat: "D1" }, +// ]; + +users.find( + { name: "Alice" }, + { + join: [ + { + collection: tickets, + from: "purchasedTicketIds", + on: "_id", + as: "tickets", + options: { + project: { _id: 0 }, + }, + }, + ], + } +); + +// [ +// { +// name: "Alice", +// purchasedTicketIds: [1, 2], +// tickets: [ +// { seat: "B1" }, +// { seat: "C1" }, +// ], +// }, +// ]; +``` + +You can also use dot notation when defining the `from` or `as` fields: + +```typescript +// "inventory" collection + +// { +// name: "Bob", +// items: [ +// { itemId: 3, quantity: 1 }, <-- we want to join on these `id` properties +// { itemId: 5, quantity: 2 }, +// ], +// } + +// "items" collection + +// [ +// { _id: 3, name: "The Unstoppable Force", atk: 100 }, +// { _id: 4, name: "Sneakers", agi: 100 }, +// { _id: 5, name: "The Immovable Object", def: 100 }, +// ] + +users.find( + { name: "Bob" }, + { + join: [ + { + collection: items, + from: "items.*.itemId", + on: "_id", + as: "items.*.itemData", // creates a new `itemData` property for each item in `from` + options: { + project: { _id: 0, _created_at: 0, _updated_at: 0 }, + }, + }, + ], + } +); + +// [ +// { +// name: "Bob", +// items: [ +// { itemId: 3, quantity: 1, itemData: { name: "The Unstoppable Force", atk: 100 } }, +// { itemId: 5, quantity: 2, itemData: { name: "The Immovable Object", def: 100 } }, +// ], +// } +// ] +``` + +`join` provides the ability to include `options` of type `QueryOptions`, which in turn facilitates further joins. In simpler terms, you can nest joins infinitely to achieve more complex hierarchical relationships between collections. + +```typescript +users.find( + { .. }, + { + join: [{ + collection: tickets, + options: { + join: [{ + collection: seats, + options: { + join: [{ + collection: auditoriums, + }] + } + }] + } + }] + } +); +``` + +## Misc + +### Builtin property name defaults + +The default property names for document ID (default `_id`), "created at" (default `_created_at`) and "updated at" (default `_updated_at`) timestamps can all be changed. + +```typescript +import { ID_KEY, CREATED_AT_KEY, UPDATED_AT_KEY } from "@prsm/arc"; + +ID_KEY = "id"; +CREATED_AT_KEY = "createdAt"; +UPDATED_AT_KEY = "updatedAt"; +``` + +If you do this, make sure to do it at the beginning of collection creation. + +### Documents + +The returned value from `find`, `update`, `filter` and `remove` is always an `Array`, even when there are no results. diff --git a/packages/arc-degit/bump.config.ts b/packages/arc-degit/bump.config.ts new file mode 100644 index 0000000..05c9a4e --- /dev/null +++ b/packages/arc-degit/bump.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "bumpp"; + +export default defineConfig({ + commit: "%s release", + push: true, + tag: true, +}); diff --git a/packages/arc-degit/bun.lockb b/packages/arc-degit/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..98f844f0058c76a5bf0859b34e969a0279fec859 GIT binary patch literal 95384 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

IFQ+I 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 OR)pALK>t7d@Bag%bl?C0 literal 0 HcmV?d00001 diff --git a/packages/arc-degit/package.json b/packages/arc-degit/package.json new file mode 100644 index 0000000..2ca7057 --- /dev/null +++ b/packages/arc-degit/package.json @@ -0,0 +1,34 @@ +{ + "name": "@prsm/arc", + "version": "2.2.8", + "description": "", + "main": "./dist/index.js", + "module": "./dist/index.js", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "test": "bun tests/index.ts", + "release": "bumpp package.json && npm publish --access public" + }, + "author": "nvms", + "license": "Apache-2.0", + "devDependencies": { + "@types/lodash": "^4.14.182", + "@types/node": "^17.0.35", + "bumpp": "^9.1.0", + "manten": "^0.1.0", + "tsup": "^6.5.0", + "typescript": "^4.7.2" + }, + "dependencies": { + "dot-wild": "^3.0.1", + "lodash": "^4.17.21" + } +} diff --git a/packages/arc-degit/src/adapter/enc_fs.ts b/packages/arc-degit/src/adapter/enc_fs.ts new file mode 100644 index 0000000..1115c9a --- /dev/null +++ b/packages/arc-degit/src/adapter/enc_fs.ts @@ -0,0 +1,100 @@ +import fs from "fs"; +import path from "path"; +import { AdapterConstructorOptions, StorageAdapter } from "."; +import { SimpleFIFO } from "./fs"; +import crypto from "crypto"; + +export default class EncryptedFSAdapter implements StorageAdapter { + storagePath: string; + name: string; + filePath: string; + queue: SimpleFIFO; + key: string; + + constructor({ storagePath, name, key = "Mahpsee2X7TKLe1xwJYmar91pCSaZIY7" }: AdapterConstructorOptions) { + if (!name.endsWith(".json")) { + name += ".json"; + } + + this.storagePath = storagePath; + this.name = name; + this.queue = new SimpleFIFO(); + this.filePath = path.join(this.storagePath, this.name); + this.key = key; + this.prepareStorage(); + } + + prepareStorage() { + if (!fs.existsSync(this.storagePath)) { + fs.mkdirSync(this.storagePath); + } + + if (!fs.existsSync(this.filePath)) { + fs.writeFileSync(this.filePath, JSON.stringify({})); + } + } + + read(): { [key: string]: T } { + try { + const data = fs.readFileSync(this.filePath, "utf8"); + const decrypted = decrypt(data, this.key); + + return Object.assign( + {}, + JSON.parse(decrypted) || {}, + ); + } catch (e) { + return {}; + } + } + + write(data: { [key: string]: T }) { + this.queue.push([ + (d: { [key: string]: T }) => { + encryptAndWrite(d, this.key, this.filePath); + }, + data, + ]); + + while (this.queue.length()) { + const ar = this.queue.shift(); + ar[0](ar[1]); + } + } +} + +const encryptAndWrite = (data: any, key: string, ...args: any[]) => { + const json = JSON.stringify(data, null, 0); + return write(encrypt(json, key), ...args); +}; + +const write = (data: string, ...args: any) => { + return fs.writeFileSync(path.join(...args), data); +}; + +/** + * This function takes a string and encrypts it using the + * aes-256-cbc algorithm. It returns a base64 encoded string + * containing the encrypted data, the initialization vector + * and the authentication tag. + */ +const encrypt = (text: string, key: string) => { + const iv = Buffer.from(crypto.randomBytes(16)).toString("hex").slice(0, 16); + const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(key), iv); + const encrypted = Buffer.concat([cipher.update(text), cipher.final()]); + return `${iv}:${encrypted.toString("hex")}`; +}; + +/** + * This function takes a base64 encoded string + * containing the encrypted data, the initialization + * vector and the authentication tag and decrypts it + * using the aes-256-cbc algorithm. + */ +const decrypt = (text: string, key: string) => { + const textParts = text.includes(":") ? text.split(":") : []; + const iv = Buffer.from(textParts.shift() || "", "binary"); + const encryptedtext = Buffer.from(textParts.join(":"), "hex"); + const decipher = crypto.createDecipheriv("aes-256-cbc", Buffer.from(key), iv); + return Buffer.concat([decipher.update(encryptedtext), decipher.final()]).toString(); +}; diff --git a/packages/arc-degit/src/adapter/fs.ts b/packages/arc-degit/src/adapter/fs.ts new file mode 100644 index 0000000..3875771 --- /dev/null +++ b/packages/arc-degit/src/adapter/fs.ts @@ -0,0 +1,85 @@ +import fs from "fs"; +import path from "path"; +import { AdapterConstructorOptions, StorageAdapter } from "."; + +export class SimpleFIFO { + elements: any[] = []; + + push(...args: any[]) { + this.elements.push(...args); + } + + shift() { + return this.elements.shift(); + } + + length() { + return this.elements.length; + } +} + +export default class FSAdapter implements StorageAdapter { + storagePath: string; + name: string; + filePath: string; + queue: SimpleFIFO; + + constructor({ storagePath, name }: AdapterConstructorOptions) { + if (!name.endsWith(".json")) { + name += ".json"; + } + + this.storagePath = storagePath; + this.name = name; + this.queue = new SimpleFIFO(); + this.filePath = path.join(this.storagePath, this.name); + this.prepareStorage(); + } + + prepareStorage() { + if (!fs.existsSync(this.storagePath)) { + fs.mkdirSync(this.storagePath); + } + + if (!fs.existsSync(this.filePath)) { + fs.writeFileSync(this.filePath, JSON.stringify({})); + } + } + + read(): { [key: string]: T } { + try { + return Object.assign( + {}, + JSON.parse(fs.readFileSync(this.filePath, "utf8")) || {}, + ); + } catch (e) { + return {}; + } + } + + write(data: { [key: string]: T }) { + this.queue.push([ + (d: { [key: string]: T }) => { + writeJSON(d, this.filePath); + }, + data, + ]); + + while (this.queue.length()) { + const ar = this.queue.shift(); + ar[0](ar[1]); + } + } +} + +function writeJSON(data: any, ...args: any[]) { + const env = process.env.NODE_ENV || "development"; + const indent = env === "development" ? 2 : 0; + const out = JSON.stringify(data, null, indent); + return write(out, ...args); +} + +function write(data: any, ...args: any[]) { + const pth = path.join(...args); + return fs.writeFileSync(pth, data); +} diff --git a/packages/arc-degit/src/adapter/index.ts b/packages/arc-degit/src/adapter/index.ts new file mode 100644 index 0000000..867d42a --- /dev/null +++ b/packages/arc-degit/src/adapter/index.ts @@ -0,0 +1,14 @@ +export interface AdapterConstructor { + new ({ storagePath, name, key }: AdapterConstructorOptions): StorageAdapter; +} + +export type AdapterConstructorOptions = { + storagePath: string; + name?: string; + key?: string; +} + +export interface StorageAdapter { + read: () => { [key: string]: T }; + write: (data: { [key: string]: T }) => any; +} diff --git a/packages/arc-degit/src/adapter/localStorage.ts b/packages/arc-degit/src/adapter/localStorage.ts new file mode 100644 index 0000000..a7a75cf --- /dev/null +++ b/packages/arc-degit/src/adapter/localStorage.ts @@ -0,0 +1,27 @@ +import { AdapterConstructorOptions, StorageAdapter } from "."; + +export default class LocalStorageAdapter implements StorageAdapter { + storageKey: string; + + constructor({ storagePath }: AdapterConstructorOptions) { + this.storageKey = `arc_${storagePath}`; + } + + read(): { [key: string]: T } { + try { + return Object.assign( + {}, + JSON.parse( + localStorage.getItem(`arc_${this.storageKey}`) || "{}" + ) + ); + } catch (e) { + console.error(`arc: failed to read from key: ${this.storageKey}: ${e}`); + return {}; + } + } + + write(data: { [key: string]: T }) { + localStorage.setItem(this.storageKey, JSON.stringify(data)); + } +} diff --git a/packages/arc-degit/src/append_props.ts b/packages/arc-degit/src/append_props.ts new file mode 100644 index 0000000..64ace2f --- /dev/null +++ b/packages/arc-degit/src/append_props.ts @@ -0,0 +1,55 @@ +import _ from "lodash"; +import { checkAgainstQuery } from "./return_found"; +import { isEmptyObject, isObject, Ok } from "./utils"; + +/** + * Appends newProps to objects and arrays within source if they match the query. + * @param source - object or array of objects to append properties to + * @param query - object with properties to match against + * @param newProps - properties to append to matching objects + * @param merge - whether to merge matching objects with newProps instead of replacing + * @returns source with newProps appended to matching objects + */ +export function appendProps(source: any, query: object, newProps: any, merge = false) { + // If source is undefined, return undefined + if (source === undefined) return undefined; + + /** + * Recursively processes objects to append newProps to matching objects + * @param item - object or array to process + * @returns object or array with newProps appended to matching objects + */ + const processObject = (item: any) => { + // If item is not an object or array, return it as is + if (!isObject(item)) return item; + + // Clone the item to avoid modifying the original + const clone = _.cloneDeep(item); + + // If the clone matches the query, append or merge newProps + if (checkAgainstQuery(clone, query)) { + if (!merge) { + Object.assign(clone, newProps); + } else { + _.merge(clone, newProps); + } + } + + // Recursively process child objects and arrays + for (const key of Ok(clone)) { + if (isObject(clone[key]) || Array.isArray(clone[key])) { + clone[key] = processObject(clone[key]); + } + } + + return clone; + }; + + // If source is an array or object and query and newProps are not empty, process source + if ((Array.isArray(source) || isObject(source)) && !isEmptyObject(query) && !isEmptyObject(newProps)) { + return Array.isArray(source) ? source.map(processObject) : processObject(source); + } + + // Otherwise, return source as is + return source; +} diff --git a/packages/arc-degit/src/change_props.ts b/packages/arc-degit/src/change_props.ts new file mode 100644 index 0000000..64370d4 --- /dev/null +++ b/packages/arc-degit/src/change_props.ts @@ -0,0 +1,67 @@ +import _ from "lodash"; +import { checkAgainstQuery } from "./return_found"; +import { isEmptyObject, isObject, Ok, safeHasOwnProperty } from "./utils"; + +/** + * Recursively replaces properties of a source object or array based on a query object. + * + * @param source - The object or array to process. + * @param query - The properties to match. + * @param replaceProps - The replacement properties. + * @param createNewProperties - Whether to create new properties if they don't exist. + * @returns The processed object or array. + */ +export const changeProps = ( + source: T, + query: Partial, + replaceProps: Partial, + createNewProperties = false +): T | undefined => { + if (!source) return undefined; + + // helper function to process objects and arrays recursively + const processObject = (item: any) => { + // if item is not an object, return item + if (!isObject(item)) return item; + + // create a clone of the item + const itemClone = _.cloneDeep(item); + + // loop through replaceProps object keys + for (const key of Ok(replaceProps)) { + // if itemClone matches query and createNewProperties is true or the key already exists in itemClone + if (checkAgainstQuery(itemClone, query) && + (createNewProperties || safeHasOwnProperty(itemClone, key))) { + // update the itemClone key with the new value from replaceProps + itemClone[key] = replaceProps[key]; + } + } + + // loop through itemClone keys + for (const key of Ok(itemClone)) { + // if the value of the key is an object or an array, call processObject recursively + if (isObject(itemClone[key]) || Array.isArray(itemClone[key])) { + itemClone[key] = changeProps( + itemClone[key], + query, + replaceProps, + createNewProperties + ); + } + } + + // return the updated itemClone + return itemClone; + }; + + // if source is an object and both query and replaceProps are not empty objects, call processObject + if (isObject(source) && !isEmptyObject(query) && !isEmptyObject(replaceProps)) { + return processObject(source); + // if source is an array and both query and replaceProps are not empty objects, map through the array and call processObject on each item + } else if (Array.isArray(source) && !isEmptyObject(query) && !isEmptyObject(replaceProps)) { + return source.map(processObject) as unknown as T; + // otherwise, return the original source + } else { + return source; + } +}; diff --git a/packages/arc-degit/src/collection.ts b/packages/arc-degit/src/collection.ts new file mode 100644 index 0000000..6e98c9c --- /dev/null +++ b/packages/arc-degit/src/collection.ts @@ -0,0 +1,528 @@ +import dot from "dot-wild"; +import find from "./find"; +import { booleanOperators } from "./operators"; +import { Transaction } from "./transaction"; +import { update } from "./update"; +import { deeplyRemoveEmptyObjects, isEmptyObject, isObject, Ok } from "./utils"; +import { getCreateId } from "./ids"; +import type { StorageAdapter } from "./adapter"; + +export type CollectionOptions = Partial<{ + /** When true, automatically syncs to disk when a change is made to the database. */ + autosync: boolean; + + /** When true, automatically adds timestamps to all records. */ + timestamps: boolean; + + /** When true, document ids are integers that increment from 0. */ + integerIds: boolean; + + /** The storage adapter to use. By default, uses a filesystem adapter. */ + adapter: StorageAdapter; +}>; + +export type CreateIndexOptions = Partial<{ + key: string; + unique: boolean; +}>; + +export type QueryOptions = Partial<{ + /** When true, attempts to deeply match the query against documents. */ + deep: boolean; + + /** Specifies the key to return by. */ + returnKey: string; + + /** When true, returns cloned data (not a reference). default true */ + clonedData: boolean; + + /** Provide fallback values for null or undefined properties */ + ifNull: Record; + + /** Provide fallback values for 'empty' properties ([], {}, "") */ + ifEmpty: Record; + + /** Provide fallback values for null, undefined, or 'empty' properties. */ + ifNullOrEmpty: Record; + + /** + * -1 || 0: descending + * 1: ascending + */ + sort: { [property: string]: -1 | 0 | 1 }; + + /** + * Particularly useful when sorting, `skip` defines the number of documents + * to ignore from the beginning of the result set. + */ + skip: number; + + /** Determines the number of documents returned. */ + take: number; + + /** + * 1: property included in result document + * 0: property excluded from result document + */ + project: { + [property: string]: 0 | 1; + }; + + aggregate: { + [property: string]: + Record<"$floor", string> | + Record<"$ceil", string> | + Record<"$sub", (string|number)[]> | + Record<"$mult", (string|number)[]> | + Record<"$div", (string|number)[]> | + Record<"$add", (string|number)[]> | + Record<"$fn", (document) => unknown>; + }; + + join: Array<{ + /** The collection to join on. */ + collection: Collection; + + /** The property containing the foreign key(s). */ + from: string; + + /** The property on the joining collection that the foreign key should point to. */ + on: string; + + /** The name of the property to be created while will contain the joined documents. */ + as: string; + + /** QueryOptions that will be applied to the joined collection. */ + options?: QueryOptions; + }>; + +}>; + +export function defaultQueryOptions(): QueryOptions { + return { + deep: true, + returnKey: ID_KEY, + clonedData: true, + sort: undefined, + skip: undefined, + project: undefined, + }; +} + +// before inserting, strip any boolean modifiers from the query, e.g. +// { name: "Jean-Luc", title: { $oneOf: ["Captain", "Commander"] } } +// becomes +// { name: "Jean-Luc" }. +export function stripBooleanModifiers(query: object): object { + const ops = new Set(Ok(booleanOperators)); + + const stripObject = (obj: object): object => { + return Object.entries(obj).reduce((acc, [key, value]) => { + if (isObject(value)) { + const stripped = stripObject(value); + if (!isEmptyObject(stripped)) { + acc[key] = stripped; + } + } else if (!ops.has(key)) { + acc[key] = value; + } + return acc; + }, {}); + }; + + return deeplyRemoveEmptyObjects(stripObject(query)); +} + + +export let ID_KEY = "_id"; +export let CREATED_AT_KEY = "_created_at"; +export let UPDATED_AT_KEY = "_updated_at"; + +export type InternalData = { + current: number; + next_id: number; + id_map: { [id: string]: string }; + index: { + valuesToId: { [key: string]: { [value: string]: string[] } }; + idToValues: { [key: string]: { [cuid: string]: string | number } }; + }; +}; + +export type CollectionData = { + [key: string]: any; + internal?: InternalData; +}; + +const isValidIndexValue = (value: unknown) => + value !== undefined && (typeof value === "string" || typeof value === "number" || typeof value === "boolean"); + +export class Collection { + options: CollectionOptions; + data: CollectionData = {}; + _transaction: Transaction = null; + indices: { [key: string]: { unique: boolean } } = {}; + createId: () => string; + + + constructor( + options: CollectionOptions = {} + ) { + options.autosync = options.autosync ?? true; + options.timestamps = options.timestamps ?? true; + options.integerIds = options.integerIds ?? false; + + if (!options.adapter) { + throw new Error("No adapter provided."); + } + + this.options = options; + + const defaultPrivateData = (): InternalData => ({ + current: 0, + next_id: 0, + id_map: {}, + index: { + valuesToId: {}, + idToValues: {}, + }, + }); + + this.adapterRead(); + + // Ensure we have the internal map after adapter read. + if (!this.data.internal) { + this.data.internal = defaultPrivateData(); + } + + this.createId = getCreateId({ init: this.data.internal.current, len: 4 }); + } + + static from(data: CollectionData = {}, options: CollectionOptions = {}) { + const c = new Collection({ + adapter: { read: () => ({} as any), write: () => {} }, + autosync: false, + timestamps: false, + ...options, + }); + + c.insert(data as any); + const initial = c.data; + c.adapterRead = () => { c.data = initial; }; + + return c; + } + + adapterRead() { + this.data = this.options.adapter.read(); + } + + /** + * Given objects found by a query, assign `document` directly to these objects. + * Does not add timestamps or anything else. + * Used by transaction update rollback. + */ + assign(id: unknown, document: T): T { + if (id === undefined) return; + + if (this.options.integerIds) { + const intid = id as number; + const cuid = this.data.internal.id_map[intid]; + + if (cuid) { + this.data[cuid] = document; + return this.data[cuid]; + } + + // a cuid wasn't found, so this is a new record. + return this.insert({ ...document, [ID_KEY]: intid })[0]; + } + + if (typeof id === "string" || typeof id === "number") { + this.data[id] = document; + return this.data[id]; + } + + return undefined; + } + + filter(fn: (document: T) => boolean): T[] { + const _data = Object.assign({}, this.data); + delete _data.internal; + return Object.values(_data).filter((doc: T) => { + try { return fn(doc); } + catch (e) { return false; } + }); + } + + find(query?: object, options: QueryOptions = {}): T[] { + return find(this.data, query, options, this.options, this); + } + + update(query: object, operations: object, options: QueryOptions = {}): T[] { + return update(this.data, query, operations, options, this.options, this); + } + + upsert(query: object, operations: object, options: QueryOptions = {}): T[] { + const updated = this.update(query, operations, options); + + if (updated.length) { + return updated; + } + + // Nothing was updated. + // The idea is that we don't want the created document to be { name: "Jean-Luc", age: { $gt: 40 }, title: "Captain" }, + // instead, it should be: { name: "Jean-Luc", title: "Captain" } + query = stripBooleanModifiers(query); + + const inserted = this.insert(query as any); + return update( + inserted, + query, + operations, + options, + this.options, + this + ); + } + + remove(query: object, options: QueryOptions = {}): T[] { + const found = this.find(query, { ...options, clonedData: false }); + + // Copy the found array so we can return unmodified data. + const cloned = found.map((doc) => Object.assign({}, doc)); + + found.forEach((document) => { + let cuid: string; + + if (this.options.integerIds) { + const intid = document[ID_KEY]; + cuid = this.data.internal.id_map[intid]; + delete this.data.internal.id_map[intid]; + } else { + cuid = document[ID_KEY]; + } + + Object.keys(this.indices).forEach((key) => { + const value = dot.get(document, key); + if (isValidIndexValue(value)) { + this.data.internal.index.valuesToId[key][value] = this.data.internal.index.valuesToId[key][value].filter((c) => c !== cuid); + if (this.data.internal.index.valuesToId[key][value].length === 0) { + delete this.data.internal.index.valuesToId[key][value]; + } + delete this.data.internal.index.idToValues[cuid]; + } + + if (value === undefined) { + // This is a bit annoying, but it needs to be done. + // If the value for this document's indexed property is undefined, + // it might have been removed accidentally by an update mutation or something. + // We need to make sure we clean up any dangling indexes. + Object.keys(this.data.internal.index.valuesToId[key]).forEach((value) => { + this.data.internal.index.valuesToId[key][value] = this.data.internal.index.valuesToId[key][value].filter((c) => c !== cuid); + if (this.data.internal.index.valuesToId[key][value].length === 0) { + delete this.data.internal.index.valuesToId[key][value]; + } + }); + } + }); + + delete this.data[cuid]; + }); + + this.sync(); + + return cloned; + } + + insert(documents: T[] | T): T[] { + if (!Array.isArray(documents)) documents = [documents]; + if (!documents.length) return []; + + documents = documents.map((document) => { + const cuid = this.getId(); + this.data.internal.current++; + + if (this.options.timestamps) { + document[CREATED_AT_KEY] = Date.now(); + document[UPDATED_AT_KEY] = Date.now(); + } + + // only assign an id if it's not already there + // support explicit ids, e.g.: { _id: 0, ... } + if (document[ID_KEY] === undefined) { + document[ID_KEY] = cuid; + + if (this.options.integerIds) { + const intid = this.nextIntegerId(); + this.data.internal.id_map[intid] = cuid; + document[ID_KEY] = intid; + } + } + + this.data[cuid] = document; + + Object.keys(this.indices).forEach((key) => { + const value = String(dot.get(document, key)); + if (isValidIndexValue(value)) { + + if (this.indices[key].unique) { + if (this.data.internal.index.valuesToId?.[key]?.[value] !== undefined) { + throw new Error(`Unique index violation for key "${key}" and value "${value}"`); + } + } + + this.data.internal.index.valuesToId[key] = this.data.internal.index.valuesToId[key] || {}; + this.data.internal.index.valuesToId[key][value] = this.data.internal.index.valuesToId[key][value] || []; + this.data.internal.index.valuesToId[key][value].push(cuid); + + this.data.internal.index.idToValues[cuid] = this.data.internal.index.idToValues[cuid] || {}; + this.data.internal.index.idToValues[cuid][key] = value; + } + }); + + return document; + }); + + if (this.options.autosync) { + this.sync(); + } + + return documents; + } + + merge(id: string, item: T) { + if (!id) return; + + /** + * When merging a document, if we're using integer ids, + * grab the cuid from the id map. + */ + if (this.options.integerIds) { + const cuid = this.data.internal.id_map[id]; + if (!cuid) return; + if (this.data[cuid] === undefined) return; + Object.assign(this.data[cuid], isEmptyObject(item) ? {} : item); + this.sync(); + return; + } + + /** + * Otherwise, the id is assumed to be a cuid. + */ + if (this.data[id] === undefined) return; + Object.assign(this.data[id], isEmptyObject(item) ? {} : item); + this.sync(); + } + + sync() { + return this.options.adapter.write(this.data); + } + + drop() { + this.data = { + internal: { + current: 0, + next_id: 0, + id_map: {}, + index: { + valuesToId: {}, + idToValues: {}, + }, + }, + }; + } + + getId() { + return this.createId(); + } + + createIndex(options: CreateIndexOptions = {}) { + if (!options.key) throw new Error(`createIndex requires a key`); + + options = { + key: options.key, + unique: options.unique ?? false, + }; + + const { key, unique } = options; + + if (key.split(".").some((k) => !isNaN(Number(k)))) { + throw new Error(`Cannot use a numeric property as an index key: ${key}`); + } + + this.indices[key] = { unique }; + + if (this.data.internal.index.valuesToId[key]) { + return; + } + + Object.keys(this.data).forEach((cuid) => { + if (cuid === "internal") return; + + const value = String(dot.get(this.data[cuid], key)); + /* const value = String(key.split(".").reduce((acc, k) => acc[k], this.data[cuid])); */ + + if (isValidIndexValue(value)) { + if (unique) { + if (this.data.internal.index.valuesToId?.[key]?.[value] !== undefined) { + throw new Error(`Unique index violation for key "${key}" and value "${value}"`); + } + } + + this.data.internal.index.valuesToId[key] = this.data.internal.index.valuesToId[key] || {}; + this.data.internal.index.valuesToId[key][value] = this.data.internal.index.valuesToId[key][value] || []; + this.data.internal.index.valuesToId[key][value].push(cuid); + + this.data.internal.index.idToValues[cuid] = this.data.internal.index.idToValues[cuid] || {}; + this.data.internal.index.idToValues[cuid][key] = value; + } else { + throw new Error(`Invalid index value for property ${key}: ${value}`); + } + }); + + this.sync(); + + return this; + } + + removeIndex(key: string): boolean { + if (!this.indices[key]) return false; + + delete this.indices[key]; + + if (this.data.internal.index.valuesToId[key]) { + delete this.data.internal.index.valuesToId[key]; + } + + Object.keys(this.data.internal.index.idToValues).forEach((cuid) => { + if (this.data.internal.index.idToValues[cuid][key] !== undefined) { + delete this.data.internal.index.idToValues[cuid][key]; + } + + if (isEmptyObject(this.data.internal.index.idToValues[cuid])) { + delete this.data.internal.index.idToValues[cuid]; + } + }); + + this.sync(); + + return true; + } + + nextIntegerId() { + return this.data.internal.next_id++; + } + + transaction(fn: (transaction: Transaction) => void): void { + this._transaction = new Transaction(this); + + try { + fn(this._transaction); + } catch (e) { + this._transaction.rollback(); + throw e; + } + + this._transaction.commit(); + } +} diff --git a/packages/arc-degit/src/find.ts b/packages/arc-degit/src/find.ts new file mode 100644 index 0000000..247fc89 --- /dev/null +++ b/packages/arc-degit/src/find.ts @@ -0,0 +1,121 @@ +import _ from "lodash"; +import dot from "dot-wild"; +import { + Collection, + CollectionData, + CollectionOptions, + defaultQueryOptions, + ID_KEY, + QueryOptions, + stripBooleanModifiers, +} from "./collection"; +import { applyQueryOptions } from "./query_options"; +import { returnFound } from "./return_found"; +import { ensureArray, isObject, Ok, Ov } from "./utils"; + +const makeDistinctByKey = (arr: any[], key: string) => { + const map = new Map(); + let val: any; + arr = ensureArray(arr); + return arr.filter((el) => { + if (el === undefined) return; + val = map.get(el[key]); + if (val !== undefined) { + return false; + } + map.set(el[key], true); + return true; + }); +}; + +export default function find( + data: CollectionData, + query: any, + options: QueryOptions, + collectionOptions: CollectionOptions, + collection: Collection +): T[] { + options = { ...defaultQueryOptions(), ...options }; + query = ensureArray(query); + + // remove any empty objects from the query. + query = query.filter((q: object) => Ok(q).length > 0); + + // if there's no query, return all data. + if (!query.length) { + if (options.clonedData) { + const out = []; + + for (const key in data) { + if (key === "internal") continue; + out.push(_.cloneDeep(data[key])); + } + + return applyQueryOptions(out, options); + } + + return applyQueryOptions([...Ov(data)], options); + } + + const withoutPrivate = [...Ov(data)].slice(1); + let res = []; + + for (const q of query) { + let r = []; + if (q[ID_KEY] && !isObject(q[ID_KEY]) && !collectionOptions.integerIds) { + r.push(data[q[ID_KEY]]); + } else if ( + q[ID_KEY] && + !isObject(q[ID_KEY]) && + collectionOptions.integerIds + ) { + const f = data.internal.id_map[q[ID_KEY]]; + // If we have `f`, it's a cuid. + if (f) r.push(data[f]); + } else { + const strippedQuery = stripBooleanModifiers(_.cloneDeep(q)); + const flattened = Object.fromEntries( + Object.entries(dot.flatten(strippedQuery)).map(([k, v]) => [ + k.replace(/\\./g, "."), + v, + ]) + ); + + if (Ok(flattened).some((key) => collection.indices[key])) { + Ok(collection.indices).forEach((key) => { + const queryPropertyValue = key.includes(".") + ? flattened[key] + : q[key]; + if (queryPropertyValue) { + const cuids = + data.internal.index.valuesToId?.[key]?.[queryPropertyValue]; + + if (cuids) { + const sourceItems = cuids?.map((cuid) => data[cuid]); + r.push( + ...returnFound(sourceItems, q, options, collectionOptions) + ); + } else { + r.push(...returnFound(withoutPrivate, q, options, collection)); + } + } + }); + } else { + r = returnFound(withoutPrivate, q, options, null); + if (r === undefined) r = []; + r = ensureArray(r); + } + } + + res.push(...r); + } + + const distinct = makeDistinctByKey(res, ID_KEY); + res = applyQueryOptions(distinct, options); + + if (!options.clonedData) return res; + + const cloned = []; + for (const obj of res) cloned.push(_.cloneDeep(obj)); + return cloned; +} diff --git a/packages/arc-degit/src/ids.ts b/packages/arc-degit/src/ids.ts new file mode 100644 index 0000000..7da9351 --- /dev/null +++ b/packages/arc-degit/src/ids.ts @@ -0,0 +1,66 @@ +/* + + This is a mashup of github.com/lukeed/hexoid and github.com/paralleldrive/cuid + Both are MIT licensed. + + ~ https://github.com/paralleldrive/cuid/blob/f507d971a70da224d3eb447ed87ddbeb1b9fd097/LICENSE + -- + MIT License + Copyright (c) 2012 Eric Elliott + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ~ https://github.com/lukeed/hexoid/blob/1070447cdc62d1780d2a657b0df64348fc1e5ec5/license + -- + MIT License + Copyright (c) Luke Edwards (lukeed.com) + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +*/ + +const HEX: string[] = []; + +for (let i = 0; i < 256; i++) { + HEX[i] = (i + 256).toString(16).substring(1); +} + +function pad(str: string, size: number) { + const s = "000000" + str; + return s.substring(s.length - size); +} + +const SHARD_COUNT = 32; + +export function getCreateId(opts: { init: number; len: number }) { + const len = opts.len || 16; + let str = ""; + let num = 0; + const discreteValues = 1_679_616; // Math.pow(36, 4) + let current = opts.init + Math.ceil(discreteValues / 2); + + function counter() { + current = current <= discreteValues ? current : 0; + current++; + return (current - 1).toString(16); + } + + return () => { + if (!str || num === 256) { + str = ""; + num = ((1 + len) / 2) | 0; + while (num--) str += HEX[(256 * Math.random()) | 0]; + str = str.substring((num = 0), len); + } + + const date = Date.now().toString(36); + const paddedCounter = pad(counter(), 6); + const hex = HEX[num++]; + + const shardKey = parseInt(hex, 16) % SHARD_COUNT; + + return `a${date}${paddedCounter}${hex}${str}${shardKey}`; + }; +} diff --git a/packages/arc-degit/src/index.ts b/packages/arc-degit/src/index.ts new file mode 100644 index 0000000..b4e9246 --- /dev/null +++ b/packages/arc-degit/src/index.ts @@ -0,0 +1,6 @@ +export { type CollectionOptions, type QueryOptions, Collection } from "./collection"; +export { type ShardOptions, ShardedCollection } from "./sharded_collection"; +export { type AdapterConstructor, type AdapterConstructorOptions, type StorageAdapter } from "./adapter"; +export { default as FSAdapter } from "./adapter/fs"; +export { default as EncryptedFSAdapter } from "./adapter/enc_fs"; +export { default as LocalStorageAdapter } from "./adapter/localStorage"; diff --git a/packages/arc-degit/src/operators/boolean/and.ts b/packages/arc-degit/src/operators/boolean/and.ts new file mode 100644 index 0000000..efd75a2 --- /dev/null +++ b/packages/arc-degit/src/operators/boolean/and.ts @@ -0,0 +1,29 @@ +import dot from "dot-wild"; +import { ID_KEY } from "../../collection"; +import { returnFound } from "../../return_found"; +import { ensureArray, isObject } from "../../utils"; + +export function $and(source: object, query: object): boolean { + if (!isObject(query)) { + return true; + } + + // @ts-ignore + const ands = ensureArray(query.$and); + if (!ands) { + return true; + } + + return ands.every((and) => { + return Object.keys(and).every((key) => { + const value = and[key]; + + if (typeof value === "function") { + return value(dot.get(source, key)); + } else { + const match = returnFound(source, { [key]: value }, { deep: true, returnKey: ID_KEY, clonedData: true }, source); + return Boolean(match && match.length); + } + }); + }); +} diff --git a/packages/arc-degit/src/operators/boolean/fn.ts b/packages/arc-degit/src/operators/boolean/fn.ts new file mode 100644 index 0000000..b0731a4 --- /dev/null +++ b/packages/arc-degit/src/operators/boolean/fn.ts @@ -0,0 +1,28 @@ +import dot from "dot-wild"; +import { ensureArray, isObject, Ok } from "../../utils"; + +export function $fn(source: object, query: object): boolean { + let match = undefined; + + if (isObject(query)) { + Ok(query).forEach((k) => { + if (isObject(query[k])) { + const targetValue = dot.get(source, k); + if (targetValue === undefined) return; + + Ok(query[k]).forEach((j) => { + if (j === "$fn") { + match = true; + ensureArray(query[k][j]).forEach((fn) => { + if (!fn(targetValue)) match = false; + }); + } + }); + } + }); + } + + if (match !== undefined) return match; + + return false; +} \ No newline at end of file diff --git a/packages/arc-degit/src/operators/boolean/gtlt.ts b/packages/arc-degit/src/operators/boolean/gtlt.ts new file mode 100644 index 0000000..d316fe0 --- /dev/null +++ b/packages/arc-degit/src/operators/boolean/gtlt.ts @@ -0,0 +1,63 @@ +import dot from "dot-wild"; +import { ensureArray } from "../../utils"; + +enum ComparisonOperator { + GreaterThan = "$gt", + GreaterThanEquals = "$gte", + LessThan = "$lt", + LessThanEquals = "$lte", +} + +function match(source: object, query: object, operator: ComparisonOperator): boolean { + return Object.entries(query).map(([key, value]) => { + const qry = ensureArray(value[operator]); + const targetValue = dot.get(source, key); + if (targetValue === undefined) return false; + return qry.some(q => { + if (typeof targetValue === "string" || typeof targetValue === "number") { + switch (operator) { + case ComparisonOperator.GreaterThan: + return targetValue > q; + case ComparisonOperator.GreaterThanEquals: + return targetValue >= q; + case ComparisonOperator.LessThan: + return targetValue < q; + case ComparisonOperator.LessThanEquals: + return targetValue <= q; + default: + return false; + } + } else if (Array.isArray(targetValue)) { + switch (operator) { + case ComparisonOperator.GreaterThan: + return targetValue.length > q; + case ComparisonOperator.GreaterThanEquals: + return targetValue.length >= q; + case ComparisonOperator.LessThan: + return targetValue.length < q; + case ComparisonOperator.LessThanEquals: + return targetValue.length <= q; + default: + return false; + } + } + return false; + }); + }).every(Boolean); +} + +export function $gt(source: object, query: object): boolean { + return match(source, query, ComparisonOperator.GreaterThan); +} + +export function $gte(source: object, query: object): boolean { + return match(source, query, ComparisonOperator.GreaterThanEquals); +} + +export function $lt(source: object, query: object): boolean { + return match(source, query, ComparisonOperator.LessThan); +} + +export function $lte(source: object, query: object): boolean { + return match(source, query, ComparisonOperator.LessThanEquals); +} diff --git a/packages/arc-degit/src/operators/boolean/has.ts b/packages/arc-degit/src/operators/boolean/has.ts new file mode 100644 index 0000000..5b1e9bf --- /dev/null +++ b/packages/arc-degit/src/operators/boolean/has.ts @@ -0,0 +1,30 @@ +import { ensureArray, isObject, Ok } from "../../utils"; +import dot from "dot-wild"; + +/** + * @example + * { $has: "a" } <-- source has property "a" + * { $has: ["a", "b"] } <-- source has properties "a" AND "b" + * + * @related + * $hasAny + * $not (e.g. { $not: { $has: "a" } }) + */ +export function $has(source: object, query: object): boolean { + let match = false; + + if (isObject(query)) { + Ok(query).forEach((k) => { + if (k !== "$has") return; + + let qry = query[k]; + qry = ensureArray(qry); + + match = qry.every((q: any) => { + return dot.get(source, q) !== undefined; + }); + }); + } + + return match; +} diff --git a/packages/arc-degit/src/operators/boolean/hasAny.ts b/packages/arc-degit/src/operators/boolean/hasAny.ts new file mode 100644 index 0000000..22572f9 --- /dev/null +++ b/packages/arc-degit/src/operators/boolean/hasAny.ts @@ -0,0 +1,18 @@ +import dot from "dot-wild"; +import { ensureArray, isObject, Ok } from "../../utils"; + +/** + * @example + * { $hasAny: "a" } <-- source has property "a" + * { $hasAny: ["a", "b"] } <-- source has properties "a" OR "b" + * + * @related + * $has + * $not + * { $not: { $hasAny: "a" } }) + * { $not: { "a.b.c.d": { $hasAny: "e" } } } + */ +export function $hasAny(source: object, query: object): boolean { + const queryValues = ensureArray(query["$hasAny"]); + return queryValues.some((q: any) => dot.get(source, q)); +} diff --git a/packages/arc-degit/src/operators/boolean/includes.ts b/packages/arc-degit/src/operators/boolean/includes.ts new file mode 100644 index 0000000..26a956e --- /dev/null +++ b/packages/arc-degit/src/operators/boolean/includes.ts @@ -0,0 +1,26 @@ +import dot from "dot-wild"; +import { ensureArray, isObject, Ok } from "../../utils"; + +/** + * $includes does a simple .includes(). + * + * @example + * { "foo": "bar" }, { "foo": "baz" } + * find({ "foo": { $includes: "ba" } }) + * + * { "nums": [1, 2, 3] }, { "nums": [4, 5, 6] } + * find({ "nums": { $includes: 2 } }) + * find({ "nums": { $includes: [1, 2, 3] } }) + * + * find({ "a.b.c": { $includes: 1 } }) + */ +export function $includes(source: object, query: object): boolean { + const matches = Object.entries(query) + .flatMap(([key, value]) => { + const includes = ensureArray(value.$includes); + return includes.map((v) => dot.get(source, key)?.includes(v)); + }) + .filter((match) => match !== undefined); + + return matches.length > 0 && matches.every(Boolean); +} diff --git a/packages/arc-degit/src/operators/boolean/length.ts b/packages/arc-degit/src/operators/boolean/length.ts new file mode 100644 index 0000000..8dba974 --- /dev/null +++ b/packages/arc-degit/src/operators/boolean/length.ts @@ -0,0 +1,25 @@ +import dot from "dot-wild"; +import { isObject, Ok } from "../../utils"; + +/** + * $length asserts the length of an array or string. + * + * @example + * { "foo": [0, 0] }, { "foo": [0, 0, 0] }, { "foo": "abc" } + * find({ "foo": { $length: 3 } }) + */ +export function $length(source: object, query: object): boolean { + if (!isObject(query)) { + return false; + } + + return Object.entries(query).some(([k, qry]) => { + const targetValue = dot.get(source, k); + + return ( + targetValue !== undefined && + (Array.isArray(targetValue) || typeof targetValue === "string") && + targetValue.length === qry.$length + ); + }); +} diff --git a/packages/arc-degit/src/operators/boolean/not.ts b/packages/arc-degit/src/operators/boolean/not.ts new file mode 100644 index 0000000..e2a7e3e --- /dev/null +++ b/packages/arc-degit/src/operators/boolean/not.ts @@ -0,0 +1,35 @@ +import { ID_KEY } from "../../collection"; +import { returnFound } from "../../return_found"; +import { ensureArray, isObject, Ok, safeHasOwnProperty } from "../../utils"; + +export function $not(source: object, query: object): boolean { + const matches = []; + + if (isObject(query)) { + Ok(query).forEach((key) => { + if (key !== "$not") return; + + if (!isObject(query[key])) { + throw new Error(`$not operator requires an object as its value, received: ${query[key]}`); + } + + const nots = ensureArray(query[key]); + matches.push( + nots.every((not) => { + if (isObject(not)) { + const found = returnFound(source, not, { deep: true, returnKey: ID_KEY, clonedData: true }, source); + + if (found && found.length) { + return false; + } + + return true; + } + return !safeHasOwnProperty(source, not) + }) + ); + }); + } + + return matches.every((m) => !m); +} diff --git a/packages/arc-degit/src/operators/boolean/oneOf.ts b/packages/arc-degit/src/operators/boolean/oneOf.ts new file mode 100644 index 0000000..30ffe43 --- /dev/null +++ b/packages/arc-degit/src/operators/boolean/oneOf.ts @@ -0,0 +1,26 @@ +import dot from "dot-wild"; +import { ensureArray, isObject, Ok } from "../../utils"; + +/** + * @example + * { name: "Jean-Luc", friends: [1, 3, 4] } + * users.find({ _id: { $oneOf: [1, 3, 4] } }) + * { a: b: { c: 1 } } + * find({ "a.b.c": { $oneOf: [1, 2] } }) + */ +export function $oneOf(source: object, query: object): boolean { + const matches = []; + + if (isObject(query)) { + Ok(query).forEach((k) => { + if (query[k]["$oneOf"] === undefined) { return; } + const values = ensureArray(query[k]["$oneOf"]); + const value = dot.get(source, k); + matches.push(values.includes(value)); + }); + } + + if (!matches.length) return false; + if (matches.includes(false)) return false; + return true; +} diff --git a/packages/arc-degit/src/operators/boolean/or.ts b/packages/arc-degit/src/operators/boolean/or.ts new file mode 100644 index 0000000..4a1e3a0 --- /dev/null +++ b/packages/arc-degit/src/operators/boolean/or.ts @@ -0,0 +1,28 @@ +import dot from "dot-wild"; +import { ID_KEY } from "../../collection"; +import { returnFound } from "../../return_found"; +import { ensureArray, isObject } from "../../utils"; + +export function $or(source: object, query: object): boolean { + if (!isObject(query)) return false; + // @ts-ignore + if (!query.$or) return false; + + // @ts-ignore + const ors = ensureArray(query.$or); + for (const or of ors) { + const matches = []; + for (const [orKey, orValue] of Object.entries(or)) { + const sourceOrValue = dot.get(source, orKey); + if (typeof orValue === "function" && sourceOrValue !== undefined) { + matches.push(orValue(sourceOrValue)); + } else { + const match = returnFound(source, or, { deep: true, returnKey: ID_KEY, clonedData: true }, source); + matches.push(Boolean(match && match.length)); + } + } + if (matches.length && matches.includes(true)) return true; + } + + return false; +} \ No newline at end of file diff --git a/packages/arc-degit/src/operators/boolean/re.ts b/packages/arc-degit/src/operators/boolean/re.ts new file mode 100644 index 0000000..4d87176 --- /dev/null +++ b/packages/arc-degit/src/operators/boolean/re.ts @@ -0,0 +1,14 @@ +import dot from "dot-wild"; +import { ensureArray, isObject, Ok } from "../../utils"; + +export function $re(source: object, query: object): boolean { + if (!isObject(query)) return false; + + return Ok(query).some(k => { + const targetValue = dot.get(source, k); + if (isObject(query[k]) && targetValue !== undefined && query[k].$re) { + return ensureArray(query[k].$re).every((re: RegExp) => re.test(targetValue)); + } + return false; + }); +} diff --git a/packages/arc-degit/src/operators/boolean/xor.ts b/packages/arc-degit/src/operators/boolean/xor.ts new file mode 100644 index 0000000..cdac6b0 --- /dev/null +++ b/packages/arc-degit/src/operators/boolean/xor.ts @@ -0,0 +1,39 @@ +import dot from "dot-wild"; +import { ID_KEY } from "../../collection"; +import { returnFound } from "../../return_found"; +import { ensureArray, isObject } from "../../utils"; + +export function $xor(source: object, query: object): boolean { + if (!isObject(query)) { + return false; + } + + // @ts-ignore + const xorQueries = ensureArray(query.$xor); + + if (xorQueries.length !== 2) { + throw new Error( + `invalid $xor query. expected exactly two values, found ${xorQueries.length}.` + ); + } + + const matches = xorQueries.map((orQuery) => { + return Object.entries(orQuery).map(([key, value]) => { + const targetValue = dot.get(source, key) ?? source[key]; + + if (typeof value === "function") { + return targetValue !== undefined && value(targetValue); + } else { + const match = returnFound(source, orQuery, { + deep: true, + returnKey: ID_KEY, + clonedData: true + }, source); + return Boolean(match && match.length); + } + }); + }); + + return matches.flat().filter((m) => m).length === 1; +} + diff --git a/packages/arc-degit/src/operators/index.ts b/packages/arc-degit/src/operators/index.ts new file mode 100644 index 0000000..daac523 --- /dev/null +++ b/packages/arc-degit/src/operators/index.ts @@ -0,0 +1,79 @@ +import { Collection } from "../collection"; +import { Ok } from "../utils"; +import { $gt, $gte, $lt, $lte } from "./boolean/gtlt"; +import { $and } from "./boolean/and"; +import { $or } from "./boolean/or"; +import { $xor } from "./boolean/xor"; +import { $fn } from "./boolean/fn"; +import { $re } from "./boolean/re"; +import { $includes } from "./boolean/includes"; +import { $oneOf } from "./boolean/oneOf"; +import { $length } from "./boolean/length"; +import { $not } from "./boolean/not"; +import { $has } from "./boolean/has"; +import { $hasAny } from "./boolean/hasAny"; +import { $set } from "./mutation/set"; +import { $unset } from "./mutation/unset"; +import { $change } from "./mutation/change"; +import { $inc, $dec, $mult, $div } from "./mutation/math"; +import { $merge } from "./mutation/merge"; +import { $map } from "./mutation/map"; +import { $filter } from "./mutation/filter"; +import { $push } from "./mutation/push"; +import { $unshift } from "./mutation/unshift"; + +export const booleanOperators = { + $gt, + $gte, + $lt, + $lte, + $and, + $or, + $xor, + $includes, + $oneOf, + $fn, + $re, + $length, + $not, + $has, + $hasAny, +}; + +const mutationOperators = { + $merge, + $map, + $filter, + $push, + $unshift, + $set, + $unset, + $change, + $inc, + $dec, + $mult, + $div, +}; + +export function processMutationOperators( + source: T[], + ops: object, + query: object, + collection: Collection +): T[] { + Ok(ops).forEach((operator) => { + if (!mutationOperators[operator]) { + console.warn(`unknown operator: ${operator}`); + return; + } + + source = mutationOperators[operator]( + source, + ops[operator], + query, + collection + ); + }); + + return source; +} diff --git a/packages/arc-degit/src/operators/mutation/change.ts b/packages/arc-degit/src/operators/mutation/change.ts new file mode 100644 index 0000000..1fde863 --- /dev/null +++ b/packages/arc-degit/src/operators/mutation/change.ts @@ -0,0 +1,43 @@ +import dot from "dot-wild"; +import { Collection } from "../.."; +import { changeProps } from "../../change_props"; +import { ensureArray, isObject, Ok, unescapedFlatten } from "../../utils"; + +export function $change( + source: T[], + modifiers: any, + query: object, + collection: Collection +): T[] { + const mods = ensureArray(modifiers); + + mods.forEach((mod) => { + if (!isObject(mod) && !Array.isArray(mod)) { + const flattened = unescapedFlatten(query); + + Ok(flattened).forEach((key) => { + source = source.map((doc: T) => { + if (dot.get(doc, key) !== undefined) return dot.set(doc, key, mod[key]); + return doc; + }); + }); + + return; + } + + if (isObject(mod)) { + Ok(mod).forEach((key) => { + source = source.map((doc: T) => { + if (dot.get(doc, key) !== undefined) return dot.set(doc, key, mod[key]); + return doc; + }); + }); + + return; + } + + source = changeProps(source, query as any, mod, false); + }); + + return source; +} diff --git a/packages/arc-degit/src/operators/mutation/filter.ts b/packages/arc-degit/src/operators/mutation/filter.ts new file mode 100644 index 0000000..aa58bc4 --- /dev/null +++ b/packages/arc-degit/src/operators/mutation/filter.ts @@ -0,0 +1,48 @@ +import dot from "dot-wild"; +import { Collection, ID_KEY } from "../../collection"; +import { isFunction, isObject } from "../../utils"; + +export function $filter( + source: T[], + filterImpl: (document: T, index: number, source: T[]) => T | { [key: string]: (document: T, index: number, source: T[]) => any }, + query: object, + collection: Collection +): T[] { + if (isFunction(filterImpl)) { + return source.filter((document, index, source) => { + if (filterImpl(document, index, source)) { + return true; + } + + if (document[ID_KEY]) { + collection.remove({ [ID_KEY]: document[ID_KEY] }); + return false; + } + + return false; + }); + } + + // { $filter: { anArray: (doc) => doc > 5 } } + if (isObject(filterImpl)) { + return source.map((document) => { + Object.keys(filterImpl).forEach((key) => { + const value = dot.get(document, key); + if (!Array.isArray(value)) { + throw new Error(`$filter when providing an object to filter on, the key being operated on in the source document must be an array: ${key} was not an array`); + } + + const filtered = value.filter((document, index, source) => { + return filterImpl[key](document, index, source); + }); + + document = dot.set(document, key, filtered); + }); + + return document; + }); + } + + throw new Error("$filter expected either a function, e.g. { $filter: (doc) => doc }, or an array path with a function key, e.g. { $filter: { anArray: (doc) => doc } }"); +} + diff --git a/packages/arc-degit/src/operators/mutation/map.ts b/packages/arc-degit/src/operators/mutation/map.ts new file mode 100644 index 0000000..248cc01 --- /dev/null +++ b/packages/arc-degit/src/operators/mutation/map.ts @@ -0,0 +1,19 @@ +import { Collection } from "../.."; +import { appendProps } from "../../append_props"; +import { ensureArray } from "../../utils"; + +export function $map( + source: T[], + mapImpl: (document: T, index: number, source: T[]) => T, + query: object, + collection: Collection +): T[] { + if (typeof mapImpl !== "function") { + throw new Error("$map expected a function, e.g. { $map: (doc) => doc }"); + } + + return source.map((document, index, source) => { + return mapImpl(document, index, source); + }); +} + diff --git a/packages/arc-degit/src/operators/mutation/math.ts b/packages/arc-degit/src/operators/mutation/math.ts new file mode 100644 index 0000000..ddc5f96 --- /dev/null +++ b/packages/arc-degit/src/operators/mutation/math.ts @@ -0,0 +1,221 @@ +import dot from "dot-wild"; +import { Collection } from "../../collection"; +import { ensureArray, isObject, Ok, unescapedFlatten } from "../../utils"; + +enum Op { + Inc, + Dec, + Mult, + Div, +} + +function math( + source: T[], + modifiers: any, + query: object, + op: Op, + collection: Collection +): T[] { + const mods = ensureArray(modifiers); + source = source.map((document) => { + + mods.forEach((mod) => { + if (isObject(mod)) { + // update({ a: 1 }, { $inc: { visits: 1 } }) + // update({ a: 1 }, { $inc: { a: { b: { c: 5 }}, "d.e.f": 5 } }) + const flattened = unescapedFlatten(mod); + + Ok(flattened).forEach((key) => { + const targetValue = dot.get(document, key); + const modValue = Number(mod[key]); + + switch (op) { + case Op.Inc: + if (targetValue === undefined) { + document = dot.set(document, key, modValue); + } else { + document = dot.set(document, key, Number(targetValue) + modValue); + } + break; + case Op.Dec: + if (targetValue === undefined) { + document = dot.set(document, key, -modValue); + } else { + document = dot.set(document, key, Number(targetValue) - modValue); + } + break; + case Op.Mult: + if (targetValue === undefined) { + document = dot.set(document, key, modValue); + } else { + document = dot.set(document, key, Number(targetValue) * modValue); + } + break; + case Op.Div: + if (targetValue === undefined) { + document = dot.set(document, key, modValue); + } else { + document = dot.set(document, key, Number(targetValue) / modValue); + } + break; + } + }); + } else if (typeof mod === "number") { + // When the modifier is a number, we increment all numeric + // fields that are in the provided query. + // update({ a: 1 }, { $inc: 1 }) -> { a: 2 } + // update({ a: 1, b: 1 }, { $inc: 1 }) -> { a: 2, b: 2 } + // update({ "a.b.c": 1 }, { $inc: 1 }) -> { a: { b: { c: 2 } } } + // update({ a: { b: { c: 1 } } }, { $inc: 1 }) -> { a: { b: { c: 2 } } } + // update({ "b.c": { $gt: 1 } }, { $inc: 1 }) -> { b: { c: 3 } } + let flattened = unescapedFlatten(query); + + flattened = Object.keys(flattened).reduce((acc, key) => { + // "a.b.$has.c" => "a.b.c" + // "a.b.$has.0.c" => "a.b.c" + // "a.b.$hasAny.0.c" => "a.b.c" + // + // Useful for scenarios like: + // update({ planet: { name: "Earth", $has: "population" } }, { $inc: 1 }) + // + if (key.match(/\.\$has\.\d+$/) || key.match(/\.\$hasAny\.\d+$/)) { + let hasValue = flattened[key]; + hasValue = ensureArray(hasValue) + hasValue.forEach((v) => { + if (key.match(/\.\$hasAny\.\d+$/)) { + const val = dot.get(document, key.replace(/\.\$hasAny\.\d+$/, `.${v}`)); + + if (val !== undefined && typeof val === "number") { + acc[key.replace(/\.\$hasAny\.\d+$/, `.${v}`)] = val; + } + } else { + acc[key.replace(/\.\$has\.\d+$/, `.${v}`)] = dot.get(document, key.replace(/\.\$has\.\d+$/, `.${v}`)) ?? 0; + } + }); + return acc; + } + + if (key.match(/\$has/) || key.match(/\$hasAny/)) { + let hasValue = flattened[key]; + hasValue = ensureArray(hasValue) + hasValue.forEach((v) => { + if (key.match(/\$hasAny/)) { + const val = dot.get(document, key.replace(/\.\$hasAny/, `.${v}`)); + + if (val !== undefined && typeof val === "number") { + acc[key.replace(/\.\$hasAny/, `.${v}`)] = val; + } + } else { + acc[key.replace(/\.\$has/, `.${v}`)] = dot.get(document, key.replace(/\.\$has/, `.${v}`)); + } + }); + return acc; + } + + // "a.b.c.$gt" => "a.b.c", assumes we want to mutate the value of 'c'. + const removed = key.replace(/\.\$.*$/, ""); + acc[removed] = flattened[key]; + return acc; + }, {}); + + Ok(flattened).forEach((key) => { + const targetValue = dot.get(document, key); + + // We only operate on properties that are either undefined or already a number. + if (targetValue !== undefined && typeof targetValue !== "number") { + return; + } + + // It's possible that targetValue is undefined, for example + // if we deeply selected this document, e.g. + // Given document: + // { a: { b: { c: 1 } } } + // The operation: + // update({ c: 1 }, { $inc: 5 }); + // Would find the above document, but create a new + // property `c` at the root level of the document: + // { a: { b: { c: 1 } }, c: 5 } + // + // To update the deep `c`, we'd do something like: + // update({ "a.b.c": 1 }, { $inc: 5 }); + // or: + // update({ c: 1 }, { $inc: { "a.b.c": 5 } }); + // or: + // update({ a: { b: { c: 1 } } }, { $inc: 5 }); + switch (op) { + case Op.Inc: + if (targetValue === undefined) { + document = dot.set(document, key, mod); + } else { + document = dot.set(document, key, Number(targetValue) + mod); + } + break; + case Op.Dec: + if (targetValue === undefined) { + document = dot.set(document, key, -mod); + } else { + document = dot.set(document, key, Number(targetValue) - mod); + } + break; + case Op.Mult: + if (targetValue === undefined) { + document = dot.set(document, key, mod); + } else { + document = dot.set(document, key, Number(targetValue) * mod); + } + break; + case Op.Div: + if (targetValue === undefined) { + document = dot.set(document, key, mod); + } else { + document = dot.set(document, key, Number(targetValue) / mod); + } + break; + } + + return; + }); + } + }); + + return document; + }); + + return source; +} + +export function $inc( + source: T[], + modifiers: any, + query: object, + collection: Collection +): T[] { + return math(source, modifiers, query, Op.Inc, collection); +} + +export function $dec( + source: T[], + modifiers: any, + query: object, + collection: Collection +): T[] { + return math(source, modifiers, query, Op.Dec, collection); +} + +export function $mult( + source: T[], + modifiers: any, + query: object, + collection: Collection +): T[] { + return math(source, modifiers, query, Op.Mult, collection); +} + +export function $div( + source: T[], + modifiers: any, + query: object, + collection: Collection +): T[] { + return math(source, modifiers, query, Op.Div, collection); +} diff --git a/packages/arc-degit/src/operators/mutation/merge.ts b/packages/arc-degit/src/operators/mutation/merge.ts new file mode 100644 index 0000000..05b57b0 --- /dev/null +++ b/packages/arc-degit/src/operators/mutation/merge.ts @@ -0,0 +1,18 @@ +import { Collection } from "../.."; +import { appendProps } from "../../append_props"; +import { ensureArray } from "../../utils"; + +export function $merge( + source: T[], + modifiers: any, + query: object, + collection: Collection +): T[] { + const mods = ensureArray(modifiers); + + mods.forEach((mod) => { + source = appendProps(source, query, mod, true); + }); + + return source; +} diff --git a/packages/arc-degit/src/operators/mutation/push.ts b/packages/arc-degit/src/operators/mutation/push.ts new file mode 100644 index 0000000..272d680 --- /dev/null +++ b/packages/arc-degit/src/operators/mutation/push.ts @@ -0,0 +1,30 @@ +import dot from "dot-wild"; +import { Collection } from "../.."; +import { ensureArray, isObject } from "../../utils"; + +// { $push: { b: 2, c: 3 } } +// { $push: { b: [2, 3] } } +// { $push: { "a.b.c": 2 }} +// { $push: { "a.b.c": [2, 3] }} +export function $push(source: T[], modifiers: any, query: object, collection: Collection): T[] { + const mods = ensureArray(modifiers); + + return mods.reduce((acc, mod) => { + if (isObject(mod)) { + return Object.keys(mod).reduce((docs, key) => { + return docs.map((doc) => { + const original = dot.get(doc, key); + const value = mod[key]; + if (original !== undefined) { + const newValue = Array.isArray(value) ? original.concat(value) : original.concat([value]); + return dot.set(doc, key, newValue); + } + return doc; + }); + }, acc); + } + return acc; + }, source); +} + + diff --git a/packages/arc-degit/src/operators/mutation/set.ts b/packages/arc-degit/src/operators/mutation/set.ts new file mode 100644 index 0000000..876c561 --- /dev/null +++ b/packages/arc-degit/src/operators/mutation/set.ts @@ -0,0 +1,37 @@ +import dot from "dot-wild"; +import { Collection } from "../.."; +import { changeProps } from "../../change_props"; +import { ensureArray, isObject, Ok, unescapedFlatten } from "../../utils"; + +export function $set( + source: T[], + modifiers: any, + query: object, + collection: Collection +): T[] { + const mods = ensureArray(modifiers); + + mods.forEach((mod) => { + if (!isObject(mod)) { + const flattened = unescapedFlatten(query); + + Ok(flattened).forEach((key) => { + source = source.map((doc: T) => dot.set(doc, key, mod)); + }); + + return; + } + + if (isObject(mod)) { + Ok(mod).forEach((key) => { + source = source.map((doc: T) => dot.set(doc, key, mod[key])); + }); + + return; + } + + source = changeProps(source, query, mod, true); + }); + + return source; +} diff --git a/packages/arc-degit/src/operators/mutation/unset.ts b/packages/arc-degit/src/operators/mutation/unset.ts new file mode 100644 index 0000000..044bf76 --- /dev/null +++ b/packages/arc-degit/src/operators/mutation/unset.ts @@ -0,0 +1,26 @@ +import dot from "dot-wild"; +import { Collection } from "../.."; +import { ensureArray } from "../../utils"; + +export function $unset( + source: T[], + modifiers: any, + query: object, + collection: Collection +): T[] { + const mods = ensureArray(modifiers); + + mods.forEach((mod) => { + // { $unset: ["a", "b.c.d"] } + if (Array.isArray(mod)) { + return $unset(source, mod, query, collection); + } + + // { $unset: "a" } or { $unset: "a.b.c" } or { $unset: "a.*.c" } + source = source.map((document) => { + return dot.set(document, mod, undefined); + }); + }); + + return source; +} diff --git a/packages/arc-degit/src/operators/mutation/unshift.ts b/packages/arc-degit/src/operators/mutation/unshift.ts new file mode 100644 index 0000000..19f46e1 --- /dev/null +++ b/packages/arc-degit/src/operators/mutation/unshift.ts @@ -0,0 +1,33 @@ +import dot from "dot-wild"; +import { Collection } from "../.."; +import { ensureArray, isObject } from "../../utils"; + +// { $unshift: { b: 2, c: 3 } } +// { $unshift: { b: [2, 3] } } +// { $unshift: { "a.b.c": 2 }} +// { $unshift: { "a.b.c": [2, 3] }} +export function $unshift( + source: T[], + modifiers: any, + query: object, + collection: Collection +): T[] { + const mods = ensureArray(modifiers); + + return mods.reduce((acc, mod) => { + if (isObject(mod)) { + Object.keys(mod).forEach((key) => { + acc = acc.map((doc: T) => { + const original = dot.get(doc, key); + if (original !== undefined) { + const value = mod[key]; + const newValue = Array.isArray(value) ? value.concat(original) : [value].concat(original); + return dot.set(doc, key, newValue); + } + return doc; + }); + }); + } + return acc; + }, source); +} diff --git a/packages/arc-degit/src/query_options.ts b/packages/arc-degit/src/query_options.ts new file mode 100644 index 0000000..1d8074b --- /dev/null +++ b/packages/arc-degit/src/query_options.ts @@ -0,0 +1,244 @@ +import dot from "dot-wild"; +import _ from "lodash"; +import { QueryOptions } from "./collection"; +import { ensureArray, Ok, Ov } from "./utils"; + +enum ProjectionMode { + Explicit = 0, + ImplicitExclusion = 1, + ImplicitInclusion = 2, +} + +const getSortFunctions = (keys: string[]) => + keys.map((key) => (item: any) => item[key]); + +const getSortDirections = (nums: number[]) => + nums.map((num) => num === 1 ? "asc" : "desc"); + +const applyAggregation = (data: any[], options: QueryOptions): any[] => { + const ops = { + $floor: (item: object, str: string) => { + const prop = dot.get(item, str); + if (typeof prop === "number") { + return Math.floor(prop); + } + return 0; + }, + $ceil: (item: object, str: string) => { + const prop = dot.get(item, str); + if (typeof prop === "number") { + return Math.ceil(prop); + } + return 0; + }, + $sub: (item: object, arr: (string|number)[]) => { + let res = undefined; + for (const a of arr) { + const val = typeof a === "number" ? a : Number(dot.get(item, a) ?? 0); + res = res === undefined ? val : res - val; + } + return res; + }, + $add: (item: object, arr: (string|number)[]) => { + return arr.reduce((acc: number, val: number) => { + const numVal = Number(dot.get(item, val) ?? 0); + return typeof val === 'number' + ? (acc === undefined ? val : acc + val) + : (acc === undefined ? numVal : acc + numVal); + }, undefined); + }, + $mult: (item: object, arr: (string|number)[]) => { + return arr.reduce((res, a) => { + if (typeof a === "number") { + return Number(res) * a; + } else { + return Number(res) * (Number(dot.get(item, a)) || 1); + } + }, 1); + }, + $div: (item: object, arr: (string|number)[]) => { + return arr.reduce((res: number | undefined, a: string | number) => { + const val = typeof a === 'number' ? a : Number(dot.get(item, a) ?? 1); + return res === undefined ? val : res / val; + }, undefined); + }, + $fn: (item: object, fn: (i: any) => unknown) => { + return fn(item); + }, + }; + + Ok(options.aggregate).forEach((key) => { + if (typeof options.aggregate[key] !== "object") return; + Ok(options.aggregate[key]).forEach((operation) => { + if (operation[0] !== "$") return; + if (!ops[operation]) return; + + data = data.map((item) => { + item[key] = ops[operation](item, options.aggregate[key][operation]); + return item; + }); + }); + }); + + return data; +}; + +export const applyQueryOptions = (data: any[], options: QueryOptions): any => { + if (options.aggregate) { + data = applyAggregation(data, options); + } + + // Apply projection after aggregation so that we have the opportunity to remove + // any intermediate properties that were used strictly in aggregation and should not + // be included in the result set. + if (options.project) { + // What is the projection mode? + // 1. Implicit exclusion: { a: 1, b: 1 } + // 2. Implicit inclusion: { a: 0, b: 0 } + // 3. Explicit: { a: 0, b: 1 } + const projectionTotal = Ok(options.project).reduce((acc, key) => { + if (typeof options.project[key] === "number" && typeof acc === "number") { + return acc + options.project[key]; + } + }, 0); + + const projectionMode = + projectionTotal === Ok(options.project).length + ? ProjectionMode.ImplicitExclusion + : projectionTotal === 0 + ? ProjectionMode.ImplicitInclusion + : ProjectionMode.Explicit; + + if (projectionMode === ProjectionMode.ImplicitExclusion) { + data = data.map((item) => _.pick(item, Ok(options.project))); + } else if (projectionMode === ProjectionMode.ImplicitInclusion) { + data = data.map((item) => _.omit(item, Ok(options.project))); + } else if (projectionMode === ProjectionMode.Explicit) { + const omit = Ok(options.project).filter((key) => options.project[key] === 0); + data = data.map((item) => _.omit(item, omit)); + } + } + + if (options.sort) { + data = _.orderBy( + data, + getSortFunctions(Ok(options.sort)), + getSortDirections(Ov(options.sort)) + ); + } + + if (options.skip && typeof options.skip === "number") { + data = data.slice(options.skip); + } + + if (options.take && typeof options.take === "number") { + data = data.slice(0, options.take); + } + + const joinData = (data: any[], joinOptions: any[]) => { + return joinOptions.reduce((acc, join) => { + if (!join.collection) throw new Error("Missing required field in join: collection"); + if (!join.from) throw new Error("Missing required field in join: from"); + if (!join.on) throw new Error("Missing required field in join: on"); + if (!join.as) throw new Error("Missing required field in join: as"); + + const qo = join.options || {}; + const db = join.collection; + const tmp = db.createId(); + + const asDotStar = join.as.includes(".") && join.as.includes("*"); + + return acc.map((item) => { + if (!asDotStar) item = dot.set(item, join.as, ensureArray(dot.get(item, join.as))); + item[tmp] = []; + const from = join.from.includes(".") ? dot.get(item, join.from) : item[join.from]; + if (from === undefined) return item; + item = dot.set(item, join.as, []); + + if (Array.isArray(from)) { + from.forEach((key: unknown, index: number) => { + const query = { [`${join.on}`]: key }; + if (asDotStar) { + item = dot.set(item, join.as.replaceAll("*", index.toString()), db.find(query, qo)[0]); + } else { + item[tmp] = item[tmp].concat(db.find(query, qo)); + } + }); + + if (!asDotStar) { + item = dot.set(item, join.as, dot.get(item, join.as).concat(item[tmp])); + } + + delete item[tmp]; + + return item; + } + + const query = { [`${join.on}`]: from }; + + if (!asDotStar) { + item[tmp] = db.find(query, qo); + item = dot.set(item, join.as, dot.get(item, join.as).concat(item[tmp])); + } + + delete item[tmp]; + + return item; + }); + }, data); + }; + + if (options.join) { + data = joinData(data, options.join); + } + + const ifNull = (item: any, opts: Record) => { + for (const key in opts) { + const itemValue = dot.get(item, key); + if (itemValue === null || itemValue === undefined) { + if (typeof opts[key] === "function") { + item = dot.set(item, key, opts[key](item)); + } else { + item = dot.set(item, key, opts[key]); + } + } + } + + return item; + }; + + const ifEmpty = (item: any, opts: Record) => { + const emptyCheckers = { + array: (value: any) => Array.isArray(value) && value.length === 0, + string: (value: any) => typeof value === "string" && value.trim().length === 0, + object: (value: any) => typeof value === "object" && Ok(value).length === 0, + }; + + return Object.entries(opts).reduce((result, [key, value]) => { + const itemValue = dot.get(item, key); + const isEmpty = Object.values(emptyCheckers).some((checker) => checker(itemValue)); + if (isEmpty) { + const newValue = typeof value === "function" ? value(item) : value; + return dot.set(result, key, newValue); + } + return result; + }, item); + }; + + + if (options.ifNull) { + data = data.map((item) => ifNull(item, options.ifNull)); + } + + if (options.ifEmpty) { + data = data.map((item) => ifEmpty(item, options.ifEmpty)); + } + + if (options.ifNullOrEmpty) { + return data + .map((item) => ifNull(item, options.ifNullOrEmpty)) + .map((item) => ifEmpty(item, options.ifNullOrEmpty)); + } + + return data; +}; diff --git a/packages/arc-degit/src/return_found.ts b/packages/arc-degit/src/return_found.ts new file mode 100644 index 0000000..e69b5a0 --- /dev/null +++ b/packages/arc-degit/src/return_found.ts @@ -0,0 +1,174 @@ +import { QueryOptions } from "."; +import dot from "dot-wild"; +import { booleanOperators } from "./operators"; +import { + ensureArray, + isEmptyObject, + isObject, + Ok, + safeHasOwnProperty, +} from "./utils"; + +export const checkAgainstQuery = (source: object, query: object): boolean => { + if (typeof source !== typeof query) return false; + + const process = (src: object | object[], key: string) => { + if (src[key] === query[key]) return true; + + let mods = []; + + // Operators are sometimes a toplevel key: + // find({ $and: [{ a: 1 }, { b: 2 }] }) + if (key.startsWith("$")) mods.push(key); + + // Operators are sometimes a subkey: + // find({ number: { $gt: 100 } }) + // $not is a special case: it calls `returnFound` so will handle subkey mods itself. + if (key !== "$not" && (isObject(query[key]) && !isEmptyObject(query[key]))) { + mods = mods.concat(Ok(query[key]).filter((k) => k.startsWith("$"))); + } + + if (mods.length) { + return mods.every((mod) => { + if (mod === "$not") { + return !booleanOperators[mod](src, query); + } + return booleanOperators[mod](src, query); + }); + } + + if (key.includes(".")) { + return dot.get(src, key) === query[key]; + } + + return ( + safeHasOwnProperty(src, key) && + checkAgainstQuery(src[key], query[key]) + ); + }; + + if (Array.isArray(source) && Array.isArray(query)) { + // if any item in source OR query is either an object or an array, return + // checkAgainstQuery(source, query) for each item in source and query + if ( + source.some((item) => isObject(item) || Array.isArray(item)) || + query.some((item) => isObject(item) || Array.isArray(item)) + ) { + return source.every((_, key) => + checkAgainstQuery(source[key], query[key]) + ); + } + + // otherwise stringify each item and compare equality + return [...source].map((i) => `${i}`).sort().join(",") === [...query].map((i) => `${i}`).sort().join(","); + } + + if (Array.isArray(source) && isObject(query)) { + // supports e.g. [1, 2, 3], { $includes: 1 } + return Ok(query).every((key) => { + return process(source, key); + }); + } + + if (isObject(source) && isObject(query)) { + return Ok(query).every((key) => { + return process(source, key); + }); + } + + return source === query; +}; + +export const returnFound = ( + source: any, + query: any, + options: QueryOptions, + parentDocument: object = null +): any[] | undefined => { + if (source === undefined) return undefined; + + source["internal"] && delete source["internal"]; + + if (safeHasOwnProperty(source, options.returnKey)) { + parentDocument = source; + } + + let result = undefined; + + // If the query included mods, then we defer to the result of those mods + // to determine if we should return a document. + const queryHasMods = Ok(query).some((key) => key.startsWith("$")); + + const appendResult = (item: object) => { + if (!item || isEmptyObject(item)) return; + + result = ensureArray(result); + item = ensureArray(item); + + // Ensure unique on returnKey + if (Array.isArray(result) && Array.isArray(item)) { + const resultIds = result.map((r) => r[options.returnKey]); + if (item.some((i) => resultIds.includes(i[options.returnKey]))) return; + } + + result = result.concat(item); + }; + + const processObject = (item: object) => { + if (!item) return; + + if (safeHasOwnProperty(item, options.returnKey)) parentDocument = item; + if (checkAgainstQuery(item, query)) { + appendResult(parentDocument); + } else { + if (options.deep && !queryHasMods) { + Ok(item).forEach((key) => { + // If key exists within the current query level, then use query[key] as the new + // query for item[key]. + if (isObject(item[key]) || Array.isArray(item[key])) { + if (safeHasOwnProperty(query, key)) { + appendResult(returnFound(item[key], query[key], options, parentDocument)); + } else { + appendResult(returnFound(item[key], query, options, parentDocument)); + } + } + }); + } + } + }; + + source = ensureArray(source); + + if (isObject(query) && Array.isArray(source) && !queryHasMods) { + source.forEach((sourceObject, _index) => { + if (safeHasOwnProperty(sourceObject, options.returnKey)) { + parentDocument = sourceObject; + } + + Ok(query).forEach((key) => { + if (typeof key === "string" && key.includes(".")) { + const sourceValue = dot.get(sourceObject, key); + if (sourceValue !== undefined) { + appendResult(returnFound(sourceValue, query[key], options, parentDocument)); + } + } else { + if (isObject(sourceObject)) { + if (checkAgainstQuery(source[_index], query[key])) { + appendResult(parentDocument); + } + } else if (checkAgainstQuery(source, query[key])) { + appendResult(parentDocument); + } + } + }); + }); + } + + if (!isEmptyObject(query) && Array.isArray(source)) { + source.forEach((item) => processObject(item)); + } else { + return source; + } + + return result; +}; diff --git a/packages/arc-degit/src/sharded_collection.ts b/packages/arc-degit/src/sharded_collection.ts new file mode 100644 index 0000000..f042b22 --- /dev/null +++ b/packages/arc-degit/src/sharded_collection.ts @@ -0,0 +1,141 @@ +import { AdapterConstructor, AdapterConstructorOptions } from "./adapter"; +import { Collection, CollectionOptions, QueryOptions } from "./collection"; +import { Ok } from "./utils"; + +export type ShardOptions = { + shardKey: string; + shardCount: number; + adapter: AdapterConstructor; + adapterOptions: AdapterConstructorOptions; +}; + +export class ShardedCollection { + private collectionOptions: CollectionOptions; + public shards: { [key: string]: Collection } = {}; + + private shardKey: string; + private shardCount: number; + + private adapter: AdapterConstructor; + private adapterOptions: AdapterConstructorOptions; + + constructor( + collectionOptions: CollectionOptions, + shardOptions: ShardOptions + ) { + this.collectionOptions = collectionOptions; + this.shardKey = shardOptions.shardKey; + this.shardCount = shardOptions.shardCount; + this.adapter = shardOptions.adapter; + this.adapterOptions = shardOptions.adapterOptions; + } + + private getShard(doc: T): Collection { + const key = (doc as any)[this.shardKey]; + + if (key === undefined) { + throw new Error(`Shard key ${this.shardKey} is not found in document`); + } + + const shardId = this.hashCode(key.toString()) % this.shardCount; + + if (this.shards[shardId] === undefined) { + const adapterOptions = { + ...this.adapterOptions, + name: `${ + this.adapterOptions?.name || "collection" + }_shard${shardId}.json`, + }; + + this.shards[shardId] = new Collection({ + ...this.collectionOptions, + adapter: new this.adapter(adapterOptions), + }); + } + + return this.shards[shardId]; + } + + private hashCode(str: string): number { + let hash = 0; + if (str.length === 0) return hash; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // convert to 32bit int + } + + return hash; + } + + find(query?: object, options: QueryOptions = {}): T[] { + const docs = []; + + for (const shardId of Ok(this.shards)) { + const shardDocs = this.shards[shardId].find(query, options); + docs.push(...shardDocs); + } + + return docs; + } + + insert(docs: T[] | T): T[] { + if (!Array.isArray(docs)) { + docs = [docs]; + } + + const insertedDocs = []; + + for (const doc of docs) { + const shard = this.getShard(doc); + insertedDocs.push(...shard.insert(doc)); + } + + return insertedDocs; + } + + update(query: object, operations: object, options: QueryOptions = {}): T[] { + const updatedDocs = []; + + for (const shardId of Ok(this.shards)) { + const shardDocs = this.shards[shardId].update(query, operations, options); + updatedDocs.push(...shardDocs); + } + + return updatedDocs; + } + + upsert(query: object, operations: object, options: QueryOptions = {}): T[] { + const upsertedDocs = []; + + for (const shardId of Ok(this.shards)) { + const shardDocs = this.shards[shardId].upsert(query, operations, options); + upsertedDocs.push(...shardDocs); + } + + return upsertedDocs; + } + + remove(query: object, options: QueryOptions = {}): T[] { + const removedDocs = []; + + for (const shardId of Ok(this.shards)) { + const shardDocs = this.shards[shardId].remove(query, options); + removedDocs.push(...shardDocs); + } + + return removedDocs; + } + + drop(): void { + for (const shardId of Ok(this.shards)) { + this.shards[shardId].drop(); + } + } + + sync(): void { + for (const shardId of Ok(this.shards)) { + this.shards[shardId].sync(); + } + } +} diff --git a/packages/arc-degit/src/transaction.ts b/packages/arc-degit/src/transaction.ts new file mode 100644 index 0000000..9931038 --- /dev/null +++ b/packages/arc-degit/src/transaction.ts @@ -0,0 +1,124 @@ +import { Collection, ID_KEY, QueryOptions } from "./collection"; + +enum OpType { + INSERT = "insert", + UPDATE = "update", + REMOVE = "remove" +}; + +interface UpdateOperation { + documents: T[]; + operations: object; + options: QueryOptions; +} + +export class Transaction { + collection: Collection; + inserted: T[][] = []; + removed: T[][] = []; + updated: UpdateOperation[] = []; + + operations: OpType[] = []; + + constructor(collection: Collection) { + this.collection = collection; + } + + insert(documents: T[] | T): T[] { + const inserted = this.collection.insert(documents); + this.inserted.push(inserted); + this.operations.push(OpType.INSERT); + return inserted; + } + + update(query: object, operations: object, options: QueryOptions = {}): T[] { + // Given the query, find the documents without any projection or joining applied + // so we can store the original documents in the transaction. + const documents = this.collection.find(query, { + ...options, + project: undefined, + join: undefined, + }); + + // Store the original documents in the transaction. + this.updated.push({ documents, operations, options }); + + this.operations.push(OpType.UPDATE); + + // Then, run the update using the original query, operations, and options. + return this.collection.update(query, operations, options); + } + + remove(query: object, options: QueryOptions = {}): T[] { + // Following similar logic to update, find the original documents + // using the query and options, but without any projection or joining. + const removed = this.collection.find(query, { + ...options, + project: undefined, + join: undefined, + }); + this.collection.remove(query, options); + this.removed.push(removed); + this.operations.push(OpType.REMOVE); + return removed; + } + + rollback() { + const uninsert = (documents: T[]) => { + documents.forEach((document) => { + if (document[ID_KEY] !== undefined) { + this.collection.remove({ [ID_KEY]: document[ID_KEY] }) + } else { + this.collection.remove({ ...document } as unknown as object); + } + }); + }; + + const unupdate = (operation: UpdateOperation) => { + operation.documents.forEach((document) => { + if (document[ID_KEY] !== undefined) { + this.collection.assign(document[ID_KEY], document); + } else { + this.collection.update({ ...document } as unknown as object, operation.operations, operation.options); + } + }); + }; + + const unremove = (documents: T[]) => { + documents.forEach((document) => { + if (document[ID_KEY] !== undefined) { + this.collection.assign(document[ID_KEY], document); + } + }); + }; + + this.operations.reverse().forEach((op) => { + switch (op) { + case OpType.INSERT: + uninsert(this.inserted.pop() as T[]); + break; + case OpType.UPDATE: + unupdate(this.updated.pop()); + break; + case OpType.REMOVE: + unremove(this.removed.pop() as T[]); + break; + } + }); + + this.inserted = []; + this.updated = []; + this.removed = []; + this.operations = []; + } + + /** + * Finalizes the transaction. + */ + commit() { + this.inserted = []; + this.updated = []; + this.removed = []; + this.collection._transaction = null; + } +} diff --git a/packages/arc-degit/src/update.ts b/packages/arc-degit/src/update.ts new file mode 100644 index 0000000..c7b9721 --- /dev/null +++ b/packages/arc-degit/src/update.ts @@ -0,0 +1,82 @@ +import dot from "dot-wild"; +import { + Collection, + CollectionOptions, + CollectionData, + defaultQueryOptions, + QueryOptions, + ID_KEY, + UPDATED_AT_KEY, +} from "./collection"; +import { processMutationOperators } from "./operators"; +import { applyQueryOptions } from "./query_options"; +import { returnFound } from "./return_found"; +import { ensureArray, Ok, Ov } from "./utils"; + +export function update( + data: CollectionData, + query: any, + operations: object, + options: QueryOptions, + collectionOptions: CollectionOptions, + collection: Collection +): T[] { + options = { ...defaultQueryOptions(), ...options }; + query = ensureArray(query); + + const mutated = []; + + for (const q of query) { + let itemsToMutate = []; + itemsToMutate = returnFound([...Ov(data)], q, options, null); + itemsToMutate = ensureArray(itemsToMutate); + + mutated.push(...processMutationOperators(itemsToMutate, operations, q, collection)); + } + + /** + * If the returnKey is the default (_id), then the mutated items + * should be toplevel documents, meaning they'll have `_created_at` + * and `_updated_at` properties. + * + * This is where mutated items have their `_updated_at` properties updated. + */ + if (options.returnKey === ID_KEY && collectionOptions.timestamps) { + mutated.forEach((item) => { + + let cuid: string; + + if (collectionOptions.integerIds) { + const intid = item[ID_KEY]; + cuid = data.internal.id_map[intid]; + } else { + cuid = item[ID_KEY]; + } + + Ok(collection.indices).forEach((key) => { + if (!dot.get(item, key)) { return; } + + const oldValue = data.internal.index.idToValues[cuid][key]; + const newValue = String(dot.get(item, key)); + + if (oldValue === newValue) { return; } + + data.internal.index.valuesToId[key][newValue] = data.internal.index.valuesToId[key][newValue] || []; + data.internal.index.valuesToId[key][newValue].push(cuid); + data.internal.index.valuesToId[key][oldValue] = data.internal.index.valuesToId[key][oldValue].filter((cuid) => cuid !== cuid); + + if (data.internal.index.valuesToId[key][oldValue].length === 0) { + delete data.internal.index.valuesToId[key][oldValue]; + } + + data.internal.index.idToValues[cuid][key] = newValue; + }); + + item[UPDATED_AT_KEY] = Date.now(); + collection.merge(item[ID_KEY], item); + }); + } + + // Apply query options to mutated results before returning them. + return applyQueryOptions(mutated, options); +} diff --git a/packages/arc-degit/src/utils.ts b/packages/arc-degit/src/utils.ts new file mode 100644 index 0000000..47b5635 --- /dev/null +++ b/packages/arc-degit/src/utils.ts @@ -0,0 +1,57 @@ +import dot from "dot-wild"; + +export function ensureArray(input: any): any[] { + if (Array.isArray(input)) return input; + else if (input === undefined || input === null) return []; + else return [input]; +}; + +export function isObject(item: any): item is object { + return !!item && Object.prototype.toString.call(item) === "[object Object]"; +} + +export function isEmptyObject(item: any) { + return isObject(item) && Ok(item).length === 0; +} + +export const Ov = Object.values; +export const Ok = Object.keys; + +export const safeHasOwnProperty = (obj: object, prop: string) => + obj ? Object.prototype.hasOwnProperty.call(obj, prop) : false; + +export function isFunction(item: any) { + return typeof item === "function"; +} + +/** + * Recursively removes empty objects from an object. + * + * @example + * ``` + * { a: { b: 1, c: { d: { e: {} } } } } + * becomes + * { a: { b: 1 } } + * ``` + */ +export function deeplyRemoveEmptyObjects(o: object) { + if (!isObject(o)) return o; + + Ok(o).forEach((k) => { + if (!o[k] || !isObject(o[k])) return; + deeplyRemoveEmptyObjects(o[k]); + if (Ok(o[k]).length === 0) delete o[k]; + }); + + return o; +} + +export function unescapedFlatten(o: object) { + const flattened = dot.flatten(o); + + return Ok(flattened).reduce((acc, key) => { + const unescapedKey = key.replace(/\\./g, "."); + acc[unescapedKey] = flattened[key]; + return acc; + }, {}); +} diff --git a/packages/arc-degit/tests/common.ts b/packages/arc-degit/tests/common.ts new file mode 100644 index 0000000..1dd0cfb --- /dev/null +++ b/packages/arc-degit/tests/common.ts @@ -0,0 +1,68 @@ +import { Collection, CREATED_AT_KEY, ID_KEY, UPDATED_AT_KEY } from "../src/collection"; +import EncryptedFSAdapter from "../src/adapter/enc_fs"; +import FSAdapter from "../src/adapter/fs"; +import { ShardedCollection } from "../src/sharded_collection"; + +const getCollection = ({ name = "test", integerIds = false, populate = true, timestamps = true }): Collection => { + const collection = new Collection({ + autosync: false, + integerIds, + timestamps, + adapter: new FSAdapter({ storagePath: ".test", name }), + }); + collection.drop(); + + if (populate) { + // Adding some items to ensure that result sets correctly + // ignore unmatched queries in all cases. + // @ts-ignore + collection.insert({ xxx: "xxx" }); + // @ts-ignore + collection.insert({ yyy: "yyy" }); + // @ts-ignore + collection.insert({ zzz: "zzz" }); + } + + return collection; +}; + +const getEncryptedCollection = ({ name = "test", integerIds = false }): Collection => { + return new Collection({ + autosync: false, + integerIds, + adapter: new EncryptedFSAdapter({ storagePath: ".test", name }), + }); +}; + +export function testCollection({ name = "test", integerIds = false, populate = true, timestamps = true } = {}): Collection { + return getCollection({ name, integerIds, populate, timestamps }); +} + +export function testCollectionEncrypted({ name = "test", integerIds = false } = {}): Collection { + return getEncryptedCollection({ name, integerIds }); +} + +export function getShardedCollection({ name ="testShard", autosync = true, integerIds = false } = {}): ShardedCollection { + return new ShardedCollection( + { autosync, integerIds }, + { + shardKey: "key", + shardCount: 3, + adapter: FSAdapter, + adapterOptions: { name, storagePath: ".test" }, + }, + ); +}; + +export function nrml(results: T[], { keepIds = false } = {}): T[] { + // Remove all the _id fields, and + // remove all the `_created_at` and `_updated_at` fields. + return results.map((result) => { + if (!keepIds) { + delete result[ID_KEY]; + } + + delete result[CREATED_AT_KEY]; + delete result[UPDATED_AT_KEY]; + return result; + }); } diff --git a/packages/arc-degit/tests/index.ts b/packages/arc-degit/tests/index.ts new file mode 100644 index 0000000..9d1689f --- /dev/null +++ b/packages/arc-degit/tests/index.ts @@ -0,0 +1,58 @@ +import { describe } from "manten"; + +await describe("find", async ({ runTestSuite }) => { + runTestSuite(import("./specs/finding")); +}); + +await describe("filter", async ({ runTestSuite }) => { + runTestSuite(import("./specs/filter")); +}); + +await describe("insert", async ({ runTestSuite }) => { + runTestSuite(import("./specs/insert")); +}); + +await describe("options", async ({ runTestSuite }) => { + runTestSuite(import("./specs/options")); +}); + +await describe("operators", ({ runTestSuite }) => { + runTestSuite(import("./specs/operators/boolean")); + runTestSuite(import("./specs/operators/mutation")); +}); + +await describe("upsert", ({ runTestSuite }) => { + runTestSuite(import("./specs/upsert")); +}); + +await describe("transactions", ({ runTestSuite }) => { + runTestSuite(import("./specs/transactions")); +}); + +await describe("remove", ({ runTestSuite }) => { + runTestSuite(import("./specs/remove")); +}); + +await describe("encrypted adapter", ({ runTestSuite }) => { + runTestSuite(import("./specs/encrypted_adapter")); +}); + +await describe("utils", ({ runTestSuite }) => { + runTestSuite(import("./specs/utils")); +}); + +await describe("indexes", ({ runTestSuite}) => { + runTestSuite(import("./specs/index.test.js")); +}); + +await describe("sharded collection", ({ runTestSuite }) => { + runTestSuite(import("./specs/sharded_collection")); +}); + +await describe("Collection.from", ({ runTestSuite }) => { + runTestSuite(import("./specs/from")); +}); + +await describe("core", ({ runTestSuite }) => { + runTestSuite(import("./specs/core")); +}); diff --git a/packages/arc-degit/tests/specs/core/appendProps.test.ts b/packages/arc-degit/tests/specs/core/appendProps.test.ts new file mode 100644 index 0000000..93e5c10 --- /dev/null +++ b/packages/arc-degit/tests/specs/core/appendProps.test.ts @@ -0,0 +1,69 @@ +import { expect, testSuite } from "manten"; +import { appendProps } from "../../../src/append_props"; + +export default testSuite(async ({ describe }) => { + describe("appendProps", ({ test }) => { + test("should append newProps to an object that matches the query", () => { + const source = { id: 1, name: "John" }; + const query = { id: 1 }; + const newProps = { age: 30 }; + const result = appendProps(source, query, newProps); + expect(result).toEqual({ id: 1, name: "John", age: 30 }); + }); + + test("should append newProps to objects in an array that match the query", () => { + const source = [ + { id: 1, name: "John" }, + { id: 2, name: "Jane" }, + ]; + const query = { id: 1 }; + const newProps = { age: 30 }; + const result = appendProps(source, query, newProps); + expect(result).toEqual([ + { id: 1, name: "John", age: 30 }, + { id: 2, name: "Jane" }, + ]); + }); + + test("should merge newProps with matching objects when merge is true", () => { + const source = { id: 1, name: "John" }; + const query = { id: 1 }; + const newProps = { name: "Jonathan", age: 30 }; + const result = appendProps(source, query, newProps, true); + expect(result).toEqual({ id: 1, name: "Jonathan", age: 30 }); + }); + + test("should not modify non-matching objects", () => { + const source = { id: 2, name: "Jane" }; + const query = { id: 1 }; + const newProps = { age: 30 }; + const result = appendProps(source, query, newProps); + expect(result).toEqual({ id: 2, name: "Jane" }); + }); + + test("should return undefined if source is undefined", () => { + const result = appendProps(undefined, {}, {}); + expect(result).toBeUndefined(); + }); + + test("should not modify the source if query does not match", () => { + const source = { id: 1, name: "John" }; + const query = { id: 2 }; + const newProps = { age: 30 }; + const result = appendProps(source, query, newProps); + expect(result).toEqual({ id: 1, name: "John" }); + }); + + test("should handle nested objects", () => { + const source = { id: 1, name: "John", address: { city: "CityA" } }; + const query = { city: "CityA" }; + const newProps = { postalCode: "12345" }; + const result = appendProps(source, query, newProps); + expect(result).toEqual({ + id: 1, + name: "John", + address: { city: "CityA", postalCode: "12345" }, + }); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/core/changeProps.test.ts b/packages/arc-degit/tests/specs/core/changeProps.test.ts new file mode 100644 index 0000000..d475207 --- /dev/null +++ b/packages/arc-degit/tests/specs/core/changeProps.test.ts @@ -0,0 +1,112 @@ +import { expect, testSuite } from "manten"; +import { changeProps } from "../../../src/change_props"; + +export default testSuite(async ({ describe }) => { + describe("changeProps", ({ test }) => { + test("should return undefined for null source", () => { + expect(changeProps(null, {}, {})).toBeUndefined(); + }); + + test("should not modify the source object if query does not match", () => { + const source = { name: "John", age: 30 }; + const query = { name: "Jane" }; + const replaceProps = { age: 25 }; + expect(changeProps(source, query, replaceProps)).toEqual(source); + }); + + test("should modify the source object if query matches", () => { + const source = { name: "John", age: 30 }; + const query = { name: "John" }; + const replaceProps = { age: 25 }; + expect(changeProps(source, query, replaceProps)).toEqual({ + name: "John", + age: 25, + }); + }); + + test("should add new properties if createNewProperties is true", () => { + const source = { name: "John" }; + const query = { name: "John" }; + const replaceProps = { age: 30 }; + expect(changeProps(source, query, replaceProps as any, true)).toEqual({ + name: "John", + age: 30, + }); + }); + + test("should not add new properties if createNewProperties is false", () => { + const source = { name: "John" }; + const query = { name: "John" }; + const replaceProps = { age: 30 }; + expect(changeProps(source, query, replaceProps as any)).toEqual({ + name: "John", + }); + }); + + test("should process arrays", () => { + const source = [ + { name: "John", age: 30 }, + { name: "Jane", age: 28 }, + ]; + const query = { name: "John" }; + const replaceProps = { age: 25 }; + expect(changeProps(source, query as any, replaceProps as any)).toEqual([ + { name: "John", age: 25 }, + { name: "Jane", age: 28 }, + ]); + }); + + test("should handle nested structures, merging existing objects", () => { + const source = [ + { + name: "John", + age: 30, + address: { + city: "New York", + foo: "bar", + }, + }, + ]; + const query = { city: "New York" }; + const replaceProps = { city: "Los Angeles" }; + expect( + changeProps(source, query as any, replaceProps as any) + ).toEqual([ + { + name: "John", + age: 30, + address: { + city: "Los Angeles", + foo: "bar", + }, + }, + ]); + }); + + test("should handle nested structures, overwriting existing objects", () => { + const source = [ + { + name: "John", + age: 30, + address: { + city: "New York", + foo: "bar", + }, + }, + ]; + const query = { address: { city: "New York" } }; + const replaceProps = { address: { city: "Los Angeles" } }; + expect( + changeProps(source, query as any, replaceProps as any) + ).toEqual([ + { + name: "John", + age: 30, + address: { + city: "Los Angeles", + }, + }, + ]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/core/index.ts b/packages/arc-degit/tests/specs/core/index.ts new file mode 100644 index 0000000..2769870 --- /dev/null +++ b/packages/arc-degit/tests/specs/core/index.ts @@ -0,0 +1,10 @@ +import { testSuite } from "manten"; + + +export default testSuite(async ({ describe }) => { + describe("utils", async ({ runTestSuite }) => { + runTestSuite(import("./changeProps.test.js")); + runTestSuite(import("./appendProps.test.js")); + runTestSuite(import("./returnFound.test.js")); + }); +}); diff --git a/packages/arc-degit/tests/specs/core/returnFound.test.ts b/packages/arc-degit/tests/specs/core/returnFound.test.ts new file mode 100644 index 0000000..8dba423 --- /dev/null +++ b/packages/arc-degit/tests/specs/core/returnFound.test.ts @@ -0,0 +1,81 @@ +import { expect, testSuite } from "manten"; +import { returnFound } from "../../../src/return_found"; + +export default testSuite(async ({ describe }) => { + describe("returnFound", ({ test }) => { + test("should return undefined for undefined source", () => { + expect(returnFound(undefined, {}, { returnKey: "id" })).toBeUndefined(); + }); + + test("should ignore internal properties", () => { + const source = { id: 1, internal: true }; + const query = {}; + const options = { returnKey: "id" }; + expect(returnFound(source, query, options)).toEqual([{ id: 1 }]); + }); + + test("should return the document if it matches the query", () => { + const source = [{ id: 1, name: "Test" }]; + const query = { name: "Test" }; + const options = { returnKey: "id", deep: false }; + expect(returnFound(source, query, options)).toEqual([ + { id: 1, name: "Test" }, + ]); + }); + + test("should return undefined if no items match the query", () => { + const source = [{ id: 1, name: "Test" }]; + const query = { name: "Not Found" }; + const options = { returnKey: "id" }; + expect(returnFound(source, query, options)).toBeUndefined(); + }); + + test("should handle nested objects with deep search in dot notation", () => { + const source = [{ id: 1, details: { name: "Nested" } }]; + const query = { "details.name": "Nested" }; + const options = { returnKey: "id", deep: true }; + expect(returnFound(source, query, options)).toEqual([ + { id: 1, details: { name: "Nested" } }, + ]); + }); + + test("should handle nested objects with deep search without dot notation", () => { + const source = [{ id: 1, details: { name: "Nested" } }]; + const query = { details: { name: "Nested" } }; + const options = { returnKey: "id", deep: true }; + expect(returnFound(source, query, options)).toEqual([ + { id: 1, details: { name: "Nested" } }, + ]); + }); + + test("should handle nested objects with deep search without dot notation or a fully-qualified path", () => { + const source = [{ id: 1, details: { name: "Nested" } }]; + const query = { name: "Nested" }; + const options = { returnKey: "id", deep: true }; + expect(returnFound(source, query, options)).toEqual([ + { id: 1, details: { name: "Nested" } }, + ]); + }); + + test("should return unique items based on returnKey", () => { + const source = [ + { id: 1, name: "Duplicate" }, + { id: 1, name: "Duplicate" }, + ]; + const query = { name: "Duplicate" }; + const options = { returnKey: "id" }; + expect(returnFound(source, query, options)).toEqual([ + { id: 1, name: "Duplicate" }, + ]); + }); + + test("should return concatenated results for array items", () => { + const source = [{ id: 1, items: [{ name: "Item1" }, { name: "Item2" }] }]; + const query = { items: { name: "Item1" } }; + const options = { returnKey: "id", deep: true }; + expect(returnFound(source, query, options)).toEqual([ + { id: 1, items: [{ name: "Item1" }, { name: "Item2" }] }, + ]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/encrypted_adapter/adapter.test.ts b/packages/arc-degit/tests/specs/encrypted_adapter/adapter.test.ts new file mode 100644 index 0000000..c0b94d8 --- /dev/null +++ b/packages/arc-degit/tests/specs/encrypted_adapter/adapter.test.ts @@ -0,0 +1,32 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollectionEncrypted } from "../../common"; + +export default testSuite(async ({ test }) => { + test("can write", () => { + const collection = testCollectionEncrypted<{a: number}>({ + name: "enc", + }); + collection.drop(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + collection.sync(); + + expect(nrml(collection.find({ a: 1 }))).toEqual([{ a: 1 }]); + }); + + test("can read", () => { + const collection = testCollectionEncrypted<{a: number}>({ + name: "enc", + }); + + const found = nrml(collection.find({ a: { $gt: 0 } })); + + expect(found).toEqual([ + { a: 1 }, + { a: 2 }, + { a: 3 }, + ]); + }); +}); + diff --git a/packages/arc-degit/tests/specs/encrypted_adapter/index.ts b/packages/arc-degit/tests/specs/encrypted_adapter/index.ts new file mode 100644 index 0000000..2e933c4 --- /dev/null +++ b/packages/arc-degit/tests/specs/encrypted_adapter/index.ts @@ -0,0 +1,5 @@ +import { testSuite } from "manten"; + +export default testSuite(async ({ runTestSuite }) => { + runTestSuite(import("./adapter.test.js")); +}); diff --git a/packages/arc-degit/tests/specs/filter/basic.test.ts b/packages/arc-degit/tests/specs/filter/basic.test.ts new file mode 100644 index 0000000..8b557e3 --- /dev/null +++ b/packages/arc-degit/tests/specs/filter/basic.test.ts @@ -0,0 +1,26 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("filter", ({ test }) => { + + test("works", () => { + const collection = testCollection<{a: number}>(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + const found = nrml(collection.filter((doc) => doc.a > 1)); + expect(found).toEqual([{ a: 2 }, { a: 3 }]); + }); + + test("works with nested properties", () => { + const collection = testCollection<{a: {b: number}}>(); + collection.insert({ a: { b: 1 } }); + collection.insert({ a: { b: 2 } }); + collection.insert({ a: { b: 3 } }); + const found = nrml(collection.filter((doc) => doc.a.b > 1)); + expect(found).toEqual([{ a: { b: 2 } }, { a: { b: 3 } }]); + }); + + }); +}); diff --git a/packages/arc-degit/tests/specs/filter/index.ts b/packages/arc-degit/tests/specs/filter/index.ts new file mode 100644 index 0000000..5fbabf1 --- /dev/null +++ b/packages/arc-degit/tests/specs/filter/index.ts @@ -0,0 +1,7 @@ +import { testSuite } from "manten"; + +export default testSuite(async ({ describe }) => { + describe("filter", async ({ runTestSuite }) => { + runTestSuite(import("./basic.test.js")); + }); +}); diff --git a/packages/arc-degit/tests/specs/finding/basic.test.ts b/packages/arc-degit/tests/specs/finding/basic.test.ts new file mode 100644 index 0000000..9686a64 --- /dev/null +++ b/packages/arc-degit/tests/specs/finding/basic.test.ts @@ -0,0 +1,131 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("find", ({ test }) => { + + test("no results should return an empty array", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + const found = nrml(collection.find({ a: 4 })); + expect(found).toEqual([]); + }); + + test("empty find returns everything", () => { + const collection = testCollection(); + collection.remove({ xxx: "xxx" }); + collection.remove({ yyy: "yyy" }); + collection.remove({ zzz: "zzz" }); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + const found = nrml(collection.find({})); + expect(found).toEqual([{ a: 1 }, { a: 2 }, { a: 3 }]); + }); + + test("simple find", () => { + const collection = testCollection(); + collection.insert({ foo: "bar" }); + collection.insert({ foo: "baz" }); + collection.insert({ foo: "boo" }); + const found = nrml(collection.find({ foo: "bar" })); + expect(found).toEqual([{ foo: "bar" }]); + }); + + test("simple find, more criteria", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 2, c: 3 }); + collection.insert({ a: 1, b: 2, c: 4 }); + collection.insert({ a: 2, b: 3, c: 4 }); + const found = nrml(collection.find({ a: 1, b: 2 })); + expect(found).toEqual([{ a: 1, b: 2, c: 3 }, { a: 1, b: 2, c: 4 }]); + }); + + test("simple find - deep false", () => { + const collection = testCollection(); + collection.insert({ foo: { bar: "bar" } }); + collection.insert({ foo: { bar: "baz" } }); + collection.insert({ foo: { bar: "boo" } }); + const found = nrml(collection.find({ bar: { $includes: "ba" } }, { deep: false })); + expect(found).toEqual([]); + }); + + test("simple find - deep true", () => { + const collection = testCollection(); + collection.insert({ foo: { bar: "baz" } }); + collection.insert({ foo: { bar: "boo" } }); + collection.insert({ foo: { bar: "baz" } }); + const found = nrml(collection.find({ foo: { bar: "baz" } })); + expect(found).toEqual([{ foo: { bar: "baz" } }, { foo: { bar: "baz" } }]); + }); + + test("normal match if deep is false but toplevel matches", () => { + const collection = testCollection(); + collection.insert({ foo: { bar: "bar" } }); + collection.insert({ foo: { bar: "baz" } }); + collection.insert({ foo: { bar: "boo" } }); + const found = nrml(collection.find({ foo: { bar: "bar" } }, { deep: false })); + expect(found).toEqual([{ foo: { bar: "bar" } }]); + }); + + test("multilevel results", () => { + const collection = testCollection(); + collection.insert({ bar: "baz" }); + collection.insert({ foo: { bar: "boo" } }); + collection.insert({ foo: { bar: "baz" } }); + const found = nrml(collection.find({ foo: { bar: "baz" } })); + expect(found).toEqual([{ bar: "baz" }, { foo: { bar: "baz" } }]); + }); + + test("array literal", () => { + const collection = testCollection(); + collection.insert({ foo: ["bar", "baz"] }); + collection.insert({ foo: ["bar", "boo"] }); + collection.insert({ foo: ["baz", "bar"] }); + const found = nrml(collection.find({ foo: ["bar", "baz"] })); + expect(found).toEqual([{ foo: ["bar", "baz"] }, { foo: ["baz", "bar"] }]); + + collection.insert({ nums: [1, 2, 3] }); + collection.insert({ nums: [2, 3, 1] }); + collection.insert({ nums: [1, 3, 5] }); + const found2 = nrml(collection.find({ nums: [3, 2, 1] })); + expect(found2).toEqual([{ nums: [1, 2, 3] }, { nums: [2, 3, 1] }]); + }); + + test("array literal should exclude items that don't match the exact array", () => { + const collection = testCollection(); + collection.insert({ foo: ["bar", 1] }); + collection.insert({ foo: ["bar", 2] }); + collection.insert({ foo: ["bar", 2, 2] }); + collection.insert({ foo: ["bar", 3] }); + collection.insert({ a: { b: { foo: ["bar", 2] } } }); + const found = nrml(collection.find({ foo: ["bar", 2] })); + expect(found).toEqual([{ foo: ["bar", 2] }, { a: { b: { foo: ["bar", 2] } } }]); + }); + + test("find array using object syntax", () => { + const collection = testCollection(); + collection.insert({ a: { b: [ {c: 1}, {c: 2}, {c: 3} ] } }); + const found = nrml(collection.find({ b: { c: 2 } })); + expect(found).toEqual([{ a: { b: [ {c: 1}, {c: 2}, {c: 3} ] } }]); + }); + + test("multiple queries, merged result set", () => { + const collection = testCollection(); + collection.insert({ x: { a: 1 } }); + collection.insert({ y: { b: 1 } }); + const found = nrml(collection.find([{ a: 1 }, { b: 1 }])); + expect(found).toEqual([{ x: { a: 1 } }, { y: { b: 1 } }]); + }); + + test("really deep specificity", () => { + const collection = testCollection(); + collection.insert({ a: { b: { c: { d: { e: { f: { g: { h: { i: { j: { k: 1 } } } } } } } } } } }); + const found = nrml(collection.find({ a: { b: { c: { d: { e: { f: { g: { h: { i: { j: { k: 1 } } } } } } } } } } })); + expect(found).toEqual([{ a: { b: { c: { d: { e: { f: { g: { h: { i: { j: { k: 1 } } } } } } } } } } }]); + }); + + }); +}); diff --git a/packages/arc-degit/tests/specs/finding/index.ts b/packages/arc-degit/tests/specs/finding/index.ts new file mode 100644 index 0000000..cf5b9dd --- /dev/null +++ b/packages/arc-degit/tests/specs/finding/index.ts @@ -0,0 +1,7 @@ +import { testSuite } from "manten"; + +export default testSuite(async ({ describe }) => { + describe("finding", async ({ runTestSuite }) => { + runTestSuite(import("./basic.test.js")); + }); +}); diff --git a/packages/arc-degit/tests/specs/from/from.test.ts b/packages/arc-degit/tests/specs/from/from.test.ts new file mode 100644 index 0000000..a36240d --- /dev/null +++ b/packages/arc-degit/tests/specs/from/from.test.ts @@ -0,0 +1,17 @@ +import { expect, testSuite } from "manten"; +import { Collection } from "../../../src"; +import { nrml } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("from", ({ test }) => { + test("works", () => { + const data = [ + { a: 1, b: 2, c: 3 }, + { a: 2, b: 2, c: 3 }, + ]; + const c = Collection.from(data); + const found = nrml(c.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: 2, c: 3 }]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/from/index.ts b/packages/arc-degit/tests/specs/from/index.ts new file mode 100644 index 0000000..8478b9a --- /dev/null +++ b/packages/arc-degit/tests/specs/from/index.ts @@ -0,0 +1,7 @@ +import { testSuite } from "manten"; + +export default testSuite(async ({ describe }) => { + describe("from", async ({ runTestSuite }) => { + runTestSuite(import("./from.test.js")); + }); +}); diff --git a/packages/arc-degit/tests/specs/index.test.ts b/packages/arc-degit/tests/specs/index.test.ts new file mode 100644 index 0000000..55e3705 --- /dev/null +++ b/packages/arc-degit/tests/specs/index.test.ts @@ -0,0 +1,81 @@ +import { expect, testSuite } from "manten"; +import FSAdapter from "../../src/adapter/fs"; +import { Collection } from "../../src/collection"; + +const getCollection = () => { + const collection = new Collection({ + autosync: false, + integerIds: false, + adapter: new FSAdapter({ storagePath: ".test", name: "index" }), + }); + collection.drop(); + return collection; +}; + +export default testSuite(async ({ describe }) => { + describe("index", ({ test }) => { + test("createIndex throws if the key has a numeric property", () => { + const collection = getCollection(); + expect(() => collection.createIndex({ key: "0" })).toThrow(); + expect(() => collection.createIndex({ key: "a.0" })).toThrow(); + expect(() => collection.createIndex({ key: "a.0.b" })).toThrow(); + }); + + test("can create and remove", () => { + const collection = getCollection(); + collection.createIndex({ key: "name" }); + + expect(collection.indices["name"]).toBeDefined(); + expect(collection.indices["name"].unique).toBe(false); + + collection.removeIndex("name"); + + expect(collection.indices["name"]).toBeUndefined(); + + collection.createIndex({ key: "name", unique: true }); + + expect(collection.indices["name"]).toBeDefined(); + expect(collection.indices["name"].unique).toBe(true); + }); + + test("indexes are tracked properly", () => { + const collection = getCollection(); + collection.createIndex({ key: "person.email" }); + collection.createIndex({ key: "person.name" }); + + collection.insert({ person: { name: "Alice", email: "alice@alice.com", } }); + collection.insert({ person: { name: "Bob", email: "bob@bob.com", } }); + + const alice = collection.find({ "person.name": "Alice" }); + const bob = collection.find({ "person.name": "Bob" }); + + expect(collection.data.internal.index.valuesToId["person.name"]["Alice"]).toEqual([(alice[0] as any)._id]); + expect(collection.data.internal.index.valuesToId["person.email"]["alice@alice.com"]).toEqual([(alice[0] as any)._id]); + expect(collection.data.internal.index.valuesToId["person.name"]["Bob"]).toEqual([(bob[0] as any)._id]); + expect(collection.data.internal.index.valuesToId["person.email"]["bob@bob.com"]).toEqual([(bob[0] as any)._id]); + + expect(collection.data.internal.index.idToValues[(alice[0] as any)._id]["person.name"]).toEqual("Alice"); + expect(collection.data.internal.index.idToValues[(alice[0] as any)._id]["person.email"]).toEqual("alice@alice.com"); + expect(collection.data.internal.index.idToValues[(bob[0] as any)._id]["person.name"]).toEqual("Bob"); + expect(collection.data.internal.index.idToValues[(bob[0] as any)._id]["person.email"]).toEqual("bob@bob.com"); + + collection.update({ person: { name: "Alice" } }, { $merge: { person: { email: "a@a.com" }}}); + + expect(collection.data.internal.index.valuesToId["person.email"]["a@a.com"]).toEqual([(alice[0] as any)._id]); + expect(collection.data.internal.index.idToValues[(alice[0] as any)._id]["person.email"]).toEqual("a@a.com"); + + // no more documents have this email value, so the tracked index key should be removed. + expect(collection.data.internal.index.valuesToId["person.email"]["alice@alice.com"]).toBeUndefined(); + // the person.name index should still be there. + expect(collection.data.internal.index.valuesToId["person.name"]["Alice"]).toEqual([(alice[0] as any)._id]); + + collection.remove({ person: { name: "Alice" } }); + + expect(collection.data.internal.index.valuesToId["person.name"]["Alice"]).toBeUndefined(); + expect(collection.data.internal.index.valuesToId["person.email"]["a@a.com"]).toBeUndefined(); + expect(collection.data.internal.index.idToValues[(alice[0] as any)._id]).toBeUndefined(); + + collection.sync(); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/insert/basic.test.ts b/packages/arc-degit/tests/specs/insert/basic.test.ts new file mode 100644 index 0000000..27d40bf --- /dev/null +++ b/packages/arc-degit/tests/specs/insert/basic.test.ts @@ -0,0 +1,29 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("insert", ({ test }) => { + + test("insert one", () => { + const collection = testCollection(); + collection.insert({ foo: "bar" }); + const found = nrml(collection.find({ foo: "bar" })); + expect(found).toEqual([{ foo: "bar" }]); + }); + + test("insert multiple", () => { + const collection = testCollection(); + collection.insert([{ foo: "bar" }, { foo: "baz" }, { foo: "boo" }]); + const found = nrml(collection.find({ foo: { $includes: "b" } })); + expect(found).toEqual([{ foo: "bar" }, { foo: "baz" }, { foo: "boo" }]); + }); + + test("can insert emojis", () => { + const collection = testCollection(); + collection.insert({ foo: "👍" }); + const found = nrml(collection.find({ foo: "👍" })); + expect(found).toEqual([{ foo: "👍" }]); + }); + + }); +}); \ No newline at end of file diff --git a/packages/arc-degit/tests/specs/insert/index.ts b/packages/arc-degit/tests/specs/insert/index.ts new file mode 100644 index 0000000..bfa1e17 --- /dev/null +++ b/packages/arc-degit/tests/specs/insert/index.ts @@ -0,0 +1,7 @@ +import { testSuite } from "manten"; + +export default testSuite(async ({ describe }) => { + describe("insert", async ({ runTestSuite }) => { + runTestSuite(import("./basic.test.js")); + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/boolean/and.test.ts b/packages/arc-degit/tests/specs/operators/boolean/and.test.ts new file mode 100644 index 0000000..2c6f5f6 --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/boolean/and.test.ts @@ -0,0 +1,80 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$and", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert([ + { a: 1, b: 1, c: 1 }, + { a: 1, b: 1, c: 1 }, + { a: 1, b: 2, c: 3 }, + { a: 2, b: 2, c: 3 }, + ]); + const found = nrml(collection.find({ $and: [{ a: 1 }, { b: 2 }] })); + expect(found).toEqual([{ a: 1, b: 2, c: 3 }]); + }); + + test("nested operators", () => { + const collection = testCollection(); + collection.insert([ + { foo: "bar", num: 5 }, + { foo: "baz", num: 10 }, + { foo: "boo", num: 20 }, + ]); + const found = nrml(collection.find({ $and: [{ foo: { $includes: "ba" } }, { num: { $gt: 9 } }] })); + expect(found).toEqual([{ foo: "baz", num: 10 }]); + }); + + test("deep selectors, explicit and implicit", () => { + const collection = testCollection(); + collection.insert([ + { a: { b: { c: 1, d: 1 } } }, + { a: { b: { c: 1, d: 1 } } }, + { a: { b: { c: 1, d: 3 } } }, + ]); + const found = nrml(collection.find({ $and: [{ a: { b: { c: { $lt: 2 } } } }, { d: 3 }] })); + expect(found).toEqual([{ a: { b: { c: 1, d: 3 } } }]); + }); + + test("shallow and deep selectors", () => { + const collection = testCollection(); + collection.insert([{ a: 15, b: 1 }, { a: 15, b: { c: { d: 100 } } }]); + const found = nrml(collection.find({ $and: [{ a: 15 }, { b: { c: { d: 100 } } }] })); + expect(found).toEqual([{ a: 15, b: { c: { d: 100 } } }]); + }); + + test("functions as conditions", () => { + const collection = testCollection(); + collection.insert([ + { foo: "bar", num: 5 }, + { foo: "baz", num: 10 }, + { foo: "bazzz", num: 20 }, + ]); + const found = nrml(collection.find({ $and: [{ foo: { $includes: "ba" } }, { num: { $gt: 9 } }, { num: (v: number) => v % 10 === 0 }] })); + expect(found).toEqual([{ foo: "baz", num: 10 }, { foo: "bazzz", num: 20 }]); + }); + + test("and matches while respecting other query parameters", () => { + const collection = testCollection(); + collection.insert([ + { a: 1, num: 5 }, + { a: 2, num: 10 }, + { a: 3, num: 20 }, + ]); + const found = nrml(collection.find({ a: 2, $and: [{ num: { $gt: 0 } }, { num: { $lt: 100 } }] })); + expect(found).toEqual([{ a: 2, num: 10 }]); + }); + + test("works with dot notation", () => { + const collection = testCollection(); + collection.insert([ + { a: { b: 1, c: 1 }, d: 1 }, + { a: { b: 1, c: 1 }, d: 1 }, + { a: { b: 1, c: 3 }, d: 3 }, + ]); + const found = nrml(collection.find({ $and: [{ "a.b": 1 }, { "a.c": 3 }] })); + expect(found).toEqual([{ a: { b: 1, c: 3 }, d: 3 }]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/boolean/fn.test.ts b/packages/arc-degit/tests/specs/operators/boolean/fn.test.ts new file mode 100644 index 0000000..76b4443 --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/boolean/fn.test.ts @@ -0,0 +1,59 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$fn", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert([ + { a: 2 }, + { a: 4 }, + { a: 5 }, + { a: 6 }, + ]); + const isEven = (x: number) => x % 2 === 0; + const found = nrml(collection.find({ a: { $fn: isEven } })); + expect(found).toEqual([{ a: 2 }, { a: 4 }, { a: 6 }]); + }); + test("nested", () => { + const collection = testCollection(); + collection.insert([ + { a: { b: { c: 2 } } }, + { a: { b: { c: 4 } } }, + { a: { b: { c: 5 } } }, + { a: { b: { c: 6 } } }, + ]); + const isEven = (x: number) => x % 2 === 0; + const found = nrml(collection.find({ c: { $fn: isEven } })); + expect(found).toEqual([ { a: { b: { c: 2 } } }, { a: { b: { c: 4 } } }, { a: { b: { c: 6 } } } ]); + }); + test("nested, using dot notation", () => { + const collection = testCollection(); + collection.insert([ + { a: { b: { c: 2 } } }, + { a: { b: { c: 4 } } }, + { a: { b: { c: 5 } } }, + { a: { b: { c: 6 } } }, + ]); + const isEven = (x: number) => x % 2 === 0; + const found = nrml(collection.find({ "a.b.c": { $fn: isEven } })); + expect(found).toEqual([ { a: { b: { c: 2 } } }, { a: { b: { c: 4 } } }, { a: { b: { c: 6 } } } ]); + }); + test("multiple functions", () => { + // When using multiple functions, the source value must be true + // for each function in order to be considered a query match. + const collection = testCollection(); + collection.insert([ + { a: 2 }, + { a: 3 }, + { a: 4 }, + { a: 5 }, + { a: 6 }, + ]); + const isOdd = (x: number) => x % 2 !== 0; + const isThree = (x: number) => x === 3; + const found = nrml(collection.find({ a: { $fn: [isThree, isOdd] } })); + expect(found).toEqual([{ a: 3 }]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/boolean/gtlt.test.ts b/packages/arc-degit/tests/specs/operators/boolean/gtlt.test.ts new file mode 100644 index 0000000..f999a85 --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/boolean/gtlt.test.ts @@ -0,0 +1,142 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + + describe("$gt", ({ test }) => { + + test("works", () => { + const collection = testCollection(); + collection.insert([{ a: 2 }, { a: 4 }, { a: 5 }, { a: 6 }]); + const found = nrml(collection.find({ a: { $gt: 4 } })); + expect(found).toEqual([{ a: 5 }, { a: 6 }]); + }); + + test("works with strings", () => { + const collection = testCollection(); + collection.insert([{ a: "a" }, { a: "b" }, { a: "c" }, { a: "d" }]); + const found = nrml(collection.find({ a: { $gt: "b" } })); + expect(found).toEqual([{ a: "c" }, { a: "d" }]); + }); + + test("works with array lengths", () => { + const collection = testCollection(); + collection.insert([{ a: [1, 2] }, { a: [1, 2, 3] }, { a: [1, 2, 3, 4] }]); + const found = nrml(collection.find({ a: { $gt: 3 } })); + expect(found).toEqual([{ a: [1, 2, 3, 4] }]); + }); + + test("works with deeply nested properties using dot notation", () => { + const collection = testCollection(); + collection.insert([{ a: { b: { c: 2 } } }, { a: { b: { c: 4 } } }, { a: { b: { c: 5 } } }, { a: { b: { c: 6 } } }]); + const found = nrml(collection.find({ "a.b.c": { $gt: 4 } })); + expect(found).toEqual([{ a: { b: { c: 5 } } }, { a: { b: { c: 6 } } }]); + }); + + }); + + describe("$lt", ({ test }) => { + + test("works", () => { + const collection = testCollection(); + collection.insert([{ a: 2 }, { a: 4 }, { a: 5 }, { a: 6 }]); + const found = nrml(collection.find({ a: { $lt: 4 } })); + expect(found).toEqual([{ a: 2 }]); + }); + + test("works with strings", () => { + const collection = testCollection(); + collection.insert([{ a: "a" }, { a: "b" }, { a: "c" }, { a: "d" }]); + const found = nrml(collection.find({ a: { $lt: "b" } })); + expect(found).toEqual([{ a: "a" }]); + }); + + test("works with array lengths", () => { + const collection = testCollection(); + collection.insert([{ a: [1, 2] }, { a: [1, 2, 3] }, { a: [1, 2, 3, 4] }]); + const found = nrml(collection.find({ a: { $lt: 3 } })); + expect(found).toEqual([{ a: [1, 2] }]); + }); + + test("works with deeply nested properties using dot notation", () => { + const collection = testCollection(); + collection.insert([{ a: { b: { c: 2 } } }, { a: { b: { c: 4 } } }, { a: { b: { c: 5 } } }, { a: { b: { c: 6 } } }]); + const found = nrml(collection.find({ "a.b.c": { $lt: 4 } })); + expect(found).toEqual([{ a: { b: { c: 2 } } }]); + }); + + }); + + describe("$gte", ({ test }) => { + + test("works", () => { + const collection = testCollection(); + collection.insert([{ a: 2 }, { a: 4 }, { a: 5 }, { a: 6 }]); + const found = nrml(collection.find({ a: { $gte: 4 } })); + expect(found).toEqual([{ a: 4 }, { a: 5 }, { a: 6 }]); + }); + + test("works with strings", () => { + const collection = testCollection(); + collection.insert([{ a: "a" }, { a: "b" }, { a: "c" }, { a: "d" }]); + const found = nrml(collection.find({ a: { $gte: "b" } })); + expect(found).toEqual([{ a: "b" }, { a: "c" }, { a: "d" }]); + }); + + test("works with array lengths", () => { + const collection = testCollection(); + collection.insert([{ a: [1, 2] }, { a: [1, 2, 3] }, { a: [1, 2, 3, 4] }]); + const found = nrml(collection.find({ a: { $gte: 3 } })); + expect(found).toEqual([{ a: [1, 2, 3] }, { a: [1, 2, 3, 4] }]); + }); + + test("works with deeply nested properties using dot notation", () => { + const collection = testCollection(); + collection.insert([{ a: { b: { c: 2 } } }, { a: { b: { c: 4 } } }, { a: { b: { c: 5 } } }, { a: { b: { c: 6 } } }]); + const found = nrml(collection.find({ "a.b.c": { $gte: 4 } })); + expect(found).toEqual([{ a: { b: { c: 4 } } }, { a: { b: { c: 5 } } }, { a: { b: { c: 6 } } }]); + }); + + }); + + describe("$lte", ({ test }) => { + + test("works", () => { + const collection = testCollection(); + collection.insert([{ a: 2 }, { a: 4 }, { a: 5 }, { a: 6 }]); + const found = nrml(collection.find({ a: { $lte: 4 } })); + expect(found).toEqual([{ a: 2 }, { a: 4 }]); + }); + + test("works with strings", () => { + const collection = testCollection(); + collection.insert([{ a: "a" }, { a: "b" }, { a: "c" }, { a: "d" }]); + const found = nrml(collection.find({ a: { $lte: "b" } })); + expect(found).toEqual([{ a: "a" }, { a: "b" }]); + }); + + test("works with array lengths", () => { + const collection = testCollection(); + collection.insert([{ a: [1, 2] }, { a: [1, 2, 3] }, { a: [1, 2, 3, 4] }]); + const found = nrml(collection.find({ a: { $lte: 3 } })); + expect(found).toEqual([{ a: [1, 2] }, { a: [1, 2, 3] }]); + }); + + test("works with deeply nested properties using dot notation", () => { + const collection = testCollection(); + collection.insert([ + { a: { b: { c: 1 } } }, + { a: { b: { c: 2 } } }, + { a: { b: { c: 3 } } }, + { a: { b: { c: 4 } } }, + ]); + const found = nrml(collection.find({ "a.b.c": { $lte: 2 } })); + expect(found).toEqual([ + { a: { b: { c: 1 } } }, + { a: { b: { c: 2 } } }, + ]); + }); + + }); + +}); diff --git a/packages/arc-degit/tests/specs/operators/boolean/has.test.ts b/packages/arc-degit/tests/specs/operators/boolean/has.test.ts new file mode 100644 index 0000000..a6c4d66 --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/boolean/has.test.ts @@ -0,0 +1,39 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$has", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert([{ a: 2 }, { b: 4 }, { c: 5 }, { a: 6 }]); + const found = nrml(collection.find({ $has: "a" })); + expect(found).toEqual([{ a: 2 }, { a: 6 }]); + }); + + test("works with more than one property", () => { + const collection = testCollection(); + collection.insert([{ a: 2, b: 1 }, { b: 4 }, { a: 5 }, { a: 6, b: 3 }]); + const found = nrml(collection.find({ $has: ["a", "b"] })); + expect(found).toEqual([{ a: 2, b: 1 }, { a: 6, b: 3 }]); + }); + + test("works with $not", () => { + const collection = testCollection(); + collection.insert([{ a: 2 }, { b: 4 }, { c: 5 }, { a: 6 }]); + const found = nrml(collection.find({ $not: { $has: "a" } })); + expect(found).toEqual([ + { xxx: "xxx" }, + { yyy: "yyy" }, + { zzz: "zzz" }, + { b: 4 }, { c: 5 } + ]); + }); + + test("works with dot notation", () => { + const collection = testCollection(); + collection.insert([{ a: { b: 2 } }, { a: { c: 4 } }, { a: { d: 5 } }, { a: { b: 6 } }]); + const found = nrml(collection.find({ $has: "a.b" })); + expect(found).toEqual([{ a: { b: 2 } }, { a: { b: 6 } }]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/boolean/hasAny.test.ts b/packages/arc-degit/tests/specs/operators/boolean/hasAny.test.ts new file mode 100644 index 0000000..07ee235 --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/boolean/hasAny.test.ts @@ -0,0 +1,51 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$hasAny", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert([{ a: 2 }, { b: 4 }, { c: 5 }, { a: 6 }]); + const found = nrml(collection.find({ $hasAny: "a" })); + expect(found).toEqual([{ a: 2 }, { a: 6 }]); + }); + + test("works with more than one property", () => { + const collection = testCollection(); + collection.insert([{ a: 2, b: 1 }, { b: 4 }, { a: 5 }, { a: 6, b: 3 }, { c: 5 }]); + const found = nrml(collection.find({ $hasAny: ["a", "b"] })); + expect(found).toEqual([{ a: 2, b: 1 }, { b: 4 }, { a: 5 }, { a: 6, b: 3 }]); + }); + + test("works with $not", () => { + const collection = testCollection(); + collection.insert([{ a: 2 }, { b: 4 }, { c: 5 }, { a: 6 }]); + const found = nrml(collection.find({ $not: { $hasAny: ["a", "b"] } })); + expect(found).toEqual([ + { xxx: "xxx" }, + { yyy: "yyy" }, + { zzz: "zzz" }, + { c: 5 } + ]); + }); + + test("works with dot notation", () => { + const collection = testCollection(); + collection.insert([{ a: { b: 2 } }, { b: 4 }, { c: 5 }, { a: { b: 6 } }]); + const found = nrml(collection.find({ $hasAny: "a.b" })); + expect(found).toEqual([{ a: { b: 2 } }, { a: { b: 6 } }]); + }); + + test("works with leading dot notation to narrowly scope $hasAny", () => { + const collection = testCollection(); + collection.insert([ + { a: { b: { c: { d: 2 } } } }, + { b: 4 }, + { c: 5 }, + { a: { b: { c: { e: 6 } } } } + ]); + const found = nrml(collection.find({ "a.b.c": { $hasAny: "d"} })); + expect(found).toEqual([{ a: { b: { c: { d: 2 } } } }]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/boolean/includes.test.ts b/packages/arc-degit/tests/specs/operators/boolean/includes.test.ts new file mode 100644 index 0000000..2ebeb1b --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/boolean/includes.test.ts @@ -0,0 +1,55 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$includes", ({ test }) => { + test("simple string", () => { + const collection = testCollection(); + collection.insert({ foo: "bar" }); + collection.insert({ foo: "baz" }); + collection.insert({ foo: "boo" }); + const found = nrml(collection.find({ foo: { $includes: "ba" } })); + expect(found.length).toEqual(2); + expect(found).toEqual([{ foo: "bar" }, { foo: "baz" }]); + }); + + test("simple string deep", () => { + const collection = testCollection(); + collection.insert({ a: { b: { foo: "bar" } } }); + collection.insert({ a: { b: { foo: "baz" } } }); + collection.insert({ a: { b: { foo: "boo" } } }); + const found = nrml(collection.find({ a: { b: { foo: { $includes: "ba" } } } })); + expect(found.length).toEqual(2); + expect(found).toEqual([{ a: { b: { foo: "bar" } } }, { a: { b: { foo: "baz" } } }]); + }); + + test("simple array", () => { + const collection = testCollection(); + collection.insert({ foo: [1, 2, 3] }); + collection.insert({ foo: [1, 2, 4] }); + collection.insert({ foo: [5, 6, 7] }); + const found = nrml(collection.find({ foo: { $includes: 2 } })); + expect(found.length).toEqual(2); + expect(found).toEqual([{ foo: [1, 2, 3] }, { foo: [1, 2, 4] }]); + }); + + test("simple array deep", () => { + const collection = testCollection(); + collection.insert({ a: { b: [1, 2, 3] }}); + collection.insert({ a: { b: [1, 2, 4] }}); + collection.insert({ a: { b: [5, 6, 7] }}); + const found = nrml(collection.find({ a: { b: { $includes: 2 } } })); + expect(found.length).toEqual(2); + expect(found).toEqual([{ a: { b: [1, 2, 3] } }, { a: { b: [1, 2, 4] } }]); + }); + + test("includes array", () => { + const collection = testCollection(); + collection.insert({ a: { b: [1, 2, 3] }}); + collection.insert({ a: { b: [1, 2, 4] }}); + collection.insert({ a: { b: [5, 6, 7] }}); + const found = nrml(collection.find({ a: { b: { $includes: [1, 2] } } })); + expect(found).toEqual([{ a: { b: [1, 2, 3] } }, { a: { b: [1, 2, 4] } }]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/boolean/index.ts b/packages/arc-degit/tests/specs/operators/boolean/index.ts new file mode 100644 index 0000000..212c94a --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/boolean/index.ts @@ -0,0 +1,18 @@ +import { testSuite } from "manten"; + +export default testSuite(async ({ describe }) => { + describe("boolean", async ({ runTestSuite }) => { + runTestSuite(import("./includes.test.js")); + runTestSuite(import("./and.test.js")); + runTestSuite(import("./or.test.js")); + runTestSuite(import("./xor.test.js")); + runTestSuite(import("./fn.test.js")); + runTestSuite(import("./re.test.js")); + runTestSuite(import("./oneOf.test.js")); + runTestSuite(import("./length.test.js")); + runTestSuite(import("./not.test.js")); + runTestSuite(import("./has.test.js")); + runTestSuite(import("./hasAny.test.js")); + runTestSuite(import("./gtlt.test.js")); + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/boolean/length.test.ts b/packages/arc-degit/tests/specs/operators/boolean/length.test.ts new file mode 100644 index 0000000..c41c3ef --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/boolean/length.test.ts @@ -0,0 +1,17 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$length", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert({ foo: [0, 0] }); + collection.insert({ foo: [0, 0, 0] }); + collection.insert({ foo: [0, 0, 0] }); + collection.insert({ foo: "abc" }); + collection.insert({ foo: "abcd" }); + const found = nrml(collection.find({ foo: { $length: 3 } })); + expect(found).toEqual([{ foo: [0, 0, 0] }, { foo: [0, 0, 0] }, { foo: "abc" }]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/boolean/not.test.ts b/packages/arc-degit/tests/specs/operators/boolean/not.test.ts new file mode 100644 index 0000000..b80be0c --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/boolean/not.test.ts @@ -0,0 +1,223 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$not", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert([ + { a: 1, b: 2, c: 3 }, + { a: 2, b: 2, c: 3 }, + ]); + const found = nrml(collection.find({ $not: { a: 1 } })); + expect(found).toEqual([ + { xxx: "xxx" }, + { yyy: "yyy" }, + { zzz: "zzz" }, + { a: 2, b: 2, c: 3 } + ]); + }); + test("works with $and", () => { + const collection = testCollection(); + collection.insert([ + { a: 1, b: 2, c: 3 }, + { a: 2, b: 2, c: 3 }, + { a: 3, b: 2, c: 3 }, + ]); + const found = nrml(collection.find({ $and: [{ $not: { a: 1 } }, { $not: { a: 2 }}] })); + expect(found).toEqual([ + { xxx: "xxx" }, + { yyy: "yyy" }, + { zzz: "zzz" }, + { a: 3, b: 2, c: 3 } + ]); + }); + test("works with other mods", () => { + const collection = testCollection(); + collection.insert([ + { a: 1, b: 2, c: 3 }, + { a: 2, b: 2, c: 3 }, + { a: 3, b: 2, c: 3 }, + ]); + const found = nrml(collection.find({ $not: { a: { $lte: 2 }}})); + expect(found).toEqual([ + { xxx: "xxx" }, + { yyy: "yyy" }, + { zzz: "zzz" }, + { a: 3, b: 2, c: 3 } + ]); + }); + test("works with $and", () => { + const collection = testCollection(); + collection.insert([ + { a: 1, b: 2, c: 3 }, + { a: 2, b: 2, c: 3 }, + { a: 3, b: 2, c: 3 }, + { a: 5, b: 2, c: 3 }, + { a: 7, b: 2, c: 3 }, + ]); + const found = nrml(collection.find({ $and: [{ $not: { a: { $lte: 2 }}}, { $not: { a: { $gte: 5 }}}] })); + expect(found).toEqual([ + { xxx: "xxx" }, + { yyy: "yyy" }, + { zzz: "zzz" }, + { a: 3, b: 2, c: 3 } + ]); + }); + test("expects all provided cases to be true (does not behave as $or)", () => { + const collection = testCollection(); + collection.insert([ + { a: 1, b: 2, c: 3 }, + { a: 2, b: 2, c: 3 }, + { a: 3, b: 3, c: 3 }, + ]); + const found = nrml(collection.find({ $not: { a: 1, b: 2 }})); + expect(found).toEqual([ + { xxx: "xxx" }, + { yyy: "yyy" }, + { zzz: "zzz" }, + { a: 2, b: 2, c: 3 }, // <-- matches because a is not 1 + { a: 3, b: 3, c: 3 }, // <-- matches because a is not 1 AND b is not 2 + ]); + }); + + test("works with dot notation", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: { b: 1 } }, { a: { b: 2 } } + ]); + const found = nrml(collection.find({ $not: { "a.b": 1 }})); + expect(found).toEqual([ + { a: { b: 2 } }, + ]); + }); + + test("works with leading properties", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: { b: 1 } }, { a: { b: 2 } } + ]); + const found = nrml(collection.find({ a: { $not: { b: 1 }}})); + expect(found).toEqual([ + { a: { b: 2 } }, + ]); + }); + + test("works with leading properties very deeply", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: { b: { c: { d: 1 } } } }, { a: { b: { c: { d: 2 } } } } + ]); + const found = nrml(collection.find({ a: { b: { c: { $not: { d: 1 }}}}})); + expect(found).toEqual([ + { a: { b: { c: { d: 2 } } } }, + ]); + }); + + test("works with $includes -> $not: { $includes: ... }", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: [1, 2, 3] }, { a: [2, 3, 4] } + ]); + const found = nrml(collection.find({ $not: { a: { $includes: 1 } } })); + expect(found).toEqual([ + { a: [2, 3, 4] }, + ]); + }); + + test("works with $includes, deeply", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: { b: [1, 2, 3] } }, { a: { b: [2, 3, 4] } } + ]); + const found = nrml(collection.find({ $not: { a: { b: { $includes: 1 } } } })); + expect(found).toEqual([{ a: { b: [2, 3, 4] } }]); + }); + + test("works with $includes, very deeply", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: { b: { c: { d: [1, 2, 3] } } } }, { a: { b: { c: { d: [2, 3, 4] } } } } + ]); + const found = nrml(collection.find({ $not: { a: { b: { c: { d: { $includes: 1 } } } } } })); + expect(found).toEqual([{ a: { b: { c: { d: [2, 3, 4] } } } }]); + }); + + test("works with $includes, deep, using dot notation", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: { b: [1, 2, 3] } }, { a: { b: [2, 3, 4] } } + ]); + const found = nrml(collection.find({ $not: { "a.b": { $includes: 1 } } })); + expect(found).toEqual([{ a: { b: [2, 3, 4] } }]); + }); + + test("works with $includes, infinitely deep, using dot notation", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: { b: { c: { d: [1, 2, 3] } } } }, { a: { b: { c: { d: [2, 3, 4] } } } } + ]); + const found = nrml(collection.find({ $not: { "a.b.c.d": { $includes: 1 } } })); + expect(found).toEqual([{ a: { b: { c: { d: [2, 3, 4] } } } }]); + }); + + test("works with $oneOf, infinitely deep, using dot notation", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: { b: { c: { d: 1 } } } }, { a: { b: { c: { d: 2 } } } } + ]); + const found = nrml(collection.find({ $not: { "a.b.c.d": { $oneOf: [1, 2] } } })); + expect(found).toEqual([]); + + const found2 = nrml(collection.find({ $not: { "a.b.c.d": { $oneOf: [1, 3] } } })); + expect(found2).toEqual([{ a: { b: { c: { d: 2 } } } }]); + }); + + test("works with $oneOf, infinitely deep, not dot notation", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: { b: { c: { d: 1 } } } }, { a: { b: { c: { d: 2 } } } } + ]); + const found = nrml(collection.find({ $not: { a: { b: { c: { d: { $oneOf: [1, 2] } } } } } })); + expect(found).toEqual([]); + + const found2 = nrml(collection.find({ $not: { a: { b: { c: { d: { $oneOf: [1, 3] } } } } } })); + expect(found2).toEqual([{ a: { b: { c: { d: 2 } } } }]); + }); + + test("works with $length, infinitely deep, using dot notation", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: { b: { c: { d: [1, 2, 3] } } } }, { a: { b: { c: { d: [2, 3, 4, 5] } } } } + ]); + const found = nrml(collection.find({ $not: { "a.b.c.d": { $length: 3 } } })); + expect(found).toEqual([{ a: { b: { c: { d: [2, 3, 4, 5] } } } }]); + }); + + test("works with $hasAny, infinitely deep, using dot notation", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: { b: { c: { d: { foo: "foo", bar: "bar", baz: "baz" } } } } }, + { a: { b: { c: { d: { foo: "foo", bar: "bar" } } } } } + ]); + const found = nrml(collection.find({ $not: { "a.b.c.d": { $hasAny: ["foo", "bar"] } } })); + expect(found).toEqual([]); + + const found2 = nrml(collection.find({ $not: { "a.b.c.d": { $hasAny: ["baz"] } } })); + expect(found2).toEqual([{ a: { b: { c: { d: { foo: "foo", bar: "bar" } } } } }]); + }); + + test("works with $has, infinitely deep, using dot notation", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: { b: { c: { d: { foo: "foo", bar: "bar", baz: "baz" } } } } }, + { a: { b: { c: { d: { foo: "foo", bar: "bar" } } } } } + ]); + const found = nrml(collection.find({ $not: { "a.b.c.d": { $has: ["foo", "bar"] } } })); + expect(found).toEqual([]); + + const found2 = nrml(collection.find({ $not: { "a.b.c.d": { $has: ["baz"] } } })); + expect(found2).toEqual([{ a: { b: { c: { d: { foo: "foo", bar: "bar" } } } } }]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/boolean/oneOf.test.ts b/packages/arc-degit/tests/specs/operators/boolean/oneOf.test.ts new file mode 100644 index 0000000..70e8a9d --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/boolean/oneOf.test.ts @@ -0,0 +1,36 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$oneOf", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + const found = nrml(collection.find({ a: { $oneOf: [2, 3] } })); + expect(found.length).toEqual(2); + expect(found).toEqual([{ a: 2 }, { a: 3 }]); + }); + + test("works with dot notation", () => { + const collection = testCollection({ populate: false }); + collection.insert({ a: { b: 1 } }); + collection.insert({ a: { b: 2 } }); + collection.insert({ a: { b: 3 } }); + const found = nrml(collection.find({ "a.b": { $oneOf: [2, 3] } })); + expect(found.length).toEqual(2); + expect(found).toEqual([{ a: { b: 2 } }, { a: { b: 3 } }]); + }); + + test("works deeply without dot notation", () => { + const collection = testCollection({ populate: false }); + collection.insert({ a: { b: { c: 1 } } }); + collection.insert({ a: { b: { c: 2 } } }); + collection.insert({ a: { b: { c: 3 } } }); + const found = nrml(collection.find({ a: { b: { c: { $oneOf: [2, 3] } } } })); + expect(found.length).toEqual(2); + expect(found).toEqual([{ a: { b: { c: 2 } } }, { a: { b: { c: 3 } } }]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/boolean/or.test.ts b/packages/arc-degit/tests/specs/operators/boolean/or.test.ts new file mode 100644 index 0000000..6ef2f71 --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/boolean/or.test.ts @@ -0,0 +1,53 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$or", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert([ + { a: 1, b: 1, c: 1 }, + { a: 1, b: 1, c: 2 }, + { a: 1, b: 2, c: 3 }, + { a: 2, b: 2, c: 3 }, + ]); + const found = nrml(collection.find({ $or: [{ a: 1 }, { c: 2 }] })); + expect(found).toEqual([ + { a: 1, b: 1, c: 1 }, + { a: 1, b: 1, c: 2 }, + { a: 1, b: 2, c: 3 }, + ]); + }); + + test("nested operators", () => { + const collection = testCollection(); + collection.insert([ + { foo: "bar", num: 5 }, + { foo: "bee", num: 8 }, + { foo: "baz", num: 10 }, + { foo: "boo", num: 20 }, + ]); + const found = nrml(collection.find({ $or: [{ foo: { $includes: "ba" } }, { num: { $lt: 9 } }] })); + expect(found).toEqual([ + { foo: "bar", num: 5 }, + { foo: "bee", num: 8 }, + { foo: "baz", num: 10 }, + ]); + }); + + test("works with dot notation", () => { + const collection = testCollection(); + collection.insert([ + { a: { b: { c: 1, d: 1 } } }, + { a: { b: { c: 1, d: 2 } } }, + { a: { b: { c: 1, d: 3 } } }, + ]); + const found = nrml(collection.find({ $or: [{ "a.b.c": 1 }, { "a.b.d": 3 }] })); + expect(found).toEqual([ + { a: { b: { c: 1, d: 1 } } }, + { a: { b: { c: 1, d: 2 } } }, + { a: { b: { c: 1, d: 3 } } }, + ]); + }) + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/boolean/re.test.ts b/packages/arc-degit/tests/specs/operators/boolean/re.test.ts new file mode 100644 index 0000000..b717512 --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/boolean/re.test.ts @@ -0,0 +1,30 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$re", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert([ + { ip: "192.168.0.1" }, + { ip: "192.168.0.254" }, + { ip: "19216801" } + ]); + const ip = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/; + const found = nrml(collection.find({ ip: { $re: ip } })); + expect(found).toEqual([ { ip: "192.168.0.1" }, { ip: "192.168.0.254" } ]); + }); + + test("works with dot notation", () => { + const collection = testCollection(); + collection.insert([ + { ip: { a: "192.168.0.1" } }, + { ip: { a: "192.168.0.254" } }, + { ip: { a: "19216801" } } + ]); + const ip = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/; + const found = nrml(collection.find({ "ip.a": { $re: ip } })); + expect(found).toEqual([ { ip: { a: "192.168.0.1" } }, { ip: { a: "192.168.0.254" } } ]); + }) + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/boolean/xor.test.ts b/packages/arc-degit/tests/specs/operators/boolean/xor.test.ts new file mode 100644 index 0000000..0f2b428 --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/boolean/xor.test.ts @@ -0,0 +1,75 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$xor", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert([ + { a: 1, b: 1, c: 1 }, + { a: 1, b: 2, c: 2 }, // not included because a is 1 and b is 2, which matches the query exactly + { a: 2, b: 2, c: 3 }, + ]); + const found = nrml(collection.find({ $xor: [{ a: 1 }, { b: 2 }] })); + expect(found).toEqual([ + { a: 1, b: 1, c: 1 }, // <-- a was 1, but but was not 2 + { a: 2, b: 2, c: 3 }, // <-- a was not 1, but b was 2 + ]); + }); + + test("with nested operators", () => { + const collection = testCollection(); + collection.insert([ + { a: 1 }, + { b: 2 }, + { c: 3 }, + { a: 1, b: 2 }, // not included, because both properties exist in the query + { a: 1, c: 3 }, + { b: 2, c: 3 }, + ]); + const found = nrml(collection.find({ $xor: [{ $has: "a" }, { $has: "b" }] })); + expect(found).toEqual([ + { a: 1 }, // <-- only has "a" + { b: 2 }, // <-- only has "b" + { a: 1, c: 3 }, // <-- only has "a" + { b: 2, c: 3 }, // <-- only has "b" + ]); + }); + + test("nested operators", () => { + const collection = testCollection(); + collection.insert([ + { foo: "bar", num: 5 }, // not included, because properties both match the query + { foo: "bee", num: 8 }, + { foo: "baz", num: 10 }, + { foo: "boo", num: 20 }, // not included, because neither property matches the query + ]); + const found = nrml(collection.find({ $xor: [{ foo: { $includes: "ba" } }, { num: { $lt: 9 } }] })); + expect(found).toEqual([ + { foo: "bee", num: 8 }, // <-- foo does not include "ba", but num is less than 9 + { foo: "baz", num: 10 }, // <-- foo includes "ba", but num is not less than 9 + ]); + }); + + test("nested operators, dot notation, implicitly and explicitly deep", () => { + const collection = testCollection({ populate: false }); + collection.insert([ + { a: { b: { c: { foo: "bar", num: 5 } } } }, // not included, because properties both match the query + { a: { b: { c: { foo: "bee", num: 8 } } } }, + { a: { b: { c: { foo: "baz", num: 10 } } } }, + { a: { b: { c: { foo: "boo", num: 20 } } } }, // not included, because neither property matches the query + ]); + const found = nrml(collection.find({ $xor: [{ "a.b.c.foo": { $includes: "ba" } }, { num: { $lt: 9 } }] })); + expect(found).toEqual([ + { a: { b: { c: { foo: "bee", num: 8 } } } }, // <-- foo does not include "ba", but num is less than 9 + { a: { b: { c: { foo: "baz", num: 10 } } } }, // <-- foo includes "ba", but num is not less than 9 + ]); + }); + + test("throws when given anything other than 2 parameters", () => { + const collection = testCollection(); + expect(() => collection.find({ $xor: [{ a: 1 }, { b: 2 }, { c: 3 }] })).toThrow(); + expect(() => collection.find({ $xor: [{ a: 1 }] })).toThrow(); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/mutation/change.test.ts b/packages/arc-degit/tests/specs/operators/mutation/change.test.ts new file mode 100644 index 0000000..b5e9a5a --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/mutation/change.test.ts @@ -0,0 +1,57 @@ +import { expect, testSuite } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$change", ({ test }) => { + + test("works", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.update({ a: 2 }, { $change: { a: 3 } }); + const found = nrml(collection.find({ a: 3 })); + expect(found).toEqual([{ a: 3 }]); + }); + + test("works deeply", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update({ c: 5 }, { $change: { b: { c: 6 } } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: 6 } }]); + }); + + test("works deeply with dot notation", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update({ c: 5 }, { $change: { "b.c": 6 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: 6 } }]); + }); + + test("doesn't create new properties", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.update({ a: 1 }, { $change: { b: 1 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1 }]); + }); + + test("doesn't create new properties, deep", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.update({ a: 1 }, { $change: { b: { c: 1 } } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1 }]); + }); + + test("doesn't create new properties, deep, dot notation", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.update({ a: 1 }, { $change: { "b.c": 1 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1 }]); + }); + + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/mutation/filter.test.ts b/packages/arc-degit/tests/specs/operators/mutation/filter.test.ts new file mode 100644 index 0000000..476912f --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/mutation/filter.test.ts @@ -0,0 +1,26 @@ +import { expect, testSuite } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$filter", ({ test }) => { + test("works", () => { + const collection = testCollection(); + const filterfn = (doc: any) => doc.a === 1; + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + collection.update({ $has: "a" }, { $filter: filterfn }); + const found = nrml(collection.find({ $has: "a" })); + expect(found).toEqual([{ a: 1 }]); + }); + + test("works against a nested array", () => { + const collection = testCollection(); + collection.insert({ a: [1, 2, 3, 4, 5] }); + collection.update({ $has: "a" }, { $filter: { a: (doc: any) => doc > 3 } }); + const found = nrml(collection.find({ $has: "a" })); + expect(found).toEqual([{ a: [4, 5] }]); + }); + }); +}); + diff --git a/packages/arc-degit/tests/specs/operators/mutation/index.ts b/packages/arc-degit/tests/specs/operators/mutation/index.ts new file mode 100644 index 0000000..9bd2695 --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/mutation/index.ts @@ -0,0 +1,15 @@ +import {testSuite} from "manten"; + +export default testSuite(async ({ describe }) => { + describe("mutation", async ({ runTestSuite }) => { + runTestSuite(import("./set.test.js")); + runTestSuite(import("./unset.test.js")); + runTestSuite(import("./change.test.js")); + runTestSuite(import("./merge.test.js")); + runTestSuite(import("./math.test.js")); + runTestSuite(import("./map.test.js")); + runTestSuite(import("./push.test.js")); + runTestSuite(import("./unshift.test.js")); + runTestSuite(import("./filter.test.js")); + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/mutation/map.test.ts b/packages/arc-degit/tests/specs/operators/mutation/map.test.ts new file mode 100644 index 0000000..f52d882 --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/mutation/map.test.ts @@ -0,0 +1,25 @@ +import { expect, testSuite } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$map", ({ test }) => { + test("works", () => { + const collection = testCollection(); + const mapfn = (doc: any) => ({ ...doc, c: 5 }); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update({ a: 1 }, { $map: mapfn }); + const found = nrml(collection.find({ c: 5 })); + expect(found).toEqual([{ a: 1, b: { c: 5 }, c: 5 }]); + }); + + test("works with other operators", () => { + const collection = testCollection(); + const mapfn = (doc: any) => ({ ...doc, c: 5 }); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update({ a: 1 }, { $map: mapfn, $unset: "b" }); + const found = nrml(collection.find({ c: 5 })); + expect(found).toEqual([{ a: 1, c: 5 }]); + }); + }); +}); + diff --git a/packages/arc-degit/tests/specs/operators/mutation/math.test.ts b/packages/arc-degit/tests/specs/operators/mutation/math.test.ts new file mode 100644 index 0000000..b50988a --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/mutation/math.test.ts @@ -0,0 +1,270 @@ +import { expect, testSuite } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$inc", ({ test }) => { + + test("works", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update({ a: 1 }, { $inc: { a: 5 } }); + const found = nrml(collection.find({ c: 5 })); + expect(found).toEqual([{ a: 6, b: { c: 5 } }]); + }); + + test("arrays", () => { + const collection = testCollection(); + collection.insert({ a: { b: [1, 2, 3] } }); + collection.update({ a: { b: [1, 2, 3] } }, { $inc: 5 }); + const found = nrml(collection.find({ b: [6,7,8] })); + expect(found).toEqual([{ a: { b: [6, 7, 8] } }]); + }); + + test("works, syntax 2", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update({ a: 1 }, { $inc: 5 }); + const found = nrml(collection.find({ c: 5 })); + expect(found).toEqual([{ a: 6, b: { c: 5 } }]); + }); + + test("increments only the properties defined in query", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 2, c: 3 }); + collection.update({ a: 1, b: 2 }, { $inc: 5 }); + const found = nrml(collection.find({ a: 6 })); + expect(found).toEqual([{ a: 6, b: 7, c: 3 }]); + }); + + test("implcitly creates properties", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.update({ a: 1 }, { $inc: { b: 5 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: 5 }]); + }); + + test("syntax 2 increments properties specified in query", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 2, c: 3 }); + collection.update({ a: 1, b: 2, c: 3 }, { $inc: 5 }); + const found = nrml(collection.find({ a: 6, b: 7, c: 8 })); + expect(found).toEqual([{ a: 6, b: 7, c: 8 }]); + }); + + test("deep selector, shallow and deep increment", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: { d: 1, e: 1 } } }); + collection.update({ d: 1 }, { $inc: { f: 5, "b.c.d": 5 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: { d: 6, e: 1 } }, f: 5 }]); + }); + + test("deep selector, shallow increment, syntax 2", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: { d: 1, e: 1 } } }); + collection.update({ d: 1 }, { $inc: 5 }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: { d: 1, e: 1 } }, d: 5 }]); + }); + + test("deep selector, implicitly create shallow properties", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: { d: 1 } } }); + collection.update({ d: 1 }, { $inc: { e: 5 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: { d: 1 } }, e: 5 }]); + }); + + test("deep selector, implicitly create deep properties", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: { d: 1 } } }); + collection.update({ d: 1 }, { $inc: { "b.c.e": 5 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: { d: 1, e: 5 } } }]); + }); + + test("updates keys specified in query, even when using other mods", () => { + const collection = testCollection(); + collection.insert([ + { a: 1, b: { c: 2 }}, + { a: 1, b: { c: 2 }}, + { a: 1, b: { c: 2 }}, + ]); + collection.update({ b: { c: { $gt: 0 }}}, { $inc: 5 }); + const found = nrml(collection.find({ c: 7 })); + expect(found).toEqual([ + { a: 1, b: { c: 7 }}, + { a: 1, b: { c: 7 }}, + { a: 1, b: { c: 7 }}, + ]); + }); + + test("updates keys specified in query, even when using other mods - dot notation", () => { + const collection = testCollection(); + collection.insert([ + { a: 1, b: { c: 2 }}, + { a: 1, b: { c: 2 }}, + { a: 1, b: { c: 2 }}, + ]); + collection.update({ "b.c": { $gt: 0 } }, { $inc: 5 }); + const found = nrml(collection.find({ c: 7 })); + expect(found).toEqual([ + { a: 1, b: { c: 7 }}, + { a: 1, b: { c: 7 }}, + { a: 1, b: { c: 7 }}, + ]); + }); + + test("update keys specified in query when the query is an object", () => { + const collection = testCollection(); + collection.insert([ + { a: { b: { c: 1 } } }, + { a: { b: { c: 2 } } }, + ]); + collection.update({ a: { b: { c: 1 } } }, { $inc: 5 }); + const found = nrml(collection.find({ c: 6 })); + expect(found).toEqual([{ a: { b: { c: 6 } } }]); + }); + + test("update keys specified in query - dot notation", () => { + const collection = testCollection(); + collection.insert([ + { a: { b: { c: 1 } } }, + { a: { b: { c: 2 } } }, + ]); + collection.update({ "a.b.c": 1 }, { $inc: 5 }); + const found = nrml(collection.find({ c: 6 })); + expect(found).toEqual([{ a: { b: { c: 6 } } }]); + }); + + test("update keys specified in query, mix of object and dot notation", () => { + const collection = testCollection(); + collection.insert([ + { a: { b: { c: 1, d: 2 } } }, + { a: { b: { c: 2, d: 3 } } }, + ]); + collection.update({ a: { b: { c: 1 } }, "a.b.d": 2 }, { $inc: 5 }); + const found = nrml(collection.find({ c: 6 })); + expect(found).toEqual([{ a: { b: { c: 6, d: 7 } } }]); + }) + }); + + describe("$dec", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update({ a: 1 }, { $dec: { a: 5 } }); + const found = nrml(collection.find({ c: 5 })); + expect(found).toEqual([{ a: -4, b: { c: 5 } }]); + }); + + test("works, syntax 2", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update({ a: 1 }, { $dec: 5 }); + const found = nrml(collection.find({ c: 5 })); + expect(found).toEqual([{ a: -4, b: { c: 5 } }]); + }); + + test("implicitly creates properties", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.update({ a: 1 }, { $dec: { b: 5 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: -5 }]); + }); + }); + + describe("$mult", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert({ a: 5, b: { c: 5 } }); + collection.update({ a: 5 }, { $mult: { a: 5 } }); + const found = nrml(collection.find({ c: 5 })); + expect(found).toEqual([{ a: 25, b: { c: 5 } }]); + }); + + test("works, syntax 2", () => { + const collection = testCollection(); + collection.insert({ a: 5, b: { c: 5 } }); + collection.update({ a: 5 }, { $mult: 5 }); + const found = nrml(collection.find({ c: 5 })); + expect(found).toEqual([{ a: 25, b: { c: 5 } }]); + }); + + test("implicitly creates properties", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.update({ a: 1 }, { $mult: { b: 5 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: 5 }]); + }); + + test("deep selector, implicitly creates properties", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: { d: 1 } } }); + collection.update({ d: 1 }, { $inc: { e: 5 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: { d: 1 } }, e: 5 }]); + }); + }); + + describe("special behavior with $has and $hasAny", ({ test }) => { + + test("$has single property", () => { + const collection = testCollection(); + collection.insert([{ a: { b: 1, c: 1 } }, { a: 1 }]); + collection.update({ a: { $has: "b" }}, { $inc: 5 }); + const found = nrml(collection.find({ b: 6, c: 1 })); + expect(found).toEqual([{ a: { b: 6, c: 1 } }]); + }); + + test("$has array", () => { + const collection = testCollection(); + collection.insert([{ a: { b: 1, c: 1 } }, { a: 1 }]); + collection.update({ a: { $has: ["b", "c"] }}, { $inc: 5 }); + const found = nrml(collection.find({ b: 6, c: 6 })); + expect(found).toEqual([{ a: { b: 6, c: 6 } }]); + }); + + test("$hasAny, with one property missing", () => { + const collection = testCollection(); + collection.insert([{ a: { b: 1 } }, { a: 1 }]); + collection.update({ a: { $hasAny: ["b", "c"] }}, { $inc: 5 }); + const found = nrml(collection.find({ b: 6 })); + expect(found).toEqual([{ a: { b: 6 } }]); + }); + + test("$hasAny, updates all specified properties", () => { + const collection = testCollection(); + collection.insert([{ a: { b: 1, c: 1 } }, { a: 1 }]); + collection.update({ a: { $hasAny: ["b", "c"] }}, { $inc: 5 }); + const found = nrml(collection.find({ b: 6, c: 6 })); + expect(found).toEqual([{ a: { b: 6, c: 6 } }]); + }); + + test("$hasAny single property, dot notation", () => { + const collection = testCollection(); + collection.insert([{ a: { b: { c: 1 } } }, { a: 1 }]); + collection.update({ "a.b": { $hasAny: "c" }}, { $inc: 5 }); + const found = nrml(collection.find({ c: 6 })); + expect(found).toEqual([{ a: { b: { c: 6 } } }]); + }); + + test("$has real world test", () => { + const collection = testCollection(); + collection.insert([ + { planet: { name: "Earth", population: 1 } }, + { planet: { name: "Mars" }}, + ]); + collection.update({ planet: { name: { $includes: "a" }, $has: "population" } }, { $inc: { "planet.population": 1 } }); + const found = nrml(collection.find({ name: { $includes: "a" }})); + expect(found).toEqual([ + { planet: { name: "Earth", population: 2 }}, + { planet: { name: "Mars" }}, + ]); + }); + + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/mutation/merge.test.ts b/packages/arc-degit/tests/specs/operators/mutation/merge.test.ts new file mode 100644 index 0000000..99dcc5b --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/mutation/merge.test.ts @@ -0,0 +1,41 @@ +import { expect, testSuite } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$merge", ({ test }) => { + test("shallow selector, deep-root update", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update({ a: 1 }, { $merge: { b: { d: 6 } } }); + const found = nrml(collection.find({ d: 6 })); + expect(found).toEqual([{ a: 1, b: { c: 5, d: 6 } }]); + }); + + test("deep-root selector, deep-root update", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update({ a: 1, b: { c: 5 } }, { $merge: { b: { d: 6 } } }); + const found = nrml(collection.find({ d: 6 })); + expect(found).toEqual([{ a: 1, b: { c: 5, d: 6 } }]); + }); + + test("overwrites existing properties", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update( + { a: 1 }, + { $merge: { a: 2, b: { d: 6 } } } + ); + const found = nrml(collection.find({ d: 6 })); + expect(found).toEqual([{ a: 2, b: { c: 5, d: 6 } }]); + }); + + test("deep selector merges deeply", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update({ c: 5 }, { $merge: { a: 2 } }); + const found = nrml(collection.find({ a: 2 })); + expect(found).toEqual([{ a: 1, b: { c: 5, a: 2 } }]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/mutation/push.test.ts b/packages/arc-degit/tests/specs/operators/mutation/push.test.ts new file mode 100644 index 0000000..f37e055 --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/mutation/push.test.ts @@ -0,0 +1,73 @@ +import { expect, testSuite } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$push", ({ test }) => { + + test("works", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: [1] }); + collection.update({ a: 1 }, { $push: { b: 2 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: [1, 2] }]); + }); + + test("push more than one value", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: [1] }); + collection.update({ a: 1 }, { $push: { b: [2, 3] } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: [1, 2, 3] }]); + }); + + test("push with dot notation", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 1, d: [1, 2]} }); + collection.update({ c: 1 }, { $push: { "b.d": [3, 4] } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: 1, d: [1, 2, 3, 4] } }]); + }); + + test("push with dot notation, multiple pushes", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 1, d: [1, 2]}, e: { c: 1, d: [1, 2] } }); + collection.update({ c: 1 }, { $push: { "b.d": [3, 4], "e.d": [3, 4] } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: 1, d: [1, 2, 3, 4] }, e: { c: 1, d: [1, 2, 3, 4] } }]); + }); + + test("push an object to an array of objects", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: [{ name: "a" }] }); + collection.update({ a: 1 }, { $push: { b: { name: "b" } } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: [{ name: "a" }, { name: "b" }] }]); + }); + + test("push with dot notation, an object to an array of objects", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 1, d: [{ name: "a" }] } }); + collection.update({ c: 1 }, { $push: { "b.d": { name: "b" } } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: 1, d: [{ name: "a" }, { name: "b" }] } }]); + }); + + test("push does not create the target array if it doesn't exist", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.update({ a: 1 }, { $push: { b: 1 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1 }]); + }); + + test("push with dot notation does not create the target array if it does not exist", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.update({ a: 1 }, { $push: { "b.c": 1 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1 }]); + }); + + }); +}); + diff --git a/packages/arc-degit/tests/specs/operators/mutation/set.test.ts b/packages/arc-degit/tests/specs/operators/mutation/set.test.ts new file mode 100644 index 0000000..8bddb16 --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/mutation/set.test.ts @@ -0,0 +1,58 @@ +import { expect, testSuite } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$set", ({ test }) => { + + test("works", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.update({ a: 2 }, { $set: { b: 3 } }); + const found = nrml(collection.find({ a: 2 })); + expect(found).toEqual([{ a: 2, b: 3 }]); + }); + + test("deep selector doesn't implicitly update a deep property", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update({ c: 5 }, { $set: { d: 6 } }); + const found = nrml(collection.find({ d: 6 })); + expect(found).toEqual([{ a: 1, b: { c: 5 }, d: 6 }]); + }); + + test("will create deep objects", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.update({ a: 1 }, { $set: { b: { c: 5 } } }); + const found = nrml(collection.find({ b: { c: 5 } })); + expect(found).toEqual([{ a: 1, b: { c: 5 } }]); + }); + + test("does not merge objects, instead overwrites", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 5 } }); + collection.update({ a: 1 }, { $set: { b: { d: 6 } } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { d: 6 } }]); + }); + + test("shorthand behavior", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 1 } }); + collection.insert({ a: 2, b: { c: 2 } }); + collection.update({ b: { c: 2 }}, { $set: 11 }); + const found = nrml(collection.find({ a: 2 })); + expect(found).toEqual([{ a: 2, b: { c: 11 } }]); + }); + + test("works with dot notation", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 1 } }); + collection.update({ "b.c": 1 }, { $set: { "b.c": 2 } }); + const found = nrml(collection.find({ "b.c": 2 })); + expect(found).toEqual([{ a: 1, b: { c: 2 } }]); + }); + + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/mutation/unset.test.ts b/packages/arc-degit/tests/specs/operators/mutation/unset.test.ts new file mode 100644 index 0000000..13f7c3b --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/mutation/unset.test.ts @@ -0,0 +1,46 @@ +import { expect, testSuite } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$unset", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 2, c: 3 }); + collection.update({ a: 1 }, { $unset: "c" }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: 2 }]); + }); + + test("dot notation", () => { + const collection = testCollection(); + collection.insert({ a: { b: { c: 1, d: 2, e: 3 } } }); + collection.update({ e: 3 }, { $unset: "a.b.c" }); + const found = nrml(collection.find({ e: 3 })); + expect(found).toEqual([{ a: { b: { d: 2, e: 3 } } }]); + }); + + test("dot notation array", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 1, d: 2 }, e: 3 }); + collection.update({ a: 1 }, { $unset: ["e", "b.c"] }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { d: 2 } }]); + }); + + test("dot notation nested query", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 1, d: 2 }, e: 3 }); + collection.update({ b: { d: 2 } }, { $unset: "b.d" }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: 1 }, e: 3 }]); + }); + + test("dot notation, remove all items from array", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: [{ c: 1, d: 1 }, { c: 2, d: 2 }] }); + collection.update({ a: 1 }, { $unset: "b.*.c" }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: [{ d: 1 }, { d: 2 }] }]); + }) + }); +}); diff --git a/packages/arc-degit/tests/specs/operators/mutation/unshift.test.ts b/packages/arc-degit/tests/specs/operators/mutation/unshift.test.ts new file mode 100644 index 0000000..4ef0d27 --- /dev/null +++ b/packages/arc-degit/tests/specs/operators/mutation/unshift.test.ts @@ -0,0 +1,73 @@ +import { expect, testSuite } from "manten"; +import { nrml, testCollection } from "../../../common"; + +export default testSuite(async ({ describe }) => { + describe("$unshift", ({ test }) => { + + test("works", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: [1] }); + collection.update({ a: 1 }, { $unshift: { b: 2 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: [2, 1] }]); + }); + + test("unshift more than one value", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: [1] }); + collection.update({ a: 1 }, { $unshift: { b: [2, 3] } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: [2, 3, 1] }]); + }); + + test("unshift with dot notation", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 1, d: [1, 2]} }); + collection.update({ c: 1 }, { $unshift: { "b.d": [3, 4] } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: 1, d: [3, 4, 1, 2] } }]); + }); + + test("unshift with dot notation, multiple unshifts", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 1, d: [1, 2]}, e: { c: 1, d: [1, 2] } }); + collection.update({ c: 1 }, { $unshift: { "b.d": [3, 4], "e.d": [3, 4] } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: 1, d: [3, 4, 1, 2] }, e: { c: 1, d: [3, 4, 1, 2] } }]); + }); + + test("unshift an object to an array of objects", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: [{ name: "a" }] }); + collection.update({ a: 1 }, { $unshift: { b: { name: "b" } } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: [{ name: "b" }, { name: "a" }] }]); + }); + + test("unshift with dot notation, an object to an array of objects", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: { c: 1, d: [{ name: "a" }] } }); + collection.update({ c: 1 }, { $unshift: { "b.d": { name: "b" } } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1, b: { c: 1, d: [{ name: "b" }, { name: "a" }] } }]); + }); + + test("unshift does not create the target array if it doesn't exist", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.update({ a: 1 }, { $unshift: { b: 1 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1 }]); + }); + + test("unshift with dot notation does not create the target array if it does not exist", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.update({ a: 1 }, { $unshift: { "b.c": 1 } }); + const found = nrml(collection.find({ a: 1 })); + expect(found).toEqual([{ a: 1 }]); + }); + + }); +}); + diff --git a/packages/arc-degit/tests/specs/options/ifEmpty.test.ts b/packages/arc-degit/tests/specs/options/ifEmpty.test.ts new file mode 100644 index 0000000..fd89e1b --- /dev/null +++ b/packages/arc-degit/tests/specs/options/ifEmpty.test.ts @@ -0,0 +1,48 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("ifEmpty", ({ test }) => { + + test("adds missing properties", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1, d: " " }); + collection.insert({ a: 2, b: 2, c: 2, d: [] }); + collection.insert({ a: 3, b: 3, c: 3, d: { e: "test" } }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { ifEmpty: { d: 5 } })); + expect(found).toEqual([ + { a: 1, b: 1, c: 1, d: 5 }, + { a: 2, b: 2, c: 2, d: 5 }, + { a: 3, b: 3, c: 3, d: { e: "test" } }, + ]); + }); + + test("adds missing properties using dot notation", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1, d: { e: " " } }); + collection.insert({ a: 2, b: 2, c: 2, d: { e: [] } }); + collection.insert({ a: 3, b: 3, c: 3, d: { e: "test" } }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { ifEmpty: { "d.e": 5 } })); + expect(found).toEqual([ + { a: 1, b: 1, c: 1, d: { e: 5 } }, + { a: 2, b: 2, c: 2, d: { e: 5 } }, + { a: 3, b: 3, c: 3, d: { e: "test" } }, + ]); + }); + + test("does not create new properties", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1, d: " " }); + collection.insert({ a: 2, b: 2, c: 2, d: [] }); + collection.insert({ a: 3, b: 3, c: 3, d: { e: "test" } }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { ifEmpty: { e: 5 } })); + expect(found).toEqual([ + { a: 1, b: 1, c: 1, d: " " }, + { a: 2, b: 2, c: 2, d: [] }, + { a: 3, b: 3, c: 3, d: { e: "test" } }, + ]); + }); + + }); +}); + diff --git a/packages/arc-degit/tests/specs/options/ifNull.test.ts b/packages/arc-degit/tests/specs/options/ifNull.test.ts new file mode 100644 index 0000000..823269f --- /dev/null +++ b/packages/arc-degit/tests/specs/options/ifNull.test.ts @@ -0,0 +1,70 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("ifNull", ({ test }) => { + test("adds missing properties", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2, d: null }); + collection.insert({ a: 3, b: 3, c: 3, d: "test" }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { ifNull: { d: 5 } })); + expect(found).toEqual([ + { a: 1, b: 1, c: 1, d: 5 }, + { a: 2, b: 2, c: 2, d: 5 }, + { a: 3, b: 3, c: 3, d: "test" }, + ]); + }); + + test("adds missing properties using dot notation", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + collection.insert({ a: 3, b: 3, c: 3, d: { e: "test" } }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { ifNull: { "d.e": 5 } })); + expect(found).toEqual([ + { a: 1, b: 1, c: 1, d: { e: 5 } }, + { a: 2, b: 2, c: 2, d: { e: 5 } }, + { a: 3, b: 3, c: 3, d: { e: "test" } }, + ]); + }); + + test("doesn't overwrite properties", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + collection.insert({ a: 3, b: 3, c: 3, d: { e: "test" } }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { ifNull: { c: 5 } })); + expect(found).toEqual([ + { a: 1, b: 1, c: 1 }, + { a: 2, b: 2, c: 2 }, + { a: 3, b: 3, c: 3, d: { e: "test" } }, + ]); + }); + + test("works when the new value is a complex object", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { ifNull: { d: { e: 5 } } })); + expect(found).toEqual([{ a: 1, b: 1, c: 1, d: { e: 5 } }, { a: 2, b: 2, c: 2, d: { e: 5 } }]); + }); + + test("works when the new value is a null value", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { ifNull: { d: null } })); + expect(found).toEqual([{ a: 1, b: 1, c: 1, d: null }, { a: 2, b: 2, c: 2, d: null }]); + }); + + test("ifnull receives a function which is passed the document", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { ifNull: { d: (doc) => doc.a } })); + expect(found).toEqual([{ a: 1, b: 1, c: 1, d: 1 }, { a: 2, b: 2, c: 2, d: 2 }]); + }); + + }); +}); diff --git a/packages/arc-degit/tests/specs/options/ifNullOrEmpty.test.ts b/packages/arc-degit/tests/specs/options/ifNullOrEmpty.test.ts new file mode 100644 index 0000000..5e28e12 --- /dev/null +++ b/packages/arc-degit/tests/specs/options/ifNullOrEmpty.test.ts @@ -0,0 +1,35 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("ifNullOrEmpty", ({ test }) => { + + test("adds missing properties", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2, d: [] }); + collection.insert({ a: 3, b: 3, c: 3, d: "test" }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { ifNullOrEmpty: { d: 5 } })); + expect(found).toEqual([ + { a: 1, b: 1, c: 1, d: 5 }, + { a: 2, b: 2, c: 2, d: 5 }, + { a: 3, b: 3, c: 3, d: "test" } + ]); + }); + + test("adds missing properties using dot notation", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2, d: { e: [] } }); + collection.insert({ a: 3, b: 3, c: 3, d: { e: "test" } }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { ifNullOrEmpty: { "d.e": 5 } })); + expect(found).toEqual([ + { a: 1, b: 1, c: 1, d: { e: 5 } }, + { a: 2, b: 2, c: 2, d: { e: 5 } }, + { a: 3, b: 3, c: 3, d: { e: "test" } } + ]); + }); + + }); +}); + diff --git a/packages/arc-degit/tests/specs/options/index.ts b/packages/arc-degit/tests/specs/options/index.ts new file mode 100644 index 0000000..10132d5 --- /dev/null +++ b/packages/arc-degit/tests/specs/options/index.ts @@ -0,0 +1,14 @@ +import { testSuite } from "manten"; + +export default testSuite(async ({ describe }) => { + describe("query options", async ({ runTestSuite }) => { + runTestSuite(import("./sort.test.js")); + runTestSuite(import("./integerIds.test.js")); + runTestSuite(import("./project.test.js")); + runTestSuite(import("./skip_take.test.js")); + runTestSuite(import("./join.test.js")); + runTestSuite(import("./ifNull.test.js")); + runTestSuite(import("./ifEmpty.test.js")); + runTestSuite(import("./ifNullOrEmpty.test.js")); + }); +}); diff --git a/packages/arc-degit/tests/specs/options/integerIds.test.ts b/packages/arc-degit/tests/specs/options/integerIds.test.ts new file mode 100644 index 0000000..133c110 --- /dev/null +++ b/packages/arc-degit/tests/specs/options/integerIds.test.ts @@ -0,0 +1,21 @@ +import { testSuite, expect } from "manten"; +import { ID_KEY } from "../../../src/collection"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("integerIds", ({ test }) => { + test("works", () => { + const collection = testCollection({ integerIds: true }); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + const found = nrml(collection.find({ a: { $lt: 5 } }), { keepIds: true }); + // these start at 3 because testCollection adds 3 documents. + expect(found).toEqual([ + { a: 1, [ID_KEY]: 3 }, + { a: 2, [ID_KEY]: 4 }, + { a: 3, [ID_KEY]: 5 }, + ]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/options/join.test.ts b/packages/arc-degit/tests/specs/options/join.test.ts new file mode 100644 index 0000000..33cba3a --- /dev/null +++ b/packages/arc-degit/tests/specs/options/join.test.ts @@ -0,0 +1,318 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("join", ({ test }) => { + test("works", () => { + const users = testCollection(); + const tickets = testCollection({ name: "tickets", integerIds: true, timestamps: false }); + + users.insert({ name: "Jonathan", tickets: [3, 4] }); + tickets.insert({ title: "Ticket 0", description: "Ticket 0 description" }); + tickets.insert({ title: "Ticket 1", description: "Ticket 1 description" }); + tickets.insert({ title: "Ticket 2", description: "Ticket 2 description" }); + + const res = nrml(users.find({ name: "Jonathan" }, { + join: [{ + collection: tickets, + from: "tickets", + on: "_id", + as: "userTickets", + options: { + project: { _id: 0 }, + }, + }], + }))[0]; + + expect(res).toEqual({ + name: "Jonathan", + tickets: [3, 4], + userTickets: [ + { title: "Ticket 0", description: "Ticket 0 description" }, + { title: "Ticket 1", description: "Ticket 1 description" }, + ], + }); + }); + + test("will overwrite original property", () => { + const users = testCollection(); + const tickets = testCollection({ name: "tickets", integerIds: true, timestamps: false }); + + users.insert({ name: "Jonathan", tickets: [3, 4] }); + tickets.insert({ title: "Ticket 0", description: "Ticket 0 description" }); + tickets.insert({ title: "Ticket 1", description: "Ticket 1 description" }); + tickets.insert({ title: "Ticket 2", description: "Ticket 2 description" }); + + const res = nrml(users.find({ name: "Jonathan" }, { + join: [{ + collection: tickets, + from: "tickets", + on: "_id", + as: "tickets", + options: { + project: { _id: 0 }, + } + }], + }))[0]; + + const tks = tickets.find({ _id: { $oneOf: [3, 4] } }); + + expect(res).toEqual({ + name: "Jonathan", + tickets: [ + { title: "Ticket 0", description: "Ticket 0 description" }, + { title: "Ticket 1", description: "Ticket 1 description" }, + ], + }); + }); + + test("creates the 'as' property even when nothing matches", () => { + const users = testCollection(); + const tickets = testCollection({ name: "tickets" }); + + users.insert({ name: "Jonathan", tickets: [] }); + + const res = nrml(users.find({ name: "Jonathan" }, { + join: [{ + collection: tickets, + from: "tickets", + on: "_id", + as: "userTickets", + }], + }))[0]; + + expect(res).toHaveProperty("userTickets"); + expect((res as any).userTickets).toEqual([]); + }); + + test("creates the 'as' property even when nothing matches, dot notation", () => { + const users = testCollection(); + const tickets = testCollection({ name: "tickets" }); + + users.insert({ name: "Jonathan", tickets: [] }); + + const res = nrml(users.find({ name: "Jonathan" }, { + join: [{ + collection: tickets, + from: "tickets", + on: "_id", + as: "user.tickets", + }], + }))[0]; + + expect(res).toHaveProperty("user.tickets"); + expect((res as any).user.tickets).toEqual([]); + }); + + test("respects QueryOptions", () => { + const users = testCollection(); + const tickets = testCollection({ name: "tickets", integerIds: true }); + + users.insert({ name: "Jonathan", tickets: [3, 4] }); + tickets.insert({ title: "Ticket 0", description: "Ticket 0 description" }); + tickets.insert({ title: "Ticket 1", description: "Ticket 1 description" }); + tickets.insert({ title: "Ticket 2", description: "Ticket 2 description" }); + + const res = nrml(users.find({ name: "Jonathan" }, { + join: [{ + collection: tickets, + from: "tickets", + on: "_id", + as: "userTickets", + options: { project: { title: 1 } }, + }], + }))[0]; + + expect(res).toEqual({ + name: "Jonathan", + tickets: [3, 4], + userTickets: [{ title: "Ticket 0" }, { title: "Ticket 1" }], + }); + }); + + test("multiple joins", () => { + const users = testCollection(); + const skills = testCollection({ name: "skills", integerIds: true }); + const items = testCollection({ name: "items", integerIds: true }); + + users.insert({ name: "Jonathan", skills: [3, 4], items: [4, 5] }); + + skills.insert({ title: "Skill 0" }); + skills.insert({ title: "Skill 1" }); + skills.insert({ title: "Skill 2" }); + + items.insert({ title: "Item 0" }); + items.insert({ title: "Item 1" }); + items.insert({ title: "Item 2" }); + + const res = nrml( + users.find( + { name: "Jonathan" }, + { + join: [ + { + collection: skills, + from: "skills", + on: "_id", + as: "userSkills", + }, + { + collection: items, + from: "items", + on: "_id", + as: "userItems", + }, + ], + } + ) + )[0]; + + const sks = skills.find({ _id: { $oneOf: [3, 4] } }); + const its = items.find({ _id: { $oneOf: [4, 5] } }); + + expect(res).toEqual({ + name: "Jonathan", + skills: [3, 4], + items: [4, 5], + userSkills: [...sks], + userItems: [...its], + }); + }); + + test("nested joins", () => { + const users = testCollection({ timestamps: false }); + const tickets = testCollection({ name: "tickets", integerIds: true, timestamps: false }); + const seats = testCollection({ name: "seats", integerIds: true, timestamps: false }); + + users.insert({ name: "Jonathan", tickets: [3, 4] }); + tickets.insert({ title: "Ticket 0", seat: 3 }); + tickets.insert({ title: "Ticket 1", seat: 5 }); + tickets.insert({ title: "Ticket 2" }); + seats.insert({ seat: "S3" }); + seats.insert({ seat: "S4" }); + seats.insert({ seat: "S5" }); + + const res = nrml(users.find({ name: "Jonathan" }, { + join: [{ + collection: tickets, + from: "tickets", + on: "_id", + as: "userTickets", + options: { + project: { _id: 0 }, + join: [{ + collection: seats, + from: "seat", + on: "_id", + as: "ticketSeats", + options: { + project: { _id: 0 }, + } + }] + }, + }], + project: { _id: 0 }, + }))[0]; + + expect(res).toEqual({ + name: "Jonathan", + tickets: [3, 4], + userTickets: [ + { + title: "Ticket 0", + seat: 3, + ticketSeats: [{ seat: "S3" }], + }, + { + title: "Ticket 1", + seat: 5, + ticketSeats: [{ seat: "S5" }], + }, + ] + }); + }); + + test("with join.from and join.as dot notation, accessing array index on join.as", () => { + const inventory = testCollection(); + const items = testCollection({ name: "items", integerIds: true }); + + inventory.insert({ + name: "Jonathan", + items: [ + { itemId: 3, quantity: 1 }, + { itemId: 5, quantity: 2 }, + ], + }); + + items.insert({ name: "The Unstoppable Force", atk: 100 }); // id 3 + items.insert({ name: "Sneakers", agi: 100 }); // id 4 + items.insert({ name: "The Immovable Object", def: 100 }); // id 5 + + const res = nrml(inventory.find({ name: "Jonathan" }, { + join: [{ + collection: items, + from: "items.*.itemId", + on: "_id", + as: "items.*.itemData", + options: { + project: { _id: 0, _created_at: 0, _updated_at: 0 }, + } + }], + }))[0]; + + expect(res).toEqual({ + name: "Jonathan", + items: [ + { itemId: 3, quantity: 1, itemData: { name: "The Unstoppable Force", atk: 100 } }, + { itemId: 5, quantity: 2, itemData: { name: "The Immovable Object", def: 100 } }, + ], + }) + }); + + test("with join.from and join.as dot notation, no array '*' on join.as", () => { + const inventory = testCollection(); + const items = testCollection({ name: "items", integerIds: true }); + + inventory.insert({ + name: "Jonathan", + items: [ + { itemId: 3, quantity: 1 }, + { itemId: 5, quantity: 2 }, + ], + meta: { + data: [], + } + }); + + items.insert({ name: "The Unstoppable Force", atk: 100 }); // id 3 + items.insert({ name: "Sneakers", agi: 100 }); // id 4 + items.insert({ name: "The Immovable Object", def: 100 }); // id 5 + + const res = nrml(inventory.find({ name: "Jonathan" }, { + join: [{ + collection: items, + from: "items.*.itemId", + on: "_id", + as: "meta.data", + options: { + project: { _id: 0, _created_at: 0, _updated_at: 0 }, + } + }], + }))[0]; + + expect(res).toEqual({ + name: "Jonathan", + items: [ + { itemId: 3, quantity: 1 }, + { itemId: 5, quantity: 2 }, + ], + meta: { + data: [ + { name: "The Unstoppable Force", atk: 100 }, + { name: "The Immovable Object", def: 100 } + ], + }, + }); + }) + }); +}); diff --git a/packages/arc-degit/tests/specs/options/project.test.ts b/packages/arc-degit/tests/specs/options/project.test.ts new file mode 100644 index 0000000..f38b0ea --- /dev/null +++ b/packages/arc-degit/tests/specs/options/project.test.ts @@ -0,0 +1,221 @@ +import { testSuite, expect } from "manten"; +import { CREATED_AT_KEY, ID_KEY, UPDATED_AT_KEY } from "../../../src/collection"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("project", ({ test }) => { + test("implicit exclusion", () => { + const collection = testCollection({ timestamps: false }); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + collection.insert({ a: 3, b: 3, c: 3 }); + const found = collection.find({ a: 1 }, { project: { b: 1 } }); + expect(found).toEqual([{ b: 1 }]); + }); + + test("implicit inclusion", () => { + const collection = testCollection({ timestamps: false }); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + collection.insert({ a: 3, b: 3, c: 3 }); + const found = collection.find({ a: 1 }, { project: { b: 0 } }); + const id = found[0][ID_KEY]; + expect(id).toBeDefined(); + expect(found).toEqual([{ _id: id, a: 1, c: 1 }]); + }); + + test("implicit inclusion - _id implicitly included", () => { + const collection = testCollection({ timestamps: false }); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + collection.insert({ a: 3, b: 3, c: 3 }); + const foundWithId = collection.find({ a: 1 }, { project: { b: 0 } }); + const id = foundWithId[0][ID_KEY]; + expect(id).toBeDefined(); + expect(foundWithId).toEqual([{ _id: id, a: 1, c: 1 }]); + }); + + test("explicit", () => { + const collection = testCollection({ timestamps: false }); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + collection.insert({ a: 3, b: 3, c: 3 }); + const found = nrml(collection.find({ a: 1 }, { project: { b: 1, c: 0 } })); + expect(found).toEqual([{ a: 1, b: 1 }]); + }); + + test("explicit - ID_KEY implicitly included", () => { + const collection = testCollection({ timestamps: false }); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + collection.insert({ a: 3, b: 3, c: 3 }); + const foundWithId = collection.find( + { a: 1 }, + { + project: { + b: 1, + c: 0, + _created_at: 0, + _updated_at: 0, + }, + } + ); + const id = foundWithId[0][ID_KEY]; + expect(id).toBeDefined(); + expect(foundWithId).toEqual([{ _id: id, a: 1, b: 1 }]); + }); + + test("empty query respects projection", () => { + const collection = testCollection({ timestamps: false }); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + + const found = collection.find({}, { project: { b: 1 } }); + + for (const doc of found) { + expect(doc[ID_KEY]).toBeUndefined(); + expect(doc[CREATED_AT_KEY]).toBeUndefined(); + expect(doc[UPDATED_AT_KEY]).toBeUndefined(); + } + }); + + describe("aggregation", ({ test }) => { + test("$floor, $ceil, $sub, $add, $mult, $div", () => { + const collection = testCollection({ timestamps: false }); + collection.insert({ a: 1, b: 1, c: 5.6 }); + collection.insert({ a: 2, b: 2, c: 2 }); + collection.insert({ a: 3, b: 3, c: 3 }); + + const found = collection.find( + { a: 1 }, + { + aggregate: { + flooredC: { $floor: "c" }, + ceiledC: { $ceil: "c" }, + subbed1: { $sub: ["c", "a"] }, + subbed2: { $sub: [15, "flooredC", 0, 1] }, + mult1: { $mult: ["c", 2, "subbed2"] }, + div1: { $div: ["subbed2", 2, "a", 2] }, + add1: { $add: ["c", 2, "subbed2"] }, + }, + project: { + b: 0, + _created_at: 0, + _updated_at: 0, + _id: 0, + }, + } + ); + + expect(found).toEqual([{ + a: 1, + c: 5.6, + flooredC: 5, + ceiledC: 6, + subbed1: 4.6, + subbed2: 9, + mult1: 100.8, + div1: 2.25, + add1: 16.6, + }]); + }); + + test("more realistic use-case", () => { + const collection = testCollection({ timestamps: false }); + collection.insert({ math: 72, english: 82, science: 92 }); + collection.insert({ math: 60, english: 70, science: 80 }); + collection.insert({ math: 90, english: 72, science: 84 }); + + const found = nrml(collection.find( + { $has: ["math", "english", "science"] }, + { + aggregate: { + total: { $add: ["math", "english", "science"] }, + average: { $div: ["total", 3] }, + }, + } + )); + + expect(found).toEqual([ + { math: 72, english: 82, science: 92, total: 246, average: 82 }, + { math: 60, english: 70, science: 80, total: 210, average: 70 }, + { math: 90, english: 72, science: 84, total: 246, average: 82 }, + ]); + }); + + test("remove intermediate aggregation properties with projection", () => { + const collection = testCollection(); + collection.insert({ math: 72, english: 82, science: 92 }); + collection.insert({ math: 60, english: 70, science: 80 }); + collection.insert({ math: 90, english: 72, science: 84 }); + + const found = nrml(collection.find( + { $has: ["math", "english", "science"] }, + { + aggregate: { + total: { $add: ["math", "english", "science"] }, // <-- projected out + average: { $div: ["total", 3] }, + }, + project: { + math: 1, + english: 1, + science: 1, + average: 1, + }, + } + )); + + expect(found).toEqual([ + { math: 72, english: 82, science: 92, average: 82 }, + { math: 60, english: 70, science: 80, average: 70 }, + { math: 90, english: 72, science: 84, average: 82 }, + ]); + }); + + test("accessing properties with dot notation", () => { + const collection = testCollection(); + collection.insert({ a: { b: { c: 1 } } }); + collection.insert({ a: { b: { c: 2 } } }); + collection.insert({ a: { b: { c: 3 } } }); + + const found = collection.find( + { a: { b: { c: 1 } } }, + { + aggregate: { + d: { $add: ["a.b.c", 1] }, + }, + project: { + a: 0, + _created_at: 0, + _updated_at: 0, + _id: 0, + }, + } + ); + + expect(found).toEqual([{ d: 2 }]); + }); + + test("$fn", () => { + const collection = testCollection(); + collection.insert({ first: "John", last: "Doe" }); + collection.insert({ first: "Jane", last: "Doe" }); + + const found = nrml(collection.find( + { $has: ["first", "last"] }, + { + aggregate: { + fullName: { $fn: (doc) => `${doc.first} ${doc.last}` }, + }, + } + )); + + expect(found).toEqual([ + { first: "John", last: "Doe", fullName: "John Doe" }, + { first: "Jane", last: "Doe", fullName: "Jane Doe" }, + ]); + }); + }); + + }); +}); diff --git a/packages/arc-degit/tests/specs/options/skip_take.test.ts b/packages/arc-degit/tests/specs/options/skip_take.test.ts new file mode 100644 index 0000000..762a179 --- /dev/null +++ b/packages/arc-degit/tests/specs/options/skip_take.test.ts @@ -0,0 +1,37 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("skip", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + collection.insert({ a: 3, b: 3, c: 3 }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { skip: 1 })); + expect(found).toEqual([{ a: 2, b: 2, c: 2 }, { a: 3, b: 3, c: 3 }]); + }); + }); + + describe("take", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + collection.insert({ a: 3, b: 3, c: 3 }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { take: 1 })); + expect(found).toEqual([{ a: 1, b: 1, c: 1 }]); + }); + }); + + describe("skip take", ({ test }) => { + test("works", () => { + const collection = testCollection(); + collection.insert({ a: 1, b: 1, c: 1 }); + collection.insert({ a: 2, b: 2, c: 2 }); + collection.insert({ a: 3, b: 3, c: 3 }); + const found = nrml(collection.find({ a: { $gt: 0 } }, { skip: 1, take: 1 })); + expect(found).toEqual([{ a: 2, b: 2, c: 2 }]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/options/sort.test.ts b/packages/arc-degit/tests/specs/options/sort.test.ts new file mode 100644 index 0000000..9b104fc --- /dev/null +++ b/packages/arc-degit/tests/specs/options/sort.test.ts @@ -0,0 +1,57 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("sort", ({ test }) => { + test("ascending", () => { + const collection = testCollection(); + collection.insert({ a: 2 }); + collection.insert({ a: 1 }); + collection.insert({ a: 3 }); + const found = nrml(collection.find({ a: { $lt: 5 } }, { sort: { a: 1 } })); + expect(found).toEqual([{ a: 1 }, { a: 2 }, { a: 3 }]); + }); + test("ascending update results", () => { + const collection = testCollection(); + collection.insert({ a: 2 }); + collection.insert({ a: 1 }); + collection.insert({ a: 3 }); + const found = nrml(collection.update({ a: { $lt: 10 } }, { $inc: 5 }, { sort: { a: 1 } })); + expect(found).toEqual([{ a: 6 }, { a: 7 }, { a: 8 }]); + }); + test("descending with -1", () => { + const collection = testCollection(); + collection.insert({ a: 2 }); + collection.insert({ a: 1 }); + collection.insert({ a: 3 }); + const found = nrml(collection.find({ a: { $lt: 5 } }, { sort: { a: -1 } })); + expect(found).toEqual([{ a: 3 }, { a: 2 }, { a: 1 }]); + }); + test("descending with 0", () => { + const collection = testCollection(); + collection.insert({ a: 2 }); + collection.insert({ a: 1 }); + collection.insert({ a: 3 }); + const found = nrml(collection.find({ a: { $lt: 5 } }, { sort: { a: 0 } })); + expect(found).toEqual([{ a: 3 }, { a: 2 }, { a: 1 }]); + }); + test("more than one property, asc and desc, numeric and alphanumeric", () => { + const collection = testCollection(); + collection.insert({ name: "Deanna Troi", age: 28 }); + collection.insert({ name: "Worf", age: 24 }); + collection.insert({ name: "Xorf", age: 24 }); + collection.insert({ name: "Zorf", age: 24 }); + collection.insert({ name: "Jean-Luc Picard", age: 59 }); + collection.insert({ name: "William Riker", age: 29 }); + const found = nrml(collection.find({ age: { $gt: 1 } }, { sort: { age: 1, name: -1 } })); + expect(found).toEqual([ + { name: "Zorf", age: 24 }, + { name: "Xorf", age: 24 }, + { name: "Worf", age: 24 }, + { name: "Deanna Troi", age: 28 }, + { name: "William Riker", age: 29 }, + { name: "Jean-Luc Picard", age: 59 }, + ]); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/remove/basic.test.ts b/packages/arc-degit/tests/specs/remove/basic.test.ts new file mode 100644 index 0000000..3b48a40 --- /dev/null +++ b/packages/arc-degit/tests/specs/remove/basic.test.ts @@ -0,0 +1,24 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("remove", ({ test }) => { + test("it works", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + const removed = nrml(collection.remove({ a: 2 })); + const found = nrml(collection.find({ a: { $lt: 5 } })); + expect(removed).toEqual([{ a: 2 }]); + expect(found).toEqual([{ a: 1 }, { a: 3 }]); + }); + test("normalizes internal id_map", () => { + const collection = testCollection({ integerIds: true }); + collection.insert({ a: 1 }); + expect(collection.data["internal"]["id_map"][3]).toBeDefined(); + collection.remove({ a: 1 }); + expect(collection.data["internal"]["id_map"][3]).toBeUndefined(); + }); + }); +}); diff --git a/packages/arc-degit/tests/specs/remove/index.ts b/packages/arc-degit/tests/specs/remove/index.ts new file mode 100644 index 0000000..fbfc3b7 --- /dev/null +++ b/packages/arc-degit/tests/specs/remove/index.ts @@ -0,0 +1,7 @@ +import { testSuite } from "manten"; + +export default testSuite(async ({ describe }) => { + describe("removing", async ({ runTestSuite }) => { + runTestSuite(import("./basic.test.js")); + }); +}); diff --git a/packages/arc-degit/tests/specs/sharded_collection/basic.test.ts b/packages/arc-degit/tests/specs/sharded_collection/basic.test.ts new file mode 100644 index 0000000..d51b7b3 --- /dev/null +++ b/packages/arc-degit/tests/specs/sharded_collection/basic.test.ts @@ -0,0 +1,30 @@ +import { testSuite, expect } from "manten"; +import { getShardedCollection } from "../../common"; + +export default testSuite(async ({ describe }) => { + describe("sharding", ({ test }) => { + + test("works", () => { + const c = getShardedCollection(); + + const docs = []; + + for (let i = 0; i < 250; i++) { + docs.push({ key: i }); + } + + c.insert(docs); + + expect(Object.keys(c.shards).length).toEqual(3); + expect(Object.keys(c.shards).every((shardId) => Object.keys(c.shards[shardId].data).length === 85)); + + const found = c.find({ key: 1 }); + expect(found.length).toEqual(1); + + c.drop(); + c.sync(); + }); + + }); + +}); diff --git a/packages/arc-degit/tests/specs/sharded_collection/index.ts b/packages/arc-degit/tests/specs/sharded_collection/index.ts new file mode 100644 index 0000000..944b646 --- /dev/null +++ b/packages/arc-degit/tests/specs/sharded_collection/index.ts @@ -0,0 +1,7 @@ +import { testSuite } from "manten"; + +export default testSuite(async ({ describe }) => { + describe("sharded collection", async ({ runTestSuite }) => { + runTestSuite(import("./basic.test.js")); + }); +}); diff --git a/packages/arc-degit/tests/specs/transactions/basic.test.ts b/packages/arc-degit/tests/specs/transactions/basic.test.ts new file mode 100644 index 0000000..da52ab3 --- /dev/null +++ b/packages/arc-degit/tests/specs/transactions/basic.test.ts @@ -0,0 +1,180 @@ +import { testSuite, expect } from "manten"; +import { Transaction } from "../../../src/transaction"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ describe, test }) => { + describe("inserts", ({ test }) => { + test("will insert and remove on rollback", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + const original = collection.find(); + + collection.transaction((t) => { + t.insert({ a: 4 }); + const found = nrml(collection.find({ a: 4 })); + expect(found).toEqual([{ a: 4 }]); + t.rollback(); + const found2 = nrml(collection.find({ a: 4 })); + expect(found2).toEqual([]); + }); + + const latest = collection.find(); + expect(latest).toEqual(original); + }); + + test("will insert many and remove all on rollback", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + const original = collection.find(); + + collection.transaction((t) => { + t.insert([{ a: 4 }, { a: 5 }]); + const found = nrml(collection.find({ a: { $gt: 3 } })); + expect(found).toEqual([{ a: 4 }, { a: 5 }]); + + t.rollback(); + + const found2 = nrml(collection.find({ a: { $gt: 3 } })); + expect(found2).toEqual([]); + }); + + const latest = collection.find(); + expect(latest).toEqual(original); + }); + }); + + describe("updates", ({ test }) => { + test("will update and revert on rollback", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + const original = collection.find(); + + collection.transaction((t) => { + t.update({ a: 1 }, { $set: { a: 4 } }); + + const found = nrml(collection.find({ a: 4 })); + expect(found).toEqual([{ a: 4 }]); + + t.rollback(); + + const found2 = nrml(collection.find({ a: 4 })); + expect(found2).toEqual([]); + + const found3 = nrml(collection.find({ a: 1 })); + expect(found3).toEqual([{ a: 1 }]); + }); + + const latest = collection.find(); + expect(latest).toEqual(original); + }); + + test("will update many and revert all on rollback", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + const original = collection.find(); + + collection.transaction((t) => { + t.update({ a: { $gt: 1 } }, { $inc: 5 }); + + const found = nrml(collection.find({ a: { $gt: 1 } })); + expect(found).toEqual([{ a: 7 }, { a: 8 }]); + + t.rollback(); + + const found2 = nrml(collection.find({ a: { $gt: 3 } })); + expect(found2).toEqual([]); + + const found3 = nrml(collection.find({ a: { $gt: 0 } })); + expect(found3).toEqual([{ a: 1 }, { a: 2 }, { a: 3 }]); + }); + + const latest = collection.find(); + expect(latest).toEqual(original); + }); + }); + + describe("removes", ({ test }) => { + test("will remove and restore on rollback", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + const original = collection.find(); + + collection.transaction((t) => { + t.remove({ a: 3 }); + + const found = nrml(collection.find({ a: 3 })); + expect(found).toEqual([]); + + t.rollback(); + + const found2 = nrml(collection.find({ a: 3 })); + expect(found2).toEqual([{ a: 3 }]); + }); + + const latest = collection.find(); + expect(latest).toEqual(original); + }); + + test("will remove many and restore all on rollback", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + const original = collection.find(); + + collection.transaction((t) => { + t.remove({ a: { $gt: 1 } }); + + const found = nrml(collection.find({ a: { $gt: 1 } })); + expect(found).toEqual([]); + + t.rollback(); + + const found2 = nrml(collection.find({ a: { $gt: 1 } })); + expect(found2).toEqual([{ a: 2 }, { a: 3 }]); + }); + + const latest = collection.find(); + expect(latest).toEqual(original); + }); + }); + + test("throws if already in a transaction", () => { + const collection = testCollection(); + + collection.transaction((tx) => { + expect(collection.transaction).toThrow(); + }); + }); + + test("throwing inside a transaction rolls it back", () => { + const collection = testCollection(); + collection.insert({ a: 1 }); + collection.insert({ a: 2 }); + collection.insert({ a: 3 }); + const original = collection.find(); + + try { + collection.transaction((t) => { + t.insert({ a: 4 }); + throw new Error("test"); + }); + } catch (e) { + // ignore + } + + const latest = collection.find(); + expect(latest).toEqual(original); + }); + +}); diff --git a/packages/arc-degit/tests/specs/transactions/index.ts b/packages/arc-degit/tests/specs/transactions/index.ts new file mode 100644 index 0000000..2ba0220 --- /dev/null +++ b/packages/arc-degit/tests/specs/transactions/index.ts @@ -0,0 +1,5 @@ +import { testSuite } from "manten"; + +export default testSuite(async ({ runTestSuite }) => { + runTestSuite(import("./basic.test.js")); +}); diff --git a/packages/arc-degit/tests/specs/upsert/index.ts b/packages/arc-degit/tests/specs/upsert/index.ts new file mode 100644 index 0000000..1dcdf2a --- /dev/null +++ b/packages/arc-degit/tests/specs/upsert/index.ts @@ -0,0 +1,5 @@ +import { testSuite } from "manten"; + +export default testSuite(async ({ runTestSuite }) => { + runTestSuite(import("./upsert.test.js")); +}); diff --git a/packages/arc-degit/tests/specs/upsert/upsert.test.ts b/packages/arc-degit/tests/specs/upsert/upsert.test.ts new file mode 100644 index 0000000..343547e --- /dev/null +++ b/packages/arc-degit/tests/specs/upsert/upsert.test.ts @@ -0,0 +1,44 @@ +import { testSuite, expect } from "manten"; +import { nrml, testCollection } from "../../common"; + +export default testSuite(async ({ test, describe }) => { + test("works", () => { + const collection = testCollection(); + collection.upsert({ name: "Jean-Luc" }, { $set: { title: "Captain" } }); + const found = nrml(collection.find({ name: "Jean-Luc" })); + expect(found).toEqual([{ name: "Jean-Luc", title: "Captain" }]); + }); + + test("works - pre-existing document", () => { + const collection = testCollection(); + collection.insert({ animal: "dog" }); + collection.insert({ animal: "cat" }); + collection.upsert({ animal: "dog" }, { $set: { name: "scooby" } }); + const found = nrml(collection.find({ animal: "dog" })); + expect(found).toEqual([{ animal: "dog", name: "scooby" }]); + }); + + describe("strip boolean modifiers before insertion", ({ test }) => { + test("ex 1", () => { + const collection = testCollection(); + // the idea is that we don't want the created document to be { name: "Jean-Luc", age: { $gt: 40 }, title: "Captain" }, + // but rather { name: "Jean-Luc", title: "Captain" } + collection.upsert({ name: "Jean-Luc", age: { $gt: 40 } }, { $set: { title: "Captain" } }); + const found = nrml(collection.find({ name: "Jean-Luc" })); + expect(found).toEqual([{ name: "Jean-Luc", title: "Captain" }]); + }); + test("ex 2", () => { + const collection = testCollection(); + collection.upsert({ name: "Jean-Luc", age: { asdf: 1, $gt: 40 } }, { $set: { title: "Captain" } }); + const found = nrml(collection.find({ name: "Jean-Luc" })); + expect(found).toEqual([{ name: "Jean-Luc", age: { asdf: 1 }, title: "Captain" }]); + }); + test("ex 3", () => { + const collection = testCollection(); + collection.upsert({ name: "Jean-Luc", age: { $gt: 40, foo: { $lt: 40, bar: "baz" } }, title: "Captain" }, { $set: { title: "Captain" } }); + const found = nrml(collection.find({ name: "Jean-Luc" })); + expect(found).toEqual([{ name: "Jean-Luc", title: "Captain", age: { foo: { bar: "baz" } } }]); + }); + }); + +}); diff --git a/packages/arc-degit/tests/specs/utils/index.ts b/packages/arc-degit/tests/specs/utils/index.ts new file mode 100644 index 0000000..60851a1 --- /dev/null +++ b/packages/arc-degit/tests/specs/utils/index.ts @@ -0,0 +1,6 @@ +import { testSuite } from "manten"; + +export default testSuite(async ({ runTestSuite }) => { + runTestSuite(import("./stripBooleanModifiers.test.js")); +}); + diff --git a/packages/arc-degit/tests/specs/utils/stripBooleanModifiers.test.ts b/packages/arc-degit/tests/specs/utils/stripBooleanModifiers.test.ts new file mode 100644 index 0000000..98a454b --- /dev/null +++ b/packages/arc-degit/tests/specs/utils/stripBooleanModifiers.test.ts @@ -0,0 +1,38 @@ +import { testSuite, expect } from "manten"; +import { stripBooleanModifiers } from "../../../src/collection"; + +export default testSuite(async ({ test }) => { + test("should strip boolean modifiers", () => { + const query = { name: "A", title: { $oneOf: ["Captain", "Commander"] } }; + const stripped = stripBooleanModifiers(query); + expect(stripped).toEqual({ name: "A" }); + }); + + test("should strip multiple boolean modifers", () => { + const query = { + name: "A", + title: { + $oneOf: ["Captain", "Commander"], + $not: { $has: "Lieutenant" }, + }, + age: { $gt: 30 }, + }; + const stripped = stripBooleanModifiers(query); + expect(stripped).toEqual({ name: "A" }); + }); + + test("should strip boolean modifiers, preserving other keys", () => { + const query = { + name: "A", + title: { + $oneOf: ["Captain", "Commander"], + $not: { $has: "Lieutenant" }, + thing: "C", + }, + age: { $gt: 30 }, + other: "B", + }; + const stripped = stripBooleanModifiers(query); + expect(stripped).toEqual({ name: "A", title: { thing: "C" }, other: "B" }); + }); +}); diff --git a/packages/arc-degit/tsconfig.json b/packages/arc-degit/tsconfig.json new file mode 100644 index 0000000..136f3a7 --- /dev/null +++ b/packages/arc-degit/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "es2022", + "target": "esnext", + "outDir": "lib", + "esModuleInterop": true, + "moduleResolution": "node", + "declaration": true, + "declarationDir": "lib" + } +} diff --git a/packages/arc-degit/tsup.config.ts b/packages/arc-degit/tsup.config.ts new file mode 100644 index 0000000..cabe3b6 --- /dev/null +++ b/packages/arc-degit/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, + minify: true, + sourcemap: "inline", + target: "esnext", +});