├── .codeclimate.yml ├── .codesandbox └── ci.json ├── .github └── workflows │ └── default.yml ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── Cache │ ├── Cache.test.ts │ ├── Cache.ts │ ├── CacheMissingPrimaryColumnValueError.ts │ ├── cacheDelete.ts │ ├── cacheRead.ts │ ├── cacheStabilize.ts │ ├── cacheSync.ts │ ├── cacheWrite.ts │ └── optimizeInstructions.ts ├── Column │ ├── Column.test.ts │ ├── Column.ts │ ├── IndexableColumn.test.ts │ ├── IndexableColumn.ts │ ├── PrimaryColumn.test.ts │ ├── PrimaryColumn.ts │ ├── UniqueColumn.test.ts │ ├── UniqueColumn.ts │ ├── columnGet.ts │ ├── columnMetadata.ts │ └── columnSet.ts ├── Datastore │ ├── Datastore.ts │ └── SearchStrategyError.ts ├── Entity │ ├── Entity.test.ts │ ├── Entity.ts │ └── entityMetadata.ts ├── Instruction │ ├── DeleteInstruction.test.ts │ ├── DeleteInstruction.ts │ ├── Instruction.ts │ ├── WriteInstruction.test.ts │ └── WriteInstruction.ts ├── MemoryDatastore │ ├── MemoryDatastore.test.ts │ └── MemoryDatastore.ts ├── Relationship │ ├── ToMany.test.ts │ ├── ToMany.ts │ ├── ToOne.test.ts │ ├── ToOne.ts │ ├── addTo.ts │ ├── relationshipMetadata.ts │ ├── removeFrom.ts │ ├── toManyChild.testhelpers.ts │ ├── toManyGet.ts │ ├── toManyParent.testhelpers.ts │ ├── toManySet.ts │ ├── toOneChild.testhelpers.ts │ ├── toOneGet.ts │ ├── toOneParent.testhelpers.ts │ └── toOneSet.ts ├── Repository │ ├── ColumnNotFindableError.ts │ ├── ColumnNotSearchableError.ts │ ├── EntityNotFoundError.ts │ ├── Repository.test.ts │ ├── Repository.ts │ ├── RepositoryFindError.ts │ ├── RepositoryLoadError.ts │ ├── repositoryDelete.ts │ ├── repositoryFind.ts │ ├── repositoryLoad.ts │ ├── repositorySave.ts │ └── repositorySearch.ts ├── __tests__ │ └── library │ │ ├── datastores │ │ └── libraryDatastore.testhelpers.ts │ │ ├── fixtures │ │ └── williamShakespeare.testhelpers.ts │ │ ├── index.test.ts │ │ └── models │ │ └── Author.testhelpers.ts ├── index.test.ts ├── index.ts ├── metadata.ts ├── types │ ├── columnType.test.ts │ ├── columnType.ts │ ├── toManyType.ts │ └── toOneType.ts └── utils │ ├── cache.ts │ ├── columns.test.ts │ ├── columns.ts │ ├── datastore.test.ts │ ├── datastore.ts │ ├── entities.ts │ ├── errors.ts │ ├── hydrate.ts │ ├── keyGeneration.test.ts │ ├── keyGeneration.ts │ ├── metadata.ts │ ├── relationships.test.ts │ └── relationships.ts └── tsconfig.json /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | plugins: 3 | eslint: 4 | enabled: true 5 | fixme: 6 | enabled: true 7 | git-legal: 8 | enabled: true 9 | markdownlint: 10 | enabled: true 11 | checks: 12 | MD033: 13 | enabled: false 14 | MD013: 15 | enabled: false 16 | nodesecurity: 17 | enabled: true 18 | exclude_patterns: 19 | - "**/*.testhelpers.ts" 20 | - "**/*.test.ts" 21 | - "*.js" 22 | -------------------------------------------------------------------------------- /.codesandbox/ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "sandboxes": ["github/gregbrimble/kv-orm-cf-workers-example"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/default.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push] 3 | 4 | jobs: 5 | build: 6 | name: Test 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v2 11 | - name: Setup Node 12 | uses: actions/setup-node@v1 13 | with: 14 | fetch-depth: 1 15 | - name: Install 16 | run: npm install 17 | - name: Lint 18 | run: npm run lint 19 | - name: Test 20 | run: npm run test:coverage 21 | - name: Upload Coverage Report 22 | uses: codecov/codecov-action@v1.0.6 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/git,node,macos,visualstudiocode 3 | # Edit at https://www.gitignore.io/?templates=git,node,macos,visualstudiocode 4 | 5 | ### Git ### 6 | # Created by git for backups. To disable backups in Git: 7 | # $ git config --global mergetool.keepBackup false 8 | *.orig 9 | 10 | # Created by git when using merge tools for conflicts 11 | *.BACKUP.* 12 | *.BASE.* 13 | *.LOCAL.* 14 | *.REMOTE.* 15 | *_BACKUP_*.txt 16 | *_BASE_*.txt 17 | *_LOCAL_*.txt 18 | *_REMOTE_*.txt 19 | 20 | ### macOS ### 21 | # General 22 | .DS_Store 23 | .AppleDouble 24 | .LSOverride 25 | 26 | # Icon must end with two \r 27 | Icon 28 | 29 | # Thumbnails 30 | ._* 31 | 32 | # Files that might appear in the root of a volume 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | 41 | # Directories potentially created on remote AFP share 42 | .AppleDB 43 | .AppleDesktop 44 | Network Trash Folder 45 | Temporary Items 46 | .apdisk 47 | 48 | ### Node ### 49 | # Logs 50 | logs 51 | *.log 52 | npm-debug.log* 53 | yarn-debug.log* 54 | yarn-error.log* 55 | lerna-debug.log* 56 | 57 | # Diagnostic reports (https://nodejs.org/api/report.html) 58 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 59 | 60 | # Runtime data 61 | pids 62 | *.pid 63 | *.seed 64 | *.pid.lock 65 | 66 | # Directory for instrumented libs generated by jscoverage/JSCover 67 | lib-cov 68 | 69 | # Coverage directory used by tools like istanbul 70 | coverage 71 | *.lcov 72 | 73 | # nyc test coverage 74 | .nyc_output 75 | 76 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 77 | .grunt 78 | 79 | # Bower dependency directory (https://bower.io/) 80 | bower_components 81 | 82 | # node-waf configuration 83 | .lock-wscript 84 | 85 | # Compiled binary addons (https://nodejs.org/api/addons.html) 86 | build/Release 87 | 88 | # Dependency directories 89 | node_modules/ 90 | jspm_packages/ 91 | 92 | # TypeScript v1 declaration files 93 | typings/ 94 | 95 | # TypeScript cache 96 | *.tsbuildinfo 97 | 98 | # Optional npm cache directory 99 | .npm 100 | 101 | # Optional eslint cache 102 | .eslintcache 103 | 104 | # Optional REPL history 105 | .node_repl_history 106 | 107 | # Output of 'npm pack' 108 | *.tgz 109 | 110 | # Yarn Integrity file 111 | .yarn-integrity 112 | 113 | # dotenv environment variables file 114 | .env 115 | .env.test 116 | 117 | # parcel-bundler cache (https://parceljs.org/) 118 | .cache 119 | 120 | # next.js build output 121 | .next 122 | 123 | # nuxt.js build output 124 | .nuxt 125 | 126 | # vuepress build output 127 | .vuepress/dist 128 | 129 | # Serverless directories 130 | .serverless/ 131 | 132 | # FuseBox cache 133 | .fusebox/ 134 | 135 | # DynamoDB Local files 136 | .dynamodb/ 137 | 138 | ### VisualStudioCode ### 139 | .vscode/* 140 | !.vscode/settings.json 141 | !.vscode/tasks.json 142 | !.vscode/launch.json 143 | !.vscode/extensions.json 144 | 145 | ### VisualStudioCode Patch ### 146 | # Ignore all local history of files 147 | .history 148 | 149 | # End of https://www.gitignore.io/api/git,node,macos,visualstudiocode 150 | 151 | dist/ 152 | core 153 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v13.13.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Greg Brimble 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Welcome to @kv-orm/core 👋

2 |

3 | 4 | GitHub Actions Checks 5 | 6 | 7 | LGTM Alerts 8 | 9 | 10 | Synk Vulnerabilities 11 | 12 | 13 | Codecov 14 | 15 | 16 | LGTM Code Quality 17 | 18 | 19 | Code Climate Maintainability 20 | 21 | 22 | Version 23 | 24 | 25 | License 26 | 27 | 28 | Types 29 | 30 | 31 | GitHub Last Commit 32 | 33 |

34 | 35 | [kv-orm] is an Node.JS [ORM](https://en.wikipedia.org/wiki/Object-relational_mapping) for [key-value datastores](https://en.wikipedia.org/wiki/Key-value_database). **It is currently in beta**. 36 | 37 | ## Author 38 | 39 | 👤 **Greg Brimble** 40 | 41 | - Github: [@GregBrimble](https://github.com/GregBrimble) 42 | - Personal Website: [https://gregbrimble.com/](https://gregbrimble.com/) 43 | 44 | ## 🤝 Contributing 45 | 46 | Contributions, issues and feature requests are welcome! Feel free to check [issues page](https://github.com/kv-orm/core/issues). 47 | 48 | ## 😍 Show your support 49 | 50 | Please consider giving this project a ⭐️ if you use it, or if it provides some inspiration! 51 | 52 | # Supported Datastores 53 | 54 | - In-Memory 55 | - [Cloudflare Workers KV](https://github.com/kv-orm/cf-workers) 56 | 57 | If there is any other datastore that you'd like to see supported, please [create an issue](https://github.com/kv-orm/core/issues/new), or [make a pull request](https://github.com/kv-orm/core/fork). 58 | 59 | # Features 60 | 61 | - Support for multiple key-value datastores in a single application. 62 | 63 | ```typescript 64 | import { MemoryDatastore } from "@kv-orm/core"; 65 | 66 | const libraryDatastore = new MemoryDatastore(); 67 | const applicationSecrets = new MemoryDatastore(); 68 | ``` 69 | 70 | See above for the full list of [Supported Datastores](#Supported%20Datastores). 71 | 72 | - Easy construction of typed Entities using [Typescript](https://www.typescriptlang.org/). 73 | 74 | ```typescript 75 | import { Column, Entity } from "@kv-orm/core"; 76 | 77 | @Entity({ datastore: libraryDatastore }) 78 | class Author { 79 | @Column() 80 | public firstName: string; 81 | 82 | @Column() 83 | public lastName: string; 84 | 85 | // ... 86 | } 87 | ``` 88 | 89 | - On-demand, asynchronous, lazy-loading: [kv-orm] won't load properties of an Entity until they're needed, and will do so seamlessly at the time of lookup. 90 | 91 | ```typescript 92 | import { getRepository } from "@kv-orm/core"; 93 | 94 | const authorRepository = getRepository(Author); 95 | 96 | let author = await authorRepository.load("william@shakespeare.com"); // 1ms - no properties of the author have been loaded 97 | 98 | console.log(await author.firstName); // 60ms - author.firstName is fetched 99 | ``` 100 | 101 | - No unnecessary reads: if a property is already in memory, [kv-orm] won't look it up again unless it needs to. 102 | 103 | ```typescript 104 | let author = await authorRepository.load("william@shakespeare.com"); 105 | 106 | console.log(await author.lastName); // 60ms - author.lastName is fetched 107 | console.log(await author.lastName); // 1ms - author.lastName is retrieved from memory (no lookup performed) 108 | ``` 109 | 110 | - Indexable and Unique Columns allowing quick lookups for specific entity instances: 111 | 112 | ```typescript 113 | import { UniqueColumn, IndexableColumn } from "@kv-orm/core"; 114 | 115 | @Entity({ datastore: libraryDatastore }) 116 | class Author { 117 | // ... 118 | 119 | @IndexableColumn() 120 | public birthYear: number; 121 | 122 | @UniqueColumn() 123 | public phoneNumber: string; 124 | // ... 125 | } 126 | 127 | let authors = await authorRepository.search("birthYear", 1564); // An AsyncGenerator yielding authors born in 1564 128 | let author = await authorRepository.find("phoneNumber", "+1234567890"); // A single author with the phone number +1234567890 129 | ``` 130 | 131 | - Relationships (\*-to-one & \*-to-many) with backref support, and on-update & on-delete cascading. 132 | 133 | ```typescript 134 | import { ToOne, ToMany } from "@kv-orm/core"; 135 | 136 | @Entity({ datastore: libraryDatastore }) 137 | class Author { 138 | // ... 139 | 140 | @ToOne({ type: Book, backRef: "author", cascade: true }) 141 | public books: Book[]; 142 | 143 | // ... 144 | } 145 | 146 | @Entity({ datastore: libraryDatastore }) 147 | class Book { 148 | @ToMany({ type: Author, backRef: "books", cascade: true }) 149 | public author: Author; 150 | 151 | // ... 152 | } 153 | ``` 154 | 155 | # Usage 156 | 157 | ## Install 158 | 159 | ```sh 160 | npm install --save @kv-orm/core 161 | ``` 162 | 163 | ## Datastores 164 | 165 | ### `MemoryDatastore` 166 | 167 | `MemoryDatastore` is inbuilt into `@kv-orm/core`. It is a simple in-memory key-value datastore, and can be used for prototyping applications. 168 | 169 | ```typescript 170 | import { MemoryDatastore } from `@kv-orm/core`; 171 | 172 | const libraryDatastore = new MemoryDatastore(); 173 | ``` 174 | 175 | ### Cloudflare Workers KV 176 | 177 | See [`@kv-orm/cf-workers`](https://github.com/kv-orm/cf-workers) for more information. 178 | 179 | ## Entities 180 | 181 | An Entity is an object which stores data about something e.g. an Author. The Entity decorator takes a datastore to save the Entity instances into. 182 | 183 | Optionally, you can also pass in a `key` to the decorator, to rename the key name in the datastore. 184 | 185 | You can initialize a new instance of the Entity as normal. 186 | 187 | ```typescript 188 | import { Entity } from "@kv-orm/core"; 189 | 190 | @Entity({ datastore: libraryDatastore, key: "Author" }) 191 | class Author { 192 | // ... 193 | } 194 | 195 | const authorInstance = new Author(); 196 | ``` 197 | 198 | ## Columns 199 | 200 | Using the `@Column()` decorator on an Entity property is how you mark it as a savable property. You must `await` their value. This is because it might need to asynchronously query the datastore, if it doesn't have the value in memory. 201 | 202 | Like with Entities, you can optionally pass in a `key` to the decorator. 203 | 204 | ```typescript 205 | import { 206 | Column, 207 | PrimaryColumn, 208 | UniqueColumn, 209 | IndexableColumn, 210 | } from "@kv-orm/core"; 211 | import { Book } from "./Book"; 212 | 213 | @Entity({ datastore: libraryDatastore }) 214 | class Author { 215 | @Column({ key: "givenName" }) 216 | public firstName: string; 217 | 218 | @Column() 219 | public lastName: string; 220 | 221 | @Column() 222 | public nickName: string | undefined; 223 | 224 | @PrimaryColumn() 225 | public emailAddress: string; 226 | 227 | @IndexableColumn() 228 | public birthYear: number; 229 | 230 | @UniqueColumn() 231 | public phoneNumber: string; 232 | 233 | public someUnsavedProperty: any; 234 | 235 | @ToMany({ type: () => Book, backRef: "author", cascade: true }) // More on this later 236 | public books: Book[] = []; 237 | 238 | public constructor({ 239 | firstName, 240 | lastName, 241 | emailAddress, 242 | birthYear, 243 | phoneNumber, 244 | }: { 245 | firstName: string; 246 | lastName: string; 247 | emailAddress: string; 248 | birthYear: number; 249 | phoneNumber: string; 250 | }) { 251 | this.firstName = firstName; 252 | this.lastName = lastName; 253 | this.emailAddress = emailAddress; 254 | this.birthYear = birthYear; 255 | this.phoneNumber = phoneNumber; 256 | } 257 | } 258 | 259 | const williamShakespeare = new Author({ 260 | firstName: "William", 261 | lastName: "Shakespeare", 262 | emailAddress: "william@shakespeare.com", 263 | birthYear: 1564, 264 | phoneNumber: "+1234567890", 265 | }); 266 | williamShakespeare.nickName = "Bill"; 267 | williamShakespeare.someUnsavedProperty = "Won't get saved!"; 268 | 269 | // When in an async function, you can fetch the value with `await` 270 | (async () => { 271 | console.log(await author.firstName); 272 | })(); 273 | ``` 274 | 275 | ### Primary Columns 276 | 277 | Any non-singleton class needs a PrimaryColumn used to differentiate Entity instances. For this reason, **PrimaryColumn values are required and must be unique**. 278 | 279 | ```typescript 280 | @Entity({ datastore: libraryDatastore }) 281 | class Author { 282 | // ... 283 | 284 | @PrimaryColumn() 285 | public emailAddress: string; 286 | 287 | // ... 288 | } 289 | ``` 290 | 291 | An example of a singleton class where you do not need a PrimaryColumn, might be a global configuration Entity where you store application secrets (e.g. API keys). 292 | 293 | ### Indexable Columns 294 | 295 | An IndexableColumn can be used to mark a property as one which you may wish to later lookup with. For example, in SQL, you might perform the following query: `SELECT * FROM Author WHERE birthYear = 1564`. In [kv-orm], you can lookup Entity instances with a given IndexableColumn value with a repository's [search](#Search) method 296 | 297 | ```typescript 298 | @Entity({ datastore: libraryDatastore }) 299 | class Author { 300 | // ... 301 | 302 | @IndexableColumn() 303 | public birthYear: number; 304 | 305 | // ... 306 | } 307 | ``` 308 | 309 | IndexableColumn types should be used to store non-unique values. 310 | 311 | ### Unique Columns 312 | 313 | Columns with unique values can be setup with UniqueColumn. This is more efficient that an IndexableColumn, and the [loading mechanism](#Find) is simpler. 314 | 315 | ```typescript 316 | @Entity({ datastore: libraryDatastore }) 317 | class Author { 318 | // ... 319 | 320 | @UniqueColumn() 321 | public phoneNumber: number; 322 | 323 | // ... 324 | } 325 | ``` 326 | 327 | ### Property Getters/Setters 328 | 329 | If your property is particularly complex (i.e. can't be stored natively in the datastore), you may wish to use a property getter/setter for a Column, to allow you to serialize it before saving in the datastore. 330 | 331 | For example, let's say you have a complex property on `Author`, `somethingComplex`: 332 | 333 | ```typescript 334 | @Entity({ datastore: libraryDatastore }) 335 | class Author { 336 | // ... 337 | 338 | @Column() 339 | private _complex: string = ""; // place to store serialized value of somethingComplex 340 | 341 | set somethingComplex(value: any) { 342 | // function serialize(value: any): string 343 | this._complex = serialize(value); 344 | } 345 | get somethingComplex(): any { 346 | // function deserialize(serializedValue: string): any 347 | return (async () => deserialize(await this._complex))(); 348 | } 349 | 350 | // ... 351 | } 352 | ``` 353 | 354 | ## Repositories 355 | 356 | To actually interact with the datastore, you'll need a Repository. 357 | 358 | ```typescript 359 | import { getRepository } from "@kv-orm/core"; 360 | 361 | const authorRepository = getRepository(Author); 362 | ``` 363 | 364 | ### Save 365 | 366 | You can then save Entity instances. 367 | 368 | ```typescript 369 | const williamShakespeare = new Author({ 370 | firstName: "William", 371 | lastName: "Shakespeare", 372 | emailAddress: "william@shakespeare.com", 373 | birthYear: 1564, 374 | phoneNumber: "+1234567890", 375 | }); 376 | 377 | await authorRepository.save(williamShakepseare); 378 | ``` 379 | 380 | ### Load 381 | 382 | And subsequently, load them back again. If the Entity has a PrimaryColumn, you can load the specific instance by passing in the PrimaryColumn value. 383 | 384 | ```typescript 385 | const loadedWilliamShakespeare = await authorRepository.load( 386 | "william@shakespeare.com" 387 | ); 388 | 389 | console.log(await loadedWilliamShakespeare.nickName); // Bill 390 | ``` 391 | 392 | ### Search 393 | 394 | If a property has been set as an IndexableColumn (is non-unique), you can search for instances with a saved value. 395 | 396 | ```typescript 397 | const searchedAuthors = await authorRepository.search("birthYear", 1564); 398 | 399 | for await (const searchedAuthor of searchedAuthors) { 400 | console.log(await searchedAuthor.nickName); // Bill 401 | } 402 | ``` 403 | 404 | ### Find 405 | 406 | If a property has been set as a UniqueColumn, you can directly load an instance by a saved value. If no results are found, `null` is returned. 407 | 408 | ```typescript 409 | const foundWilliamShakespeare = await authorRepository.find( 410 | "phoneNumber", 411 | "+1234567890" 412 | ); 413 | 414 | console.log(await foundWilliamShakespeare?.nickName); // Bill 415 | 416 | const foundNonexistent = await authorRepository.find( 417 | "phoneNumber", 418 | "+9999999999" 419 | ); 420 | 421 | console.log(foundNonexistent); // null 422 | ``` 423 | 424 | ## Relationships 425 | 426 | All Relationships must supply the `type` of Entity as a function (to allow circular dependencies) and a `backRef` (the property name on the inverse-side of the Relationship). 427 | 428 | In order to propagate changes automatically on update and on delete, `cascade` can be set to `true`. 429 | 430 | Note: deleting may be disproportionally intensive as it load, and then must edit or delete every related instance. 431 | 432 | ### One To One / Many To One 433 | 434 | For \* to one Relationships, use the ToOne decorator. 435 | 436 | ```typescript 437 | import { Author, authorRepository } from "./Author"; 438 | 439 | @Entity({ datastore: libraryDatastore }) 440 | class Book { 441 | // ... 442 | 443 | @ToOne({ type: () => Author, backRef: "books", cascade: true }) 444 | public author: Author; 445 | 446 | // ... 447 | } 448 | 449 | const bookRepository = getRepository(Book); 450 | 451 | const williamShakespeare = await authorRepository.load( 452 | "william@shakespeare.com" 453 | ); 454 | 455 | const hamlet = new Book({ title: "Hamlet", author: williamShakespeare }); 456 | 457 | console.log((await hamlet.author) === williamShakespeare); // true 458 | 459 | // And because `cascade` was set to `true`, the Author instance has been updated as well 460 | const books = await williamShakespeare.books; 461 | for await (const book of books) { 462 | console.log(await book.title); // Hamlet 463 | } 464 | 465 | await bookRepository.save(hamlet); // Will also save williamShakespeare 466 | ``` 467 | 468 | ### One To Many / Many To Many 469 | 470 | ToMany Relationships are slightly different to how Columns and ToOne Relationships work. Instead of setting its value and awaiting a Promise of its value, ToMany Relationships set an array of values, and await a Promise of an [AsyncGenerator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of) which yields its values. 471 | 472 | ```typescript 473 | @Entity({ datastore: libraryDatastore }) 474 | class Author { 475 | // ... 476 | 477 | @ToMany({ type: () => Book, backRef: "author", cascade: true }) // More on this later 478 | public books: Book[] = []; 479 | 480 | // ... 481 | } 482 | 483 | // ... 484 | 485 | const hamlet = new Book({ title: "Hamlet" }); 486 | 487 | williamShakespeare.books = [hamlet]; 488 | 489 | const books = await williamShakespeare.books; 490 | for await (const book of books) { 491 | console.log(await book.title); // Hamlet 492 | } 493 | 494 | // And because `cascade` was set to `true`, the Book instance has been updated as well 495 | console.log((await hamlet.author) === williamShakespeare); // true - because `cascade` is set to true 496 | 497 | await authorRepository.save(williamShakespeare); // Will also save hamlet 498 | ``` 499 | 500 | Note: The order of returned Entities by the AsyncGenerator is not guaranteed. In fact, as items are loaded into memory, they will be pushed to the front to ensure tha other Entities are loaded only as needed. 501 | 502 | #### Helpers 503 | 504 | To simplify interacting with `ToMany` Relationships, some helpers are available. 505 | 506 | Note: saving must still be completed afterwards to persist changes. 507 | 508 | ##### `addTo` 509 | 510 | `addTo` simplifies pushing an element to a ToMany relaionship: 511 | 512 | ```typescript 513 | import { addTo } from "@kv-orm/core"; 514 | 515 | addTo(williamShakespeare, "books", hamlet); 516 | ``` 517 | 518 | ##### `removeFrom` 519 | 520 | `removeFrom` simplifies splicing an element from a ToMany relaionship: 521 | 522 | ```typescript 523 | import { removeFrom } from "@kv-orm/core"; 524 | 525 | removeFrom(williamShakespeare, "books", hamlet); 526 | ``` 527 | 528 | # Types 529 | 530 | ## `columnType` 531 | 532 | When defining an Entity in TypeScript, you must provide types for the properties. For example, `firstName` and `lastName` are set as `string`s: 533 | 534 | ```typescript 535 | import { Column, Entity } from "@kv-orm/core"; 536 | 537 | @Entity({ datastore: libraryDatastore }) 538 | class Author { 539 | @Column() 540 | public firstName: string; 541 | 542 | @Column() 543 | public lastName: string; 544 | 545 | // ... 546 | } 547 | ``` 548 | 549 | However, when reading these values with kv-orm, in fact a `Promise` is returned. Simply switching these type definitions to `Promise` is not valid either, as writing values is done synchronously. 550 | 551 | Therefore, to improve the developer experience and prevent TypeScript errors such as `'await' has no effect on the type of this expression. ts(80007)` and `Property 'then' does not exist on type 'T'. ts(2339)`, when declaring the Entity, wrap the property types in the `columnType` helper. In the same example: 552 | 553 | ```typescript 554 | import { columnType } from "@kv-orm/core"; 555 | 556 | @Entity({ datastore: libraryDatastore }) 557 | class Author { 558 | @Column() 559 | public firstName: columnType; 560 | 561 | @Column() 562 | public lastName: columnType; 563 | 564 | // ... 565 | } 566 | ``` 567 | 568 | `columnType` is simply an alias: `type columnType = T | Promise`. 569 | 570 | ## `toOneType` 571 | 572 | Similarly, for ToOne Relationships: 573 | 574 | ```typescript 575 | import { toOneType } from "@kv-orm/core"; 576 | 577 | @Entity({ datastore: libraryDatastore }) 578 | class Book { 579 | // ... 580 | 581 | @ToOne({ type: () => Author, backRef: "books", cascade: true }) 582 | public author: toOneType; 583 | 584 | // ... 585 | } 586 | ``` 587 | 588 | ## `toManyType` 589 | 590 | And ToMany Relationships: 591 | 592 | ```typescript 593 | import { toManyType } from "@kv-orm/core"; 594 | 595 | @Entity({ datastore: libraryDatastore }) 596 | class Author { 597 | // ... 598 | 599 | @ToMany({ type: () => Book, backRef: "author", cascade: true }) 600 | public books: toManyType; 601 | 602 | // ... 603 | } 604 | ``` 605 | 606 | # Development 607 | 608 | ## Clone and Install Dependencies 609 | 610 | ```sh 611 | git clone git@github.com:kv-orm/core.git 612 | npm install 613 | ``` 614 | 615 | ## Run tests 616 | 617 | ```sh 618 | npm run lint # 'npm run lint:fix' will automatically fix most problems 619 | npm test 620 | ``` 621 | 622 | ## 🚎 Roadmap 623 | 624 | 625 | Features 626 | 627 | 628 | 629 | Bugs 630 | 631 | 632 | ## 📝 License 633 | 634 | Copyright © 2019 [Greg Brimble](https://github.com/GregBrimble).
635 | This project is [MIT](https://github.com/kv-orm/core/blob/master/LICENSE) licensed. 636 | 637 | # FAQs 638 | 639 | ## My Entity keys are getting mangled when they are saved into the datastore 640 | 641 | If you're using a preprocessor that minifies class names, such as Babel, the class constructors names often get shortened. kv-orm will always use this class name, so, either disable minification in the preprocessor, or manually set the `key` value when creating an Entity e.g. 642 | 643 | ```typescript 644 | @Entity({ key: "MyClass" }) 645 | class MyClass { 646 | // ... 647 | } 648 | ``` 649 | 650 | ## How can I upgrade from the alpha (0.0.X)? 651 | 652 | Thank you for trying out kv-orm in it's alpha period! Thanks to a generous sponsor, I have been able to complete work to elevate [kv-orm] to a more featureful beta. Unfortunately, this has meant a couple of minor breaking changes. 653 | 654 | - [`PrimaryColumn`](#Primary-Column), [`IndexableColumn`](#Indexable-Column) and [`UniqueColumn`](#Unique-Column) have been introduced to deprecate the `isPrimary`, `isIndexable` and `isUnique` options on the `Column` decorator. 655 | 656 | - `Repository`'s `find` method has been renamted to [`search`](#Search), and a different, new function [`find`](#Find) been added. 657 | 658 | This is probably the most confusing and frustrating breaking change (apologies—I will take efforts to make sure this doesn't happen again). With the introduction of `UniqueColumn` as a more specific type of `IndexableColumn`, we needed a way to take advantage of its simpler loading mechanism. Since `UniqueColumn` has unique-values, there should be only ever one instance with a given value (or none), and so `find` seemed a more appropriate verb for its loading. Whereas `IndexableColumn` can have non-unique values, `search` seemed more appropriate a verb when returning an `AsyncGenerator` of instances. 659 | 660 | - [`ToOne`](#To-One) & [`ToMany`](#To-Many) have been formally introduced (renamed from their drafted `ManyToOne` & `ManyToMany` names—the parent's cardinality does not matter). 661 | 662 | - Various bugfixes and improvements. None should have unexpected breaking changes, but if I've missed a use-case, please file a [GitHub Issue](https://github.com/kv-orm/core/issues) and tell me about it. 663 | 664 | [kv-orm]: https://github.com/kv-orm/core 665 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: `ts-jest`, 3 | rootDir: `src`, 4 | coverageDirectory: `../coverage`, 5 | testEnvironment: `node`, 6 | collectCoverageFrom: [ 7 | '**/*.{js,ts}', 8 | '!**/node_modules/**', 9 | '!**/*.testhelpers.{js,ts}', 10 | ], 11 | testMatch: [ 12 | '**/__tests__/**/*.[jt]s?(x)', 13 | '**/?(*.)+(spec|test).[jt]s?(x)', 14 | '!**/*.testhelpers.ts', 15 | ], 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kv-orm/core", 3 | "version": "0.1.1", 4 | "description": "A Node.js ORM for key-value datastores", 5 | "main": "dist/index", 6 | "types": "dist/index", 7 | "files": [ 8 | "dist/**/*" 9 | ], 10 | "scripts": { 11 | "test": "jest", 12 | "test:coverage": "npm run test -- --coverage", 13 | "clean": "rm -rf dist/", 14 | "clean:node": "rm -rf package-lock.json node_modules/", 15 | "build": "tsc", 16 | "lint": "prettier -c 'src/' 'README.md'", 17 | "lint:fix": "prettier --write 'src/' 'README.md'" 18 | }, 19 | "publishConfig": { 20 | "registry": "https://npm.pkg.github.com/" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git@github.com:kv-orm/core.git" 25 | }, 26 | "keywords": [ 27 | "kv", 28 | "key-value", 29 | "orm", 30 | "model", 31 | "nodejs", 32 | "javascript" 33 | ], 34 | "author": { 35 | "name": "Greg Brimble", 36 | "email": "developer@gregbrimble.com", 37 | "url": "https://gregbrimble.com" 38 | }, 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/kv-orm/core/issues" 42 | }, 43 | "homepage": "https://github.com/kv-orm/core#readme", 44 | "devDependencies": { 45 | "@babel/core": "7.9.0", 46 | "@babel/preset-env": "7.9.5", 47 | "@types/jest": "25.2.1", 48 | "@types/node": "13.13.4", 49 | "babel-jest": "25.3.0", 50 | "codecov": "3.6.5", 51 | "husky": "^4.2.5", 52 | "jest": "25.3.0", 53 | "prettier": "2.0.4", 54 | "ts-jest": "25.3.1", 55 | "typescript": "3.8.3" 56 | }, 57 | "dependencies": { 58 | "reflect-metadata": "0.1.13" 59 | }, 60 | "husky": { 61 | "hooks": { 62 | "pre-commit": "npm run lint || (npm run lint:fix && npm run lint)", 63 | "pre-push": "npm test" 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Cache/Cache.test.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from "./Cache"; 2 | import { Datastore } from "../Datastore/Datastore"; 3 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 4 | import { Entity, BaseEntity } from "../Entity/Entity"; 5 | import { Column } from "../Column/Column"; 6 | import { CacheMissingPrimaryColumnValueError } from "./CacheMissingPrimaryColumnValueError"; 7 | 8 | describe(`Cache`, () => { 9 | let cache: Cache; 10 | let datastore: Datastore; 11 | let instance: BaseEntity; 12 | 13 | beforeEach(() => { 14 | cache = new Cache(); 15 | datastore = new MemoryDatastore(); 16 | 17 | @Entity({ datastore }) 18 | class MyEntity { 19 | @Column() 20 | public myProperty = `initial value`; 21 | 22 | @Column({ isPrimary: true }) 23 | public id: string; 24 | 25 | constructor(id: string) { 26 | this.id = id; 27 | } 28 | } 29 | 30 | instance = new MyEntity(`abc`); 31 | }); 32 | 33 | it(`can be written to, read from, and elements can be deleted`, async () => { 34 | cache.write(instance, () => `key`, `value`); 35 | expect(await cache.read(instance, `key`)).toEqual(`value`); 36 | cache.delete(instance, () => `key`); 37 | expect(await cache.read(instance, `key`)).toBeNull(); 38 | }); 39 | 40 | describe(`CacheMissingPrimaryColumnValueError`, () => { 41 | it(`is thrown when reading an, as yet, unset PrimaryColumn value`, () => { 42 | expect(() => { 43 | cache.getPrimaryColumnValue({}); 44 | }).toThrow(CacheMissingPrimaryColumnValueError); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/Cache/Cache.ts: -------------------------------------------------------------------------------- 1 | import { Key, Value } from "../Datastore/Datastore"; 2 | import { BaseEntity } from "../Entity/Entity"; 3 | import { Instruction } from "../Instruction/Instruction"; 4 | import { cacheWrite } from "./cacheWrite"; 5 | import { cacheDelete } from "./cacheDelete"; 6 | import { cacheRead } from "./cacheRead"; 7 | import { cacheSync } from "./cacheSync"; 8 | import { CacheMissingPrimaryColumnValueError } from "./CacheMissingPrimaryColumnValueError"; 9 | 10 | export class Cache { 11 | public instructions = new Map(); 12 | public data = new Map>(); 13 | private primaryColumnValues = new Map(); 14 | 15 | public recordInstruction( 16 | instance: BaseEntity, 17 | instruction: Instruction 18 | ): void { 19 | const instructions = this.instructions.get(instance) || []; 20 | instructions.push(instruction); 21 | this.instructions.set(instance, instructions); 22 | } 23 | 24 | public async sync(instance: BaseEntity): Promise { 25 | return cacheSync(this, instance); 26 | } 27 | 28 | public async read(instance: BaseEntity, key: Key): Promise { 29 | return cacheRead(this, instance, key); 30 | } 31 | 32 | public write( 33 | instance: BaseEntity, 34 | keyGenerator: () => Key, 35 | value: Value 36 | ): void { 37 | return cacheWrite(this, instance, keyGenerator, value); 38 | } 39 | 40 | public delete(instance: BaseEntity, keyGenerator: () => Key): void { 41 | return cacheDelete(this, instance, keyGenerator); 42 | } 43 | 44 | public getPrimaryColumnValue( 45 | instance: BaseEntity, 46 | { failSilently } = { failSilently: false } 47 | ): Value { 48 | const value = this.primaryColumnValues.get(instance); 49 | if (value === undefined && !failSilently) 50 | throw new CacheMissingPrimaryColumnValueError(instance); 51 | return value; 52 | } 53 | 54 | public setPrimaryColumnValue(instance: BaseEntity, value: Value): void { 55 | this.primaryColumnValues.set(instance, value); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Cache/CacheMissingPrimaryColumnValueError.ts: -------------------------------------------------------------------------------- 1 | import { KVORMError } from "../utils/errors"; 2 | import { BaseEntity } from "../Entity/Entity"; 3 | import { getConstructor } from "../utils/entities"; 4 | 5 | export class CacheMissingPrimaryColumnValueError extends KVORMError { 6 | constructor(instance: BaseEntity) { 7 | super( 8 | `Could not find the value of the PrimaryColumn in the cache for an Entity, ${ 9 | getConstructor(instance).name 10 | }` 11 | ); 12 | this.name = `CacheMissingPrimaryColumnValueError`; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Cache/cacheDelete.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from "./Cache"; 2 | import { BaseEntity } from "../Entity/Entity"; 3 | import { Key } from "../Datastore/Datastore"; 4 | import { DeleteInstruction } from "../Instruction/DeleteInstruction"; 5 | 6 | export const cacheDelete = ( 7 | cache: Cache, 8 | instance: BaseEntity, 9 | keyGenerator: () => Key 10 | ): void => 11 | cache.recordInstruction(instance, new DeleteInstruction(keyGenerator)); 12 | -------------------------------------------------------------------------------- /src/Cache/cacheRead.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from "./Cache"; 2 | import { BaseEntity } from "../Entity/Entity"; 3 | import { Key, Value } from "../Datastore/Datastore"; 4 | import { getConstructor } from "../utils/entities"; 5 | import { getDatastore } from "../utils/datastore"; 6 | import { cacheStabilize } from "./cacheStabilize"; 7 | 8 | export const cacheRead = async ( 9 | cache: Cache, 10 | instance: BaseEntity, 11 | key: Key 12 | ): Promise => { 13 | await cacheStabilize(cache, instance); 14 | const data = cache.data.get(instance) || new Map(); 15 | let value = data.get(key) || null; 16 | if (value !== null) return value; 17 | 18 | const constructor = getConstructor(instance); 19 | const datastore = getDatastore(constructor); 20 | value = await datastore.read(key); 21 | data.set(key, value); 22 | cache.data.set(instance, data); 23 | return value; 24 | }; 25 | -------------------------------------------------------------------------------- /src/Cache/cacheStabilize.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from "./Cache"; 2 | import { BaseEntity } from "../Entity/Entity"; 3 | import { optimizeInstructions } from "./optimizeInstructions"; 4 | import { Value, Key } from "../Datastore/Datastore"; 5 | 6 | export const cacheStabilize = ( 7 | cache: Cache, 8 | instance: BaseEntity 9 | ): Promise => { 10 | const instructions = optimizeInstructions(cache, instance); 11 | 12 | if (instructions.length === 0) return Promise.resolve(false); 13 | 14 | const data = cache.data.get(instance) || new Map(); 15 | 16 | for (const instruction of instructions) { 17 | data.set(instruction.key, instruction.value); 18 | } 19 | 20 | cache.data.set(instance, data); 21 | return Promise.resolve(true); 22 | }; 23 | -------------------------------------------------------------------------------- /src/Cache/cacheSync.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from "./Cache"; 2 | import { BaseEntity } from "../Entity/Entity"; 3 | import { optimizeInstructions } from "./optimizeInstructions"; 4 | import { getDatastore } from "../utils/datastore"; 5 | import { getConstructor } from "../utils/entities"; 6 | 7 | export const cacheSync = async ( 8 | cache: Cache, 9 | instance: BaseEntity 10 | ): Promise => { 11 | const constructor = getConstructor(instance); 12 | const datastore = getDatastore(constructor); 13 | const instructions = optimizeInstructions(cache, instance); 14 | 15 | if (instructions.length === 0) return Promise.resolve(false); 16 | 17 | for (const instruction of instructions) { 18 | await instruction.perform(datastore); 19 | } 20 | 21 | cache.instructions.set(instance, []); 22 | return Promise.resolve(true); 23 | }; 24 | -------------------------------------------------------------------------------- /src/Cache/cacheWrite.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from "./Cache"; 2 | import { BaseEntity } from "../Entity/Entity"; 3 | import { Key, Value } from "../Datastore/Datastore"; 4 | import { WriteInstruction } from "../Instruction/WriteInstruction"; 5 | 6 | export const cacheWrite = ( 7 | cache: Cache, 8 | instance: BaseEntity, 9 | keyGenerator: () => Key, 10 | value: Value 11 | ): void => 12 | cache.recordInstruction(instance, new WriteInstruction(keyGenerator, value)); 13 | -------------------------------------------------------------------------------- /src/Cache/optimizeInstructions.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from "./Cache"; 2 | import { BaseEntity } from "../Entity/Entity"; 3 | import { Instruction } from "../Instruction/Instruction"; 4 | 5 | export const optimizeInstructions = ( 6 | cache: Cache, 7 | instance: BaseEntity 8 | ): Instruction[] => { 9 | let instructions = cache.instructions.get(instance) || []; 10 | instructions = [...instructions].reverse(); 11 | const optimalInstructions = []; 12 | const seenKeys = new Set(); 13 | for (const instruction of instructions) { 14 | const key = instruction.key; 15 | if (!seenKeys.has(key)) { 16 | optimalInstructions.push(instruction); 17 | seenKeys.add(key); 18 | } 19 | } 20 | optimalInstructions.reverse(); 21 | cache.instructions.set(instance, optimalInstructions); 22 | return optimalInstructions; 23 | }; 24 | -------------------------------------------------------------------------------- /src/Column/Column.test.ts: -------------------------------------------------------------------------------- 1 | import { Entity, BaseEntity } from "../Entity/Entity"; 2 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 3 | import { Datastore } from "../Datastore/Datastore"; 4 | import { Column } from "../Column/Column"; 5 | import { PrimaryColumn } from "../Column/PrimaryColumn"; 6 | import { MetadataSetupError } from "../utils/metadata"; 7 | 8 | describe(`Column`, () => { 9 | let datastore: Datastore; 10 | let instance: BaseEntity; 11 | let otherInstance: BaseEntity; 12 | 13 | beforeEach(() => { 14 | datastore = new MemoryDatastore(); 15 | 16 | @Entity({ datastore, key: `MyEntity` }) 17 | class MyEntity { 18 | @Column({ key: `myProperty` }) 19 | public myProperty = `initial value`; 20 | 21 | @PrimaryColumn() 22 | public id: string; 23 | 24 | constructor(id: string) { 25 | this.id = id; 26 | } 27 | } 28 | 29 | instance = new MyEntity(`abc`); 30 | otherInstance = new MyEntity(`def`); 31 | }); 32 | 33 | it(`can be initialized with a default value`, async () => { 34 | expect(await instance.myProperty).toEqual(`initial value`); 35 | }); 36 | 37 | it(`can be written to, and subsequently read from`, async () => { 38 | instance.myProperty = `new value`; 39 | expect(await instance.myProperty).toEqual(`new value`); 40 | expect(await otherInstance.myProperty).toEqual(`initial value`); 41 | }); 42 | 43 | it(`can write and read an array value`, async () => { 44 | @Entity({ datastore }) 45 | class EntityWithArrayColumn { 46 | @Column() 47 | public arrayColumn: number[]; 48 | 49 | constructor(arrayValues: number[]) { 50 | this.arrayColumn = arrayValues; 51 | } 52 | } 53 | 54 | const values = [1, 2, 3, 4, 5]; 55 | instance = new EntityWithArrayColumn(values); 56 | 57 | expect(await instance.arrayColumn).toEqual(values); 58 | }); 59 | 60 | describe(`MetadataSetupError`, () => { 61 | it(`is thrown with a duplicate key`, () => { 62 | expect(() => { 63 | @Entity({ datastore, key: `MyOtherEntity` }) 64 | class MyOtherEntity { 65 | @Column({ key: `myDuplicatedProperty` }) 66 | public myProperty1 = `initial value`; 67 | 68 | @Column({ key: `myDuplicatedProperty` }) 69 | public myProperty2 = `other initial value`; 70 | } 71 | }).toThrow(MetadataSetupError); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/Column/Column.ts: -------------------------------------------------------------------------------- 1 | import "../metadata"; 2 | 3 | import { Key } from "../Datastore/Datastore"; 4 | import { BaseEntity, PropertyValue } from "../Entity/Entity"; 5 | import { setColumnMetadata, getColumnMetadatas } from "../utils/columns"; 6 | import { getConstructor } from "../utils/entities"; 7 | import { columnGet } from "./columnGet"; 8 | import { columnSet } from "./columnSet"; 9 | import { createColumnMetadata } from "./columnMetadata"; 10 | import { assertKeyNotInUse, getPropertyMetadatas } from "../utils/metadata"; 11 | 12 | export const COLUMN_KEY = Symbol(`Column`); 13 | 14 | interface ColumnOptions { 15 | key?: Key; 16 | isPrimary?: boolean; 17 | isIndexable?: boolean; 18 | isUnique?: boolean; 19 | } 20 | 21 | export const Column = (options: ColumnOptions = {}) => { 22 | return (instance: T, property: keyof T): void => { 23 | const columnMetadata = createColumnMetadata({ 24 | options, 25 | property, 26 | }); 27 | 28 | const constructor = getConstructor(instance); 29 | assertKeyNotInUse(constructor, columnMetadata, { 30 | getMetadatas: getPropertyMetadatas, 31 | }); 32 | setColumnMetadata(constructor, columnMetadata); 33 | 34 | Reflect.defineProperty(instance, property, { 35 | enumerable: true, 36 | configurable: true, 37 | get: async function get(this: BaseEntity) { 38 | return await columnGet(this, columnMetadata); 39 | }, 40 | set: function set(this: BaseEntity, value: PropertyValue) { 41 | columnSet(this, columnMetadata, value); 42 | }, 43 | }); 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/Column/IndexableColumn.test.ts: -------------------------------------------------------------------------------- 1 | import { Entity, BaseEntity } from "../Entity/Entity"; 2 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 3 | import { PrimaryColumn } from "./PrimaryColumn"; 4 | import { IndexableColumn } from "./IndexableColumn"; 5 | 6 | describe("UniqueColumn", () => { 7 | let instance: BaseEntity; 8 | beforeEach(() => { 9 | @Entity({ datastore: new MemoryDatastore() }) 10 | class MyEntity { 11 | @PrimaryColumn() 12 | public id: string; 13 | @IndexableColumn() 14 | public indexableColumn: string; 15 | constructor(id: string, indexableColumn: string) { 16 | this.id = id; 17 | this.indexableColumn = indexableColumn; 18 | } 19 | } 20 | instance = new MyEntity("123", "indexableValue"); 21 | }); 22 | it("can be setup", async () => { 23 | expect(await instance.indexableColumn).toEqual("indexableValue"); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/Column/IndexableColumn.ts: -------------------------------------------------------------------------------- 1 | import { Column } from "./Column"; 2 | import { Key } from "../Datastore/Datastore"; 3 | 4 | interface IndexableColumnOptions { 5 | key?: Key; 6 | } 7 | 8 | export const IndexableColumn = (options: IndexableColumnOptions = {}) => 9 | Column({ ...options, isIndexable: true }); 10 | -------------------------------------------------------------------------------- /src/Column/PrimaryColumn.test.ts: -------------------------------------------------------------------------------- 1 | import { Entity, BaseEntity } from "../Entity/Entity"; 2 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 3 | import { PrimaryColumn } from "./PrimaryColumn"; 4 | import { ReadOnlyError } from "../utils/errors"; 5 | 6 | describe("PrimaryColumn", () => { 7 | let instance: BaseEntity; 8 | beforeEach(() => { 9 | @Entity({ datastore: new MemoryDatastore() }) 10 | class MyEntity { 11 | @PrimaryColumn() 12 | public id: string; 13 | constructor(id: string) { 14 | this.id = id; 15 | } 16 | } 17 | instance = new MyEntity("123"); 18 | }); 19 | it("can be setup", async () => { 20 | expect(await instance.id).toEqual("123"); 21 | }); 22 | describe(`ReadOnlyError`, () => { 23 | it(`is thrown when attempting to write to a PrimaryColumn twice`, () => { 24 | expect(() => { 25 | instance.id = `zzz`; 26 | }).toThrow(ReadOnlyError); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/Column/PrimaryColumn.ts: -------------------------------------------------------------------------------- 1 | import { Column } from "./Column"; 2 | import { Key } from "../Datastore/Datastore"; 3 | 4 | interface PrimaryColumnOptions { 5 | key?: Key; 6 | } 7 | 8 | export const PrimaryColumn = (options: PrimaryColumnOptions = {}) => 9 | Column({ ...options, isPrimary: true }); 10 | -------------------------------------------------------------------------------- /src/Column/UniqueColumn.test.ts: -------------------------------------------------------------------------------- 1 | import { Entity, BaseEntity } from "../Entity/Entity"; 2 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 3 | import { PrimaryColumn } from "./PrimaryColumn"; 4 | import { UniqueColumn } from "./UniqueColumn"; 5 | 6 | describe("UniqueColumn", () => { 7 | let instance: BaseEntity; 8 | beforeEach(() => { 9 | @Entity({ datastore: new MemoryDatastore() }) 10 | class MyEntity { 11 | @PrimaryColumn() 12 | public id: string; 13 | @UniqueColumn() 14 | public uniqueProperty: string; 15 | constructor(id: string, uniqueProperty: string) { 16 | this.id = id; 17 | this.uniqueProperty = uniqueProperty; 18 | } 19 | } 20 | instance = new MyEntity("123", "uniqueValue"); 21 | }); 22 | it("can be setup", async () => { 23 | expect(await instance.uniqueProperty).toEqual("uniqueValue"); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/Column/UniqueColumn.ts: -------------------------------------------------------------------------------- 1 | import { Column } from "./Column"; 2 | import { Key } from "../Datastore/Datastore"; 3 | 4 | interface UniqueColumnOptions { 5 | key?: Key; 6 | } 7 | 8 | export const UniqueColumn = (options: UniqueColumnOptions = {}) => 9 | Column({ ...options, isIndexable: true, isUnique: true }); 10 | -------------------------------------------------------------------------------- /src/Column/columnGet.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity } from "../Entity/Entity"; 2 | import { ColumnMetadata } from "./columnMetadata"; 3 | import { Value } from "../Datastore/Datastore"; 4 | import { getConstructorDatastoreCache } from "../utils/entities"; 5 | import { generatePropertyKey } from "../utils/keyGeneration"; 6 | 7 | export const columnGet = async ( 8 | instance: BaseEntity, 9 | columnMetadata: ColumnMetadata 10 | ): Promise => { 11 | const { cache } = getConstructorDatastoreCache(instance); 12 | 13 | const key = generatePropertyKey(instance, columnMetadata); 14 | 15 | return await cache.read(instance, key); 16 | }; 17 | -------------------------------------------------------------------------------- /src/Column/columnMetadata.ts: -------------------------------------------------------------------------------- 1 | import { Key } from "../Datastore/Datastore"; 2 | import { PropertyKey } from "../Entity/Entity"; 3 | import { Metadata } from "../utils/metadata"; 4 | 5 | export interface ColumnMetadata extends Metadata { 6 | key: Key; 7 | property: PropertyKey; 8 | isPrimary?: boolean; 9 | isIndexable?: boolean; 10 | isUnique?: boolean; 11 | } 12 | 13 | export const createColumnMetadata = ({ 14 | options: { key, isIndexable, isPrimary, isUnique }, 15 | property, 16 | }: { 17 | options: { 18 | key?: Key; 19 | isIndexable?: boolean; 20 | isPrimary?: boolean; 21 | isUnique?: boolean; 22 | }; 23 | property: PropertyKey; 24 | }): ColumnMetadata => ({ 25 | key: key || property.toString(), 26 | property, 27 | isIndexable: !!isPrimary || !!isIndexable, 28 | isPrimary: !!isPrimary, 29 | isUnique: !!isPrimary || !!isUnique, 30 | }); 31 | -------------------------------------------------------------------------------- /src/Column/columnSet.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity } from "../Entity/Entity"; 2 | import { ColumnMetadata } from "./columnMetadata"; 3 | import { Key, Value } from "../Datastore/Datastore"; 4 | import { getConstructorDatastoreCache } from "../utils/entities"; 5 | import { 6 | generatePropertyKey, 7 | generateIndexablePropertyKey, 8 | generateRelationshipKey, 9 | } from "../utils/keyGeneration"; 10 | import { getPrimaryColumnValue, setPrimaryColumnValue } from "../utils/columns"; 11 | import { ReadOnlyError } from "../utils/errors"; 12 | 13 | export const columnSet = ( 14 | instance: BaseEntity, 15 | columnMetadata: ColumnMetadata, 16 | value: Value 17 | ): void => { 18 | const { constructor, cache } = getConstructorDatastoreCache(instance); 19 | 20 | if (columnMetadata.isPrimary) { 21 | if (getPrimaryColumnValue(instance, { failSilently: true }) !== undefined) { 22 | throw new ReadOnlyError( 23 | constructor, 24 | columnMetadata.property, 25 | value, 26 | `PrimaryColumn Value has already been set` 27 | ); 28 | } 29 | setPrimaryColumnValue(instance, value); 30 | } 31 | 32 | if (columnMetadata.isIndexable) { 33 | const indexableKeyGenerator = (): Key => 34 | generateIndexablePropertyKey(instance, columnMetadata, value); 35 | const primaryColumnValue = generateRelationshipKey(instance); 36 | cache.write(instance, indexableKeyGenerator, primaryColumnValue); 37 | } 38 | 39 | const keyGenerator = (): Key => generatePropertyKey(instance, columnMetadata); 40 | cache.write(instance, keyGenerator, value); 41 | }; 42 | -------------------------------------------------------------------------------- /src/Datastore/Datastore.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from "../Cache/Cache"; 2 | import { SearchStrategyError } from "./SearchStrategyError"; 3 | import { EntityConstructor } from "../Entity/Entity"; 4 | 5 | export type Value = any; 6 | export type Key = string; 7 | 8 | interface DatastoreOptions { 9 | keySeparator?: string; 10 | cache?: Cache; 11 | } 12 | 13 | export type Cursor = string; 14 | 15 | export enum SearchStrategy { 16 | prefix, 17 | } 18 | 19 | export interface SearchOptions { 20 | term: Key; 21 | strategy: SearchStrategy; 22 | first?: number; 23 | after?: Cursor; 24 | } 25 | 26 | export interface SearchResult { 27 | keys: Key[]; 28 | hasNextPage: boolean; 29 | cursor: Cursor; 30 | } 31 | 32 | export abstract class Datastore { 33 | public abstract searchStrategies: SearchStrategy[]; 34 | public readonly keySeparator: Key; 35 | public readonly cache: Cache; 36 | public entityConstructors: EntityConstructor[] = []; 37 | 38 | protected abstract _read(key: Key): Promise | Value; 39 | protected abstract _write(key: Key, value: Value): Promise; 40 | protected abstract _delete(key: Key): Promise; 41 | protected abstract _search(options: SearchOptions): Promise; 42 | 43 | public read(key: Key): Promise { 44 | return this._read(key); 45 | } 46 | 47 | public write(key: Key, value: Value): Promise { 48 | return this._write(key, value); 49 | } 50 | 51 | public delete(key: Key): Promise { 52 | return this._delete(key); 53 | } 54 | 55 | public search(options: SearchOptions): Promise { 56 | this.assertSearchStrategyIsValid(options.strategy); 57 | return this._search(options); 58 | } 59 | 60 | protected assertSearchStrategyIsValid = ( 61 | searchStrategy: SearchStrategy 62 | ): void => { 63 | if (!(searchStrategy in this.searchStrategies)) 64 | throw new SearchStrategyError( 65 | searchStrategy, 66 | `Search Strategy is not implemented on this type of Datastore.` 67 | ); 68 | }; 69 | 70 | public constructor({ 71 | keySeparator = `:`, 72 | cache = new Cache(), 73 | }: DatastoreOptions = {}) { 74 | this.keySeparator = keySeparator; 75 | this.cache = cache; 76 | } 77 | 78 | public registerEntity(constructor: EntityConstructor): void { 79 | this.entityConstructors.push(constructor); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Datastore/SearchStrategyError.ts: -------------------------------------------------------------------------------- 1 | import { KVORMError } from "../utils/errors"; 2 | import { SearchStrategy } from "./Datastore"; 3 | 4 | export class SearchStrategyError extends KVORMError { 5 | constructor(searchStrategy: SearchStrategy, message = `Unknown Error`) { 6 | super( 7 | `Error using Search Strategy, ${SearchStrategy[searchStrategy]}: ${message}` 8 | ); 9 | this.name = `SearchStrategyError`; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Entity/Entity.test.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from "./Entity"; 2 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 3 | import { Datastore } from "../Datastore/Datastore"; 4 | import { MetadataSetupError } from "../utils/metadata"; 5 | 6 | describe(`Entity`, () => { 7 | let datastore: Datastore; 8 | 9 | beforeEach(() => { 10 | datastore = new MemoryDatastore(); 11 | }); 12 | 13 | it(`can be initialized with a MemoryDatastore`, () => { 14 | @Entity({ datastore }) 15 | class X {} 16 | 17 | const instance = new X(); 18 | 19 | expect(instance).toBeInstanceOf(X); 20 | expect(instance.constructor).toBe(X); 21 | }); 22 | 23 | describe(`MetadataSetupError`, () => { 24 | it(`is thrown when registering two Entities with the same Key`, () => { 25 | expect(() => { 26 | @Entity({ datastore }) 27 | class X {} 28 | 29 | @Entity({ datastore, key: `X` }) 30 | class Y {} 31 | }).toThrow(MetadataSetupError); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/Entity/Entity.ts: -------------------------------------------------------------------------------- 1 | import "../metadata"; 2 | 3 | import { Datastore, Key } from "../Datastore/Datastore"; 4 | import { createEntityMetadata, EntityMetadata } from "./entityMetadata"; 5 | import { setEntityMetadata, getEntityMetadata } from "../utils/entities"; 6 | import { assertKeyNotInUse } from "../utils/metadata"; 7 | 8 | export const ENTITY_KEY = Symbol(`Entity`); 9 | 10 | export type PropertyValue = any; 11 | export type PropertyKey = string | number | symbol; 12 | 13 | export type BaseEntity = Record; 14 | 15 | export type EntityConstructor = { 16 | new (...args: any[]): T; 17 | }; 18 | 19 | interface EntityOptions { 20 | key?: Key; 21 | datastore: Datastore; 22 | } 23 | 24 | export function Entity({ 25 | datastore, 26 | key, 27 | }: EntityOptions): (constructor: EntityConstructor) => any { 28 | return function ( 29 | constructor: EntityConstructor 30 | ): EntityConstructor { 31 | const entityMetadata: EntityMetadata = createEntityMetadata({ 32 | options: { datastore, key }, 33 | constructor, 34 | }); 35 | 36 | assertKeyNotInUse(constructor, entityMetadata, { 37 | getMetadatas: () => datastore.entityConstructors.map(getEntityMetadata), 38 | }); 39 | datastore.registerEntity(constructor); 40 | setEntityMetadata(constructor, entityMetadata); 41 | 42 | return constructor; 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/Entity/entityMetadata.ts: -------------------------------------------------------------------------------- 1 | import { Datastore, Key } from "../Datastore/Datastore"; 2 | import { BaseEntity, EntityConstructor } from "./Entity"; 3 | import { Metadata } from "../utils/metadata"; 4 | 5 | export interface EntityMetadata extends Metadata { 6 | datastore: Datastore; 7 | key: Key; 8 | } 9 | 10 | export const createEntityMetadata = ({ 11 | options: { datastore, key }, 12 | constructor, 13 | }: { 14 | options: { datastore: Datastore; key?: Key }; 15 | constructor: EntityConstructor; 16 | }): EntityMetadata => ({ 17 | datastore, 18 | key: key || constructor.name, 19 | }); 20 | -------------------------------------------------------------------------------- /src/Instruction/DeleteInstruction.test.ts: -------------------------------------------------------------------------------- 1 | import { DeleteInstruction } from "./DeleteInstruction"; 2 | import { Key } from "../Datastore/Datastore"; 3 | 4 | describe(`DeleteInstruction`, () => { 5 | let instruction: DeleteInstruction; 6 | 7 | beforeEach(() => { 8 | instruction = new DeleteInstruction((): Key => `key`); 9 | }); 10 | 11 | it(`should generate keys`, async () => { 12 | expect(instruction.key).toEqual(`key`); 13 | }); 14 | 15 | it(`should have a null value`, () => { 16 | expect(instruction.value).toBeNull(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/Instruction/DeleteInstruction.ts: -------------------------------------------------------------------------------- 1 | import { Key } from "../Datastore/Datastore"; 2 | import { WriteInstruction } from "./WriteInstruction"; 3 | 4 | export class DeleteInstruction extends WriteInstruction { 5 | public constructor(keyGenerator: () => Key) { 6 | super(keyGenerator, null); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Instruction/Instruction.ts: -------------------------------------------------------------------------------- 1 | import { Datastore, Key, Value } from "../Datastore/Datastore"; 2 | 3 | export abstract class Instruction { 4 | protected abstract keyGenerator: () => Key; 5 | public abstract value: Value; 6 | 7 | public abstract async perform(datastore: Datastore): Promise; 8 | 9 | public get key(): Key { 10 | return this.keyGenerator(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Instruction/WriteInstruction.test.ts: -------------------------------------------------------------------------------- 1 | import { WriteInstruction } from "./WriteInstruction"; 2 | import { Key } from "../Datastore/Datastore"; 3 | 4 | describe(`WriteInstruction`, () => { 5 | let instruction: WriteInstruction; 6 | 7 | beforeEach(() => { 8 | instruction = new WriteInstruction((): Key => `key`, `value`); 9 | }); 10 | 11 | it(`should generate keys`, async () => { 12 | expect(instruction.key).toEqual(`key`); 13 | }); 14 | 15 | it(`should hold values`, () => { 16 | expect(instruction.value).toEqual(`value`); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/Instruction/WriteInstruction.ts: -------------------------------------------------------------------------------- 1 | import { Instruction } from "./Instruction"; 2 | import { Datastore, Key, Value } from "../Datastore/Datastore"; 3 | 4 | export class WriteInstruction extends Instruction { 5 | protected keyGenerator: () => Key; 6 | public value: Value; 7 | 8 | public constructor(keyGenerator: () => Key, value: Value) { 9 | super(); 10 | this.keyGenerator = keyGenerator; 11 | this.value = value; 12 | } 13 | 14 | public async perform(datastore: Datastore): Promise { 15 | await datastore.write(this.key, this.value); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/MemoryDatastore/MemoryDatastore.test.ts: -------------------------------------------------------------------------------- 1 | import { MemoryDatastore } from "./MemoryDatastore"; 2 | import { Datastore } from "../Datastore/Datastore"; 3 | import { SearchStrategy } from "../Datastore/Datastore"; 4 | 5 | const readWriteWorks = async (datastore: Datastore): Promise => { 6 | await datastore.write(`key`, `value`); 7 | return (await datastore.read(`key`)) === `value`; 8 | }; 9 | 10 | describe(`MemoryDatastore`, () => { 11 | let datastore: Datastore; 12 | 13 | beforeEach(() => { 14 | datastore = new MemoryDatastore(); 15 | }); 16 | 17 | it(`can be initialized`, () => { 18 | expect(datastore).toBeInstanceOf(Datastore); 19 | expect(datastore).toBeInstanceOf(MemoryDatastore); 20 | }); 21 | 22 | it(`can be written to, and subsequently read from`, async () => { 23 | expect(await readWriteWorks(datastore)).toBeTruthy(); 24 | }); 25 | 26 | it(`can delete key-values`, async () => { 27 | expect(await readWriteWorks(datastore)).toBeTruthy(); 28 | await datastore.delete(`key`); 29 | expect(await datastore.read(`key`)).toBeNull(); 30 | }); 31 | 32 | describe(`search`, () => { 33 | const defaultSearchTerms = { 34 | term: `key`, 35 | strategy: SearchStrategy.prefix, 36 | }; 37 | 38 | beforeEach(async () => { 39 | for (let i = 0; i < 2003; i++) { 40 | await datastore.write(`key${i}`, `value${i}`); 41 | } 42 | }); 43 | 44 | it(`has a default limit of keys returned`, async () => { 45 | const results = await datastore.search(defaultSearchTerms); 46 | expect(results.keys.length).toBe(1000); 47 | expect(results.cursor).toBe(`999`); 48 | expect(results.hasNextPage).toBeTruthy(); 49 | }); 50 | 51 | it(`has limits the number of keys returned`, async () => { 52 | let results = await datastore.search({ 53 | ...defaultSearchTerms, 54 | first: 2000, 55 | }); 56 | expect(results.keys.length).toBe(1000); 57 | expect(results.cursor).toBe(`999`); 58 | expect(results.hasNextPage).toBeTruthy(); 59 | 60 | results = await datastore.search({ 61 | ...defaultSearchTerms, 62 | first: -99, 63 | }); 64 | expect(results.keys.length).toBe(0); 65 | expect(results.cursor).toBe(`-1`); 66 | expect(results.hasNextPage).toBeTruthy(); 67 | }); 68 | 69 | it(`can paginate`, async () => { 70 | const results = await datastore.search({ 71 | ...defaultSearchTerms, 72 | after: `3`, 73 | }); 74 | expect(results.keys).not.toContain(`key3`); 75 | expect(results.keys).toContain(`key4`); 76 | expect(results.keys).toContain(`key1003`); 77 | expect(results.keys).not.toContain(`key1004`); 78 | expect(results.keys.length).toBe(1000); 79 | expect(results.cursor).toBe(`1003`); 80 | expect(results.hasNextPage).toBeTruthy(); 81 | }); 82 | 83 | it(`hits the end of pagination correctly`, async () => { 84 | const results = await datastore.search({ 85 | ...defaultSearchTerms, 86 | after: `1999`, 87 | }); 88 | expect(results.keys).toContain(`key2000`); 89 | expect(results.keys).toContain(`key2001`); 90 | expect(results.keys).toContain(`key2002`); 91 | expect(results.keys.length).toBe(3); 92 | expect(results.cursor).toBe(`2002`); 93 | expect(results.hasNextPage).toBeFalsy(); 94 | }); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/MemoryDatastore/MemoryDatastore.ts: -------------------------------------------------------------------------------- 1 | import { Datastore, Value, Key } from "../Datastore/Datastore"; 2 | import { 3 | SearchStrategy, 4 | SearchOptions, 5 | SearchResult, 6 | } from "../Datastore/Datastore"; 7 | 8 | export class MemoryDatastore extends Datastore { 9 | private SEARCH_FIRST_LIMIT = 1000; 10 | private SEARCH_FIRST_DEFAULT = 1000; 11 | private data: Map = new Map(); // "remembers the original insertion order of the keys", https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map 12 | 13 | public searchStrategies = [SearchStrategy.prefix]; 14 | 15 | _read(key: Key): Promise { 16 | return Promise.resolve(this.data.get(key) || null); 17 | } 18 | 19 | _write(key: Key, value: Value): Promise { 20 | this.data.set(key, value); 21 | return Promise.resolve(); 22 | } 23 | 24 | _delete(key: Key): Promise { 25 | this.data.delete(key); 26 | return Promise.resolve(); 27 | } 28 | 29 | _search({ 30 | term, 31 | first = this.SEARCH_FIRST_DEFAULT, 32 | after = `-1`, 33 | }: SearchOptions): Promise { 34 | if (first > this.SEARCH_FIRST_LIMIT) first = this.SEARCH_FIRST_LIMIT; 35 | if (first < 0) first = 0; 36 | 37 | let keys = Array.from(this.data.keys()).filter((key: Key) => 38 | key.startsWith(term) 39 | ); 40 | 41 | keys = keys.slice(+after + 1); 42 | 43 | const hasNextPage = keys.length > first; 44 | 45 | keys = keys.slice(0, first); 46 | 47 | const cursor = (+after + (hasNextPage ? first : keys.length)).toString(); 48 | 49 | return Promise.resolve({ 50 | keys, 51 | hasNextPage, 52 | cursor, 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Relationship/ToMany.test.ts: -------------------------------------------------------------------------------- 1 | import { Datastore, Value, SearchStrategy } from "../Datastore/Datastore"; 2 | import { BaseEntity, Entity, EntityConstructor } from "../Entity/Entity"; 3 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 4 | import { ToMany } from "./ToMany"; 5 | import { Column } from "../Column/Column"; 6 | import { getRepository, Repository } from "../Repository/Repository"; 7 | import { SearchResult } from "../Datastore/Datastore"; 8 | import { SearchStrategyError } from "../Datastore/SearchStrategyError"; 9 | import { ToManyChild } from "./toManyChild.testhelpers"; 10 | import { ToManyParent } from "./toManyParent.testhelpers"; 11 | import { ToOneChild } from "./toOneChild.testhelpers"; 12 | import { ToOneParent } from "./toOneParent.testhelpers"; 13 | 14 | describe(`ToMany`, () => { 15 | let childInstance: BaseEntity; 16 | let childEntityConstructor: EntityConstructor; 17 | let otherChildInstance: BaseEntity; 18 | let parentInstance: BaseEntity; 19 | 20 | let childRepository: Repository; 21 | let parentRepository: Repository; 22 | 23 | beforeEach(async () => { 24 | childRepository = getRepository(ToOneChild); 25 | parentRepository = getRepository(ToOneParent); 26 | 27 | childInstance = new ToOneChild("child"); 28 | otherChildInstance = new ToOneChild("child2"); 29 | await childRepository.save(childInstance); 30 | parentInstance = new ToOneParent("parent"); 31 | }); 32 | 33 | // it(`can save and load a relationship to a non-singleton entity`, async () => { 34 | // parentInstance.myProperty = [childInstance, otherChildInstance]; 35 | // await parentRepository.save(parentInstance); 36 | 37 | // const loadedRelations = await parentInstance.myProperty; 38 | 39 | // const expectedIDs = [`child`, "child2"]; 40 | // let i = 0; 41 | // for await (const loadedRelation of loadedRelations) { 42 | // expect(await loadedRelation.id).toEqual(expectedIDs[i]); 43 | // i++; 44 | // } 45 | // expect(i).toBe(2); 46 | // }); 47 | 48 | // it(`can save and load a relationship to a singleton entity`, async () => { 49 | // singletonParentInstance.myProperty = [singletonInstance, singletonInstance]; 50 | // await singletonParentRepository.save(singletonParentInstance); 51 | 52 | // const loadedRelations = await singletonParentInstance.myProperty; 53 | 54 | // let i = 0; 55 | // for await (const loadedRelation of loadedRelations) { 56 | // expect(await loadedRelation.constantProperty).toEqual(`Never change!`); 57 | // i++; 58 | // } 59 | // expect(i).toBe(2); 60 | // }); 61 | 62 | describe(`SearchStrategyError`, () => { 63 | class UselessDatastore extends Datastore { 64 | public searchStrategies = []; 65 | 66 | protected _read(): Promise { 67 | throw new Error(`Method not implemented.`); 68 | } 69 | 70 | protected _write(): Promise { 71 | throw new Error(`Method not implemented.`); 72 | } 73 | 74 | protected _delete(): Promise { 75 | throw new Error(`Method not implemented.`); 76 | } 77 | 78 | protected _search(): Promise { 79 | throw new Error(`Method not implemented.`); 80 | } 81 | } 82 | 83 | const uselessDatastore = new UselessDatastore(); 84 | 85 | @Entity({ datastore: uselessDatastore }) 86 | class UselessEntity { 87 | @ToMany({ type: () => childEntityConstructor, backRef: "" }) 88 | relations = undefined; 89 | } 90 | 91 | const instance = new UselessEntity(); 92 | 93 | it(`is thrown when using an unsupported Datastore`, async () => { 94 | await expect( 95 | (async (): Promise => { 96 | const relations = ((await instance.relations) as unknown) as { 97 | next: () => Promise; 98 | }; 99 | await relations.next(); 100 | })() 101 | ).rejects.toThrow(SearchStrategyError); 102 | }); 103 | 104 | it(`is thrown wnen trying to manually search the datastore`, async () => { 105 | await expect( 106 | (async (): Promise => { 107 | await uselessDatastore.search({ 108 | strategy: SearchStrategy.prefix, 109 | term: `test`, 110 | }); 111 | })() 112 | ).rejects.toThrow(SearchStrategyError); 113 | }); 114 | }); 115 | }); 116 | 117 | describe("backRefs on ToMany", () => { 118 | it("updates ToManyChild", async () => { 119 | const toManyChild = new ToManyChild("child"); 120 | const toManyParent = new ToManyParent("parent"); 121 | toManyParent.toManyChild = [toManyChild]; 122 | await getRepository(ToManyParent).save(toManyParent); 123 | 124 | const relationChildInstances = await toManyParent.toManyChild; 125 | let i = 0; 126 | for await (const relationInstance of relationChildInstances) { 127 | expect(relationInstance).toBe(toManyChild); 128 | i++; 129 | } 130 | expect(i).toBe(1); 131 | 132 | const relationParentInstances = await toManyChild.toManyParent; 133 | i = 0; 134 | for await (const relationInstance of relationParentInstances) { 135 | expect(relationInstance).toBe(toManyParent); 136 | i++; 137 | } 138 | expect(i).toBe(1); 139 | }); 140 | 141 | it("updates ToManyParent", async () => { 142 | const toManyChild = new ToManyChild("child"); 143 | const toManyParent = new ToManyParent("parent"); 144 | toManyChild.toManyParent = [toManyParent]; 145 | 146 | const relationParentInstances = await toManyChild.toManyParent; 147 | let i = 0; 148 | for await (const relationInstance of relationParentInstances) { 149 | expect(relationInstance).toBe(toManyParent); 150 | i++; 151 | } 152 | expect(i).toBe(1); 153 | 154 | const relationChildInstances = await toManyParent.toManyChild; 155 | i = 0; 156 | for await (const relationInstance of relationChildInstances) { 157 | expect(relationInstance).toBe(toManyChild); 158 | i++; 159 | } 160 | expect(i).toBe(1); 161 | }); 162 | 163 | it("updates ToOneChild", async () => { 164 | const toOneChild = new ToOneChild("child"); 165 | const toManyParent = new ToManyParent("parent"); 166 | toManyParent.toOneChild = toOneChild; 167 | expect(await toManyParent.toOneChild).toBe(toOneChild); 168 | 169 | const relationInstances = await toOneChild.toManyParent; 170 | let i = 0; 171 | for await (const relationInstance of relationInstances) { 172 | expect(relationInstance).toBe(toManyParent); 173 | i++; 174 | } 175 | expect(i).toBe(1); 176 | }); 177 | 178 | it("updates ToOneParent", async () => { 179 | const toManyChild = new ToManyChild("child"); 180 | const toOneParent = new ToOneParent("parent"); 181 | toOneParent.toManyChild = [toManyChild]; 182 | expect(await toManyChild.toOneParent).toBe(toOneParent); 183 | 184 | const relationInstances = await toOneParent.toManyChild; 185 | let i = 0; 186 | for await (const relationInstance of relationInstances) { 187 | expect(relationInstance).toBe(toManyChild); 188 | i++; 189 | } 190 | expect(i).toBe(1); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /src/Relationship/ToMany.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity, EntityConstructor } from "../Entity/Entity"; 2 | import { PropertyKey } from "../Entity/Entity"; 3 | import { Key } from "../Datastore/Datastore"; 4 | import { getConstructor } from "../utils/entities"; 5 | import { toManySet } from "./toManySet"; 6 | import { createToManyRelationshipMetadata } from "./relationshipMetadata"; 7 | import { setToManyRelationshipMetadata } from "../utils/relationships"; 8 | import { toManyGet } from "./toManyGet"; 9 | import { assertKeyNotInUse, getPropertyMetadatas } from "../utils/metadata"; 10 | 11 | interface ToManyOptions { 12 | key?: Key; 13 | type: () => EntityConstructor; 14 | cascade?: boolean; 15 | backRef: PropertyKey; 16 | } 17 | 18 | export function ToMany(options: ToManyOptions) { 19 | return (instance: BaseEntity, property: PropertyKey): void => { 20 | const toManyRelationshipMetadata = createToManyRelationshipMetadata({ 21 | options, 22 | property, 23 | }); 24 | 25 | const constructor = getConstructor(instance); 26 | assertKeyNotInUse(constructor, toManyRelationshipMetadata, { 27 | getMetadatas: getPropertyMetadatas, 28 | }); 29 | setToManyRelationshipMetadata(constructor, toManyRelationshipMetadata); 30 | 31 | Reflect.defineProperty(instance, property, { 32 | enumerable: true, 33 | configurable: true, 34 | get: function get(this: BaseEntity) { 35 | return toManyGet(this, toManyRelationshipMetadata); 36 | }, 37 | set: function set(this: BaseEntity, values: BaseEntity[]) { 38 | if (values) toManySet(this, toManyRelationshipMetadata, values); 39 | }, 40 | }); 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/Relationship/ToOne.test.ts: -------------------------------------------------------------------------------- 1 | import { Datastore } from "../Datastore/Datastore"; 2 | import { BaseEntity, Entity } from "../Entity/Entity"; 3 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 4 | import { ToOne } from "./ToOne"; 5 | import { Column } from "../Column/Column"; 6 | import { getRepository, Repository } from "../Repository/Repository"; 7 | import { ToOneChild } from "./toOneChild.testhelpers"; 8 | import { ToOneParent } from "./toOneParent.testhelpers"; 9 | import { ToManyParent } from "./toManyParent.testhelpers"; 10 | import { ToManyChild } from "./toManyChild.testhelpers"; 11 | 12 | describe(`ToOne`, () => { 13 | let childInstance: BaseEntity; 14 | let parentInstance: BaseEntity; 15 | 16 | let childRepository: Repository; 17 | let parentRepository: Repository; 18 | 19 | beforeEach(async () => { 20 | childRepository = getRepository(ToOneChild); 21 | parentRepository = getRepository(ToOneParent); 22 | 23 | childInstance = new ToOneChild("child"); 24 | await childRepository.save(childInstance); 25 | parentInstance = new ToOneParent("parent"); 26 | }); 27 | 28 | it("returns the existing instance on get", async () => { 29 | parentInstance.myProperty = childInstance; 30 | expect(await parentInstance.myProperty).toBe(childInstance); 31 | }); 32 | 33 | it(`can save and load a relationship to a non-singleton entity`, async () => { 34 | parentInstance.myProperty = childInstance; 35 | await parentRepository.save(parentInstance); 36 | 37 | const loadedRelation = await parentInstance.myProperty; 38 | expect(await loadedRelation.id).toEqual(await childInstance.id); 39 | }); 40 | }); 41 | 42 | describe("backRefs on ToOne", () => { 43 | it("updates ToOneChild", async () => { 44 | const toOneChild = new ToOneChild("child"); 45 | const toOneParent = new ToOneParent("parent"); 46 | toOneParent.toOneChild = toOneChild; 47 | expect(await toOneParent.toOneChild).toBe(toOneChild); 48 | expect(await toOneChild.toOneParent).toBe(toOneParent); 49 | }); 50 | 51 | it("updates ToOneParent", async () => { 52 | const toOneChild = new ToOneChild("child"); 53 | const toOneParent = new ToOneParent("parent"); 54 | toOneChild.toOneParent = toOneParent; 55 | expect(await toOneChild.toOneParent).toBe(toOneParent); 56 | expect(await toOneParent.toOneChild).toBe(toOneChild); 57 | }); 58 | 59 | it("updates ToManyChild", async () => { 60 | const toManyChild = new ToManyChild("child"); 61 | const toOneParent = new ToOneParent("parent"); 62 | toOneParent.toManyChild = [toManyChild]; 63 | expect(await toManyChild.toOneParent).toBe(toOneParent); 64 | 65 | const relationInstances = await toOneParent.toManyChild; 66 | let i = 0; 67 | for await (const relationInstance of relationInstances) { 68 | expect(relationInstance).toBe(toManyChild); 69 | i++; 70 | } 71 | expect(i).toBe(1); 72 | }); 73 | 74 | it("updates ToManyParent", async () => { 75 | const toOneChild = new ToOneChild("child"); 76 | const toManyParent = new ToManyParent("parent"); 77 | toManyParent.toOneChild = toOneChild; 78 | expect(await toManyParent.toOneChild).toBe(toOneChild); 79 | 80 | const relationInstances = await toOneChild.toManyParent; 81 | let i = 0; 82 | for await (const relationInstance of relationInstances) { 83 | expect(relationInstance).toBe(toManyParent); 84 | i++; 85 | } 86 | expect(i).toBe(1); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/Relationship/ToOne.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity, EntityConstructor } from "../Entity/Entity"; 2 | import { PropertyKey } from "../Entity/Entity"; 3 | import { Key } from "../Datastore/Datastore"; 4 | import { getConstructor } from "../utils/entities"; 5 | import { toOneSet } from "./toOneSet"; 6 | import { 7 | createToOneRelationshipMetadata, 8 | RelationshipOptions, 9 | } from "./relationshipMetadata"; 10 | import { setToOneRelationshipMetadata } from "../utils/relationships"; 11 | import { toOneGet } from "./toOneGet"; 12 | import { assertKeyNotInUse, getPropertyMetadatas } from "../utils/metadata"; 13 | 14 | interface ToOneOptions { 15 | key?: Key; 16 | type: () => EntityConstructor; 17 | cascade?: boolean; 18 | backRef: PropertyKey; 19 | } 20 | export function ToOne(options: ToOneOptions) { 21 | return (instance: BaseEntity, property: PropertyKey): void => { 22 | const toOneRelationshipMetadata = createToOneRelationshipMetadata({ 23 | options, 24 | property, 25 | }); 26 | 27 | const constructor = getConstructor(instance); 28 | assertKeyNotInUse(constructor, toOneRelationshipMetadata, { 29 | getMetadatas: getPropertyMetadatas, 30 | }); 31 | setToOneRelationshipMetadata(constructor, toOneRelationshipMetadata); 32 | 33 | Reflect.defineProperty(instance, property, { 34 | enumerable: true, 35 | configurable: true, 36 | get: async function get(this: BaseEntity) { 37 | return toOneGet(this, toOneRelationshipMetadata); 38 | }, 39 | set: function set(this: BaseEntity, value: BaseEntity) { 40 | if (value) toOneSet(this, toOneRelationshipMetadata, value); 41 | }, 42 | }); 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/Relationship/addTo.ts: -------------------------------------------------------------------------------- 1 | import { getConstructorDatastoreCache } from "../utils/entities"; 2 | import { BaseEntity } from "../Entity/Entity"; 3 | import { 4 | getToManyRelationshipMetadata, 5 | setToManyRelationshipMetadata, 6 | } from "../utils/relationships"; 7 | import { Key } from "../Datastore/Datastore"; 8 | import { 9 | generateManyRelationshipKey, 10 | generateRelationshipKey, 11 | } from "../utils/keyGeneration"; 12 | 13 | export const addTo = ( 14 | instance: BaseEntity, 15 | property: PropertyKey, 16 | value: BaseEntity 17 | ) => { 18 | const { constructor, cache } = getConstructorDatastoreCache(instance); 19 | const toManyRelationshipMetadata = getToManyRelationshipMetadata( 20 | constructor, 21 | property 22 | ); 23 | 24 | const keyGenerator = (): Key => 25 | generateManyRelationshipKey(instance, toManyRelationshipMetadata, value); 26 | cache.write(instance, keyGenerator, generateRelationshipKey(value)); 27 | 28 | const cachedRelationshipInstances = [ 29 | ...(toManyRelationshipMetadata.instances.get(instance) || []), 30 | value, 31 | ]; 32 | toManyRelationshipMetadata.instances.set( 33 | instance, 34 | cachedRelationshipInstances 35 | ); 36 | setToManyRelationshipMetadata(constructor, toManyRelationshipMetadata); 37 | }; 38 | -------------------------------------------------------------------------------- /src/Relationship/relationshipMetadata.ts: -------------------------------------------------------------------------------- 1 | import { Key } from "../Datastore/Datastore"; 2 | import { PropertyKey, BaseEntity } from "../Entity/Entity"; 3 | import { EntityConstructor } from "../Entity/Entity"; 4 | import { Metadata } from "../utils/metadata"; 5 | 6 | export const TOONE_RELATIONSHIP_KEY = Symbol(`ToOneRelationship`); 7 | export const TOMANY_RELATIONSHIP_KEY = Symbol(`ToManyRelationship`); 8 | 9 | export interface RelationshipMetadata extends Metadata { 10 | key: Key; 11 | property: PropertyKey; 12 | type: () => EntityConstructor; 13 | cascade: { onUpdate: boolean; onDelete: boolean }; 14 | backRef: PropertyKey; 15 | cardinality: "ToOne" | "ToMany"; 16 | } 17 | 18 | export interface ToOneRelationshipMetadata extends RelationshipMetadata { 19 | instance: Map; 20 | cardinality: "ToOne"; 21 | } 22 | 23 | export interface ToManyRelationshipMetadata extends RelationshipMetadata { 24 | instances: Map; 25 | cardinality: "ToMany"; 26 | } 27 | 28 | export interface RelationshipOptions { 29 | key?: Key; 30 | type: () => EntityConstructor; 31 | cascade?: boolean; 32 | backRef: PropertyKey; 33 | } 34 | 35 | export const createToOneRelationshipMetadata = ({ 36 | options: { key, type, cascade, backRef }, 37 | property, 38 | }: { 39 | options: RelationshipOptions; 40 | property: PropertyKey; 41 | }): ToOneRelationshipMetadata => ({ 42 | key: key || property.toString(), 43 | property, 44 | type, 45 | cascade: cascade 46 | ? { onUpdate: true, onDelete: true } 47 | : { onUpdate: false, onDelete: false }, 48 | backRef, 49 | instance: new Map(), 50 | cardinality: "ToOne", 51 | }); 52 | 53 | export const createToManyRelationshipMetadata = ({ 54 | options: { key, type, cascade, backRef }, 55 | property, 56 | }: { 57 | options: RelationshipOptions; 58 | property: PropertyKey; 59 | }): ToManyRelationshipMetadata => ({ 60 | key: key || property.toString(), 61 | property, 62 | type, 63 | cascade: cascade 64 | ? { onUpdate: true, onDelete: true } 65 | : { onUpdate: false, onDelete: false }, 66 | backRef, 67 | instances: new Map(), 68 | cardinality: "ToMany", 69 | }); 70 | -------------------------------------------------------------------------------- /src/Relationship/removeFrom.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity, PropertyKey } from "../Entity/Entity"; 2 | import { 3 | getToManyRelationshipMetadata, 4 | setToManyRelationshipMetadata, 5 | } from "../utils/relationships"; 6 | import { getConstructorDatastoreCache } from "../utils/entities"; 7 | import { Key } from "../Datastore/Datastore"; 8 | import { generateManyRelationshipKey } from "../utils/keyGeneration"; 9 | 10 | export const removeFrom = ( 11 | instance: BaseEntity, 12 | property: PropertyKey, 13 | value: BaseEntity 14 | ) => { 15 | const { constructor, cache } = getConstructorDatastoreCache(instance); 16 | const toManyRelationshipMetadata = getToManyRelationshipMetadata( 17 | constructor, 18 | property 19 | ); 20 | 21 | const keyGenerator = (): Key => 22 | generateManyRelationshipKey(instance, toManyRelationshipMetadata, value); 23 | cache.delete(instance, keyGenerator); 24 | 25 | const cachedRelationshipInstances = 26 | toManyRelationshipMetadata.instances.get(instance) || []; 27 | const indexOfInstance = cachedRelationshipInstances.indexOf(value); 28 | if (indexOfInstance !== -1) { 29 | cachedRelationshipInstances.splice(indexOfInstance, 1); 30 | toManyRelationshipMetadata.instances.set( 31 | instance, 32 | cachedRelationshipInstances 33 | ); 34 | setToManyRelationshipMetadata(constructor, toManyRelationshipMetadata); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/Relationship/toManyChild.testhelpers.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from "../Entity/Entity"; 2 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 3 | import { PrimaryColumn } from "../Column/PrimaryColumn"; 4 | import { ToOne } from "./ToOne"; 5 | import { ToOneParent } from "./toOneParent.testhelpers"; 6 | import { ToManyParent } from "./toManyParent.testhelpers"; 7 | import { toManyType } from "../types/toManyType"; 8 | import { toOneType } from "../types/toOneType"; 9 | import { ToMany } from "./ToMany"; 10 | 11 | const datastore = new MemoryDatastore(); 12 | 13 | @Entity({ datastore }) 14 | class ToManyChild { 15 | @PrimaryColumn() 16 | public id: string; 17 | 18 | @ToOne({ type: () => ToOneParent, backRef: "toManyChild", cascade: true }) 19 | public toOneParent?: toOneType = undefined; 20 | 21 | @ToMany({ type: () => ToManyParent, backRef: "toManyChild", cascade: true }) 22 | public toManyParent: toManyType = []; 23 | 24 | constructor(id: string) { 25 | this.id = id; 26 | } 27 | } 28 | 29 | export { ToManyChild }; 30 | -------------------------------------------------------------------------------- /src/Relationship/toManyGet.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity } from "../Entity/Entity"; 2 | import { getConstructor } from "../utils/entities"; 3 | import { getDatastore } from "../utils/datastore"; 4 | import { generateManyRelationshipSearchKey } from "../utils/keyGeneration"; 5 | import { ToManyRelationshipMetadata } from "./relationshipMetadata"; 6 | import { hydrateMany, getHydrator } from "../utils/hydrate"; 7 | import { 8 | arrayToAsyncGenerator, 9 | makeCachingGenerator, 10 | combineAsyncGenerators, 11 | } from "../utils/relationships"; 12 | import { getPrimaryColumnValue } from "../utils/columns"; 13 | 14 | export const toManyGet = ( 15 | instance: BaseEntity, 16 | toManyRelationshipMetadata: ToManyRelationshipMetadata 17 | ): AsyncGenerator => { 18 | const cachedRelationshipInstances = 19 | toManyRelationshipMetadata.instances.get(instance) || []; 20 | const primaryColumnValueInstances = cachedRelationshipInstances.map( 21 | (instance) => getPrimaryColumnValue(instance) 22 | ); 23 | 24 | const constructor = getConstructor(instance); 25 | const datastore = getDatastore(constructor); 26 | const hydrator = getHydrator(toManyRelationshipMetadata.type()); 27 | 28 | const searchKey = generateManyRelationshipSearchKey( 29 | instance, 30 | toManyRelationshipMetadata 31 | ); 32 | 33 | const cachingGenerator = makeCachingGenerator( 34 | instance, 35 | toManyRelationshipMetadata 36 | ); 37 | 38 | return combineAsyncGenerators( 39 | arrayToAsyncGenerator(cachedRelationshipInstances), 40 | cachingGenerator( 41 | hydrateMany(datastore, searchKey, hydrator, { 42 | skip: (identifier) => primaryColumnValueInstances.includes(identifier), 43 | }) 44 | ) 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/Relationship/toManyParent.testhelpers.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from "../Entity/Entity"; 2 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 3 | import { PrimaryColumn } from "../Column/PrimaryColumn"; 4 | import { ToOne } from "./ToOne"; 5 | import { ToMany } from "./ToMany"; 6 | import { toManyType } from "../types/toManyType"; 7 | import { toOneType } from "../types/toOneType"; 8 | import { ToOneChild } from "./toOneChild.testhelpers"; 9 | import { ToManyChild } from "./toManyChild.testhelpers"; 10 | 11 | const datastore = new MemoryDatastore(); 12 | 13 | @Entity({ datastore }) 14 | class ToManyParent { 15 | @PrimaryColumn() 16 | public id: string; 17 | 18 | @ToOne({ type: () => ToOneChild, backRef: "toManyParent", cascade: true }) 19 | public toOneChild?: toOneType = undefined; 20 | 21 | @ToMany({ type: () => ToManyChild, backRef: "toManyParent", cascade: true }) 22 | public toManyChild: toManyType = []; 23 | 24 | constructor(id: string) { 25 | this.id = id; 26 | } 27 | } 28 | 29 | export { ToManyParent }; 30 | -------------------------------------------------------------------------------- /src/Relationship/toManySet.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity } from "../Entity/Entity"; 2 | import { Key } from "../Datastore/Datastore"; 3 | import { getConstructorDatastoreCache } from "../utils/entities"; 4 | import { 5 | generateManyRelationshipKey, 6 | generateRelationshipKey, 7 | } from "../utils/keyGeneration"; 8 | import { 9 | ToManyRelationshipMetadata, 10 | ToOneRelationshipMetadata, 11 | } from "./relationshipMetadata"; 12 | import { 13 | setToManyRelationshipMetadata, 14 | getRelationshipMetadata, 15 | } from "../utils/relationships"; 16 | import { toOneSet } from "./toOneSet"; 17 | import { addTo } from "./addTo"; 18 | 19 | export const toManySet = async ( 20 | instance: BaseEntity, 21 | toManyRelationshipMetadata: ToManyRelationshipMetadata, 22 | values: BaseEntity[], 23 | { skipBackRef }: { skipBackRef: boolean } = { skipBackRef: false } 24 | ): Promise => { 25 | const { constructor, cache } = getConstructorDatastoreCache(instance); 26 | 27 | for (const value of values) { 28 | const keyGenerator = (): Key => 29 | generateManyRelationshipKey(instance, toManyRelationshipMetadata, value); 30 | cache.write(instance, keyGenerator, generateRelationshipKey(value)); 31 | } 32 | 33 | toManyRelationshipMetadata.instances.set(instance, values); 34 | setToManyRelationshipMetadata(constructor, toManyRelationshipMetadata); 35 | 36 | if (!skipBackRef) { 37 | const relationshipConstructor = toManyRelationshipMetadata.type(); 38 | const relationshipRelationshipMetadata = getRelationshipMetadata( 39 | relationshipConstructor, 40 | toManyRelationshipMetadata.backRef 41 | ); 42 | 43 | switch (relationshipRelationshipMetadata.cardinality) { 44 | case "ToOne": 45 | for (const value of values) { 46 | toOneSet( 47 | value, 48 | relationshipRelationshipMetadata as ToOneRelationshipMetadata, 49 | instance, 50 | { 51 | skipBackRef: true, 52 | } 53 | ); 54 | } 55 | break; 56 | case "ToMany": 57 | for (const value of values) { 58 | addTo(value, toManyRelationshipMetadata.backRef, instance); 59 | } 60 | break; 61 | } 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /src/Relationship/toOneChild.testhelpers.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from "../Entity/Entity"; 2 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 3 | import { PrimaryColumn } from "../Column/PrimaryColumn"; 4 | import { ToOne } from "./ToOne"; 5 | import { ToOneParent } from "./toOneParent.testhelpers"; 6 | import { ToManyParent } from "./toManyParent.testhelpers"; 7 | import { toManyType } from "../types/toManyType"; 8 | import { toOneType } from "../types/toOneType"; 9 | import { ToMany } from "./ToMany"; 10 | 11 | const datastore = new MemoryDatastore(); 12 | 13 | @Entity({ datastore }) 14 | class ToOneChild { 15 | @PrimaryColumn() 16 | public id: string; 17 | 18 | @ToOne({ type: () => ToOneParent, backRef: "toOneChild", cascade: true }) 19 | public toOneParent?: toOneType = undefined; 20 | 21 | @ToMany({ type: () => ToManyParent, backRef: "toOneChild", cascade: true }) 22 | public toManyParent: toManyType = []; 23 | 24 | constructor(id: string) { 25 | this.id = id; 26 | } 27 | } 28 | 29 | export { ToOneChild }; 30 | -------------------------------------------------------------------------------- /src/Relationship/toOneGet.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity } from "../Entity/Entity"; 2 | import { getConstructorDatastoreCache } from "../utils/entities"; 3 | import { generateOneRelationshipKey } from "../utils/keyGeneration"; 4 | import { ToOneRelationshipMetadata } from "./relationshipMetadata"; 5 | import { getHydrator } from "../utils/hydrate"; 6 | import { setToOneRelationshipMetadata } from "../utils/relationships"; 7 | 8 | export const toOneGet = async ( 9 | instance: BaseEntity, 10 | toOneRelationshipMetadata: ToOneRelationshipMetadata 11 | ): Promise => { 12 | const cachedRelationshipInstance = toOneRelationshipMetadata.instance.get( 13 | instance 14 | ); 15 | if (cachedRelationshipInstance) return cachedRelationshipInstance; 16 | 17 | const { constructor, cache } = getConstructorDatastoreCache(instance); 18 | const hydrator = getHydrator(toOneRelationshipMetadata.type()); 19 | 20 | const key = generateOneRelationshipKey(instance, toOneRelationshipMetadata); 21 | const identifier = await cache.read(instance, key); 22 | const relationshipInstance = await hydrator(identifier); 23 | 24 | toOneRelationshipMetadata.instance.set(instance, relationshipInstance); 25 | setToOneRelationshipMetadata(constructor, toOneRelationshipMetadata); 26 | 27 | return relationshipInstance; 28 | }; 29 | -------------------------------------------------------------------------------- /src/Relationship/toOneParent.testhelpers.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from "../Entity/Entity"; 2 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 3 | import { PrimaryColumn } from "../Column/PrimaryColumn"; 4 | import { ToOne } from "./ToOne"; 5 | import { ToMany } from "./ToMany"; 6 | import { toManyType } from "../types/toManyType"; 7 | import { toOneType } from "../types/toOneType"; 8 | import { ToOneChild } from "./toOneChild.testhelpers"; 9 | import { ToManyChild } from "./toManyChild.testhelpers"; 10 | 11 | const datastore = new MemoryDatastore(); 12 | 13 | @Entity({ datastore }) 14 | class ToOneParent { 15 | @PrimaryColumn() 16 | public id: string; 17 | 18 | @ToOne({ type: () => ToOneChild, backRef: "toOneParent", cascade: true }) 19 | public toOneChild?: toOneType = undefined; 20 | 21 | @ToMany({ type: () => ToManyChild, backRef: "toOneParent", cascade: true }) 22 | public toManyChild: toManyType = []; 23 | 24 | constructor(id: string) { 25 | this.id = id; 26 | } 27 | } 28 | 29 | export { ToOneParent }; 30 | -------------------------------------------------------------------------------- /src/Relationship/toOneSet.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity } from "../Entity/Entity"; 2 | import { Key } from "../Datastore/Datastore"; 3 | import { 4 | getConstructorDatastoreCache, 5 | getConstructor, 6 | } from "../utils/entities"; 7 | import { 8 | generateRelationshipKey, 9 | generateOneRelationshipKey, 10 | } from "../utils/keyGeneration"; 11 | import { 12 | ToOneRelationshipMetadata, 13 | ToManyRelationshipMetadata, 14 | } from "./relationshipMetadata"; 15 | import { 16 | setToOneRelationshipMetadata, 17 | getToOneRelationshipMetadata, 18 | getRelationshipMetadata, 19 | } from "../utils/relationships"; 20 | import { addTo } from "./addTo"; 21 | 22 | export const toOneSet = ( 23 | instance: BaseEntity, 24 | toOneRelationshipMetadata: ToOneRelationshipMetadata, 25 | value: BaseEntity, 26 | { skipBackRef }: { skipBackRef: boolean } = { skipBackRef: false } 27 | ): void => { 28 | const { constructor, cache } = getConstructorDatastoreCache(instance); 29 | 30 | const keyGenerator = (): Key => 31 | generateOneRelationshipKey(instance, toOneRelationshipMetadata); 32 | cache.write(instance, keyGenerator, generateRelationshipKey(value)); 33 | 34 | toOneRelationshipMetadata.instance.set(instance, value); 35 | setToOneRelationshipMetadata(constructor, toOneRelationshipMetadata); 36 | 37 | if (!skipBackRef) { 38 | const relationshipConstructor = toOneRelationshipMetadata.type(); 39 | const relationshipRelationshipMetadata = getRelationshipMetadata( 40 | relationshipConstructor, 41 | toOneRelationshipMetadata.backRef 42 | ); 43 | 44 | switch (relationshipRelationshipMetadata.cardinality) { 45 | case "ToOne": 46 | toOneSet( 47 | value, 48 | relationshipRelationshipMetadata as ToOneRelationshipMetadata, 49 | instance, 50 | { 51 | skipBackRef: true, 52 | } 53 | ); 54 | break; 55 | case "ToMany": 56 | addTo(value, toOneRelationshipMetadata.backRef, instance); 57 | break; 58 | } 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /src/Repository/ColumnNotFindableError.ts: -------------------------------------------------------------------------------- 1 | import { KVORMError } from "../utils/errors"; 2 | import { EntityConstructor } from "../Entity/Entity"; 3 | import { ColumnMetadata } from "../Column/columnMetadata"; 4 | 5 | export class ColumnNotFindableError extends KVORMError { 6 | constructor( 7 | constructor: EntityConstructor, 8 | columnMetadata: ColumnMetadata, 9 | message = `Unknown Error` 10 | ) { 11 | super( 12 | `The Column, ${columnMetadata.property.toString()}, on Entity, ${ 13 | constructor.name 14 | } cannot be used to find: ${message}` 15 | ); 16 | this.name = `ColumnNotFindableError`; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Repository/ColumnNotSearchableError.ts: -------------------------------------------------------------------------------- 1 | import { KVORMError } from "../utils/errors"; 2 | import { EntityConstructor } from "../Entity/Entity"; 3 | import { ColumnMetadata } from "../Column/columnMetadata"; 4 | 5 | export class ColumnNotSearchableError extends KVORMError { 6 | constructor( 7 | constructor: EntityConstructor, 8 | columnMetadata: ColumnMetadata, 9 | message = `Unknown Error` 10 | ) { 11 | super( 12 | `The Column, ${columnMetadata.property.toString()}, on Entity, ${ 13 | constructor.name 14 | } cannot be searched with: ${message}` 15 | ); 16 | this.name = `ColumnNotSearchableError`; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Repository/EntityNotFoundError.ts: -------------------------------------------------------------------------------- 1 | import { KVORMError } from "../utils/errors"; 2 | import { Value } from "../Datastore/Datastore"; 3 | import { EntityConstructor } from "../Entity/Entity"; 4 | 5 | export class EntityNotFoundError extends KVORMError { 6 | constructor(constructor: EntityConstructor, identifier?: Value) { 7 | super( 8 | `Could not find an Entity, ${constructor.name}, with PrimaryColumn identifier, ${identifier} in Datastore` 9 | ); 10 | this.name = `EntityNotFoundError`; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Repository/Repository.test.ts: -------------------------------------------------------------------------------- 1 | import { Datastore } from "../Datastore/Datastore"; 2 | import { BaseEntity, Entity } from "../Entity/Entity"; 3 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 4 | import { Column } from "../Column/Column"; 5 | import { Repository, getRepository } from "./Repository"; 6 | import { RepositoryLoadError } from "./RepositoryLoadError"; 7 | import { ColumnLookupError } from "../utils/errors"; 8 | import { EntityNotFoundError } from "./EntityNotFoundError"; 9 | import { ColumnNotSearchableError } from "./ColumnNotSearchableError"; 10 | import { ColumnNotFindableError } from "./ColumnNotFindableError"; 11 | import { RepositoryFindError } from "./RepositoryFindError"; 12 | 13 | describe(`Repository`, () => { 14 | let datastore: Datastore; 15 | let singletonInstance: BaseEntity; 16 | let singletonRepository: Repository; 17 | let complexInstance: BaseEntity; 18 | let complexRepository: Repository; 19 | 20 | beforeEach(() => { 21 | datastore = new MemoryDatastore(); 22 | 23 | @Entity({ datastore, key: `SingletonEntity` }) 24 | class SingletonEntity { 25 | @Column({ key: `myProperty`, isIndexable: true }) 26 | public myProperty = `initial value`; 27 | } 28 | 29 | @Entity({ datastore, key: `ComplexEntity` }) 30 | class ComplexEntity { 31 | @Column({ key: `myProperty` }) 32 | public myProperty = `initial value`; 33 | 34 | @Column({ key: `primaryProperty`, isPrimary: true }) 35 | public primaryProperty: number; 36 | 37 | @Column({ 38 | key: `indexableUniqueProperty`, 39 | isIndexable: true, 40 | isUnique: true, 41 | }) 42 | public indexableUniqueProperty: string; 43 | 44 | @Column({ 45 | key: `indexableProperty`, 46 | isIndexable: true, 47 | }) 48 | public indexableProperty: string; 49 | 50 | @Column() 51 | public arrayProperty: number[] = []; 52 | 53 | constructor( 54 | primaryProperty: number, 55 | indexableUniqueProperty: string, 56 | indexableProperty: string 57 | ) { 58 | this.primaryProperty = primaryProperty; 59 | this.indexableUniqueProperty = indexableUniqueProperty; 60 | this.indexableProperty = indexableProperty; 61 | } 62 | } 63 | 64 | singletonRepository = getRepository(SingletonEntity); 65 | singletonInstance = new SingletonEntity(); 66 | 67 | complexRepository = getRepository(ComplexEntity); 68 | complexInstance = new ComplexEntity(12345, `abc@xyz.com`, `blue`); 69 | }); 70 | 71 | it(`can be initialized with a default value`, async () => { 72 | expect(await datastore.read(`SingletonEntity:myProperty`)).toBeNull(); 73 | expect(await singletonRepository.save(singletonInstance)).toBeTruthy(); 74 | expect(await datastore.read(`SingletonEntity:myProperty`)).toEqual( 75 | `initial value` 76 | ); 77 | expect(await singletonRepository.save(singletonInstance)).toBeFalsy(); 78 | 79 | expect(await complexRepository.save(complexInstance)).toBeTruthy(); 80 | expect(await datastore.read(`ComplexEntity:myProperty`)).toBeNull(); 81 | expect(await datastore.read(`ComplexEntity:12345:myProperty`)).toEqual( 82 | `initial value` 83 | ); 84 | expect(await datastore.read(`ComplexEntity:12345:primaryProperty`)).toEqual( 85 | 12345 86 | ); 87 | expect( 88 | await datastore.read(`ComplexEntity:12345:indexableUniqueProperty`) 89 | ).toEqual(`abc@xyz.com`); 90 | expect( 91 | await datastore.read(`ComplexEntity:indexableUniqueProperty:abc@xyz.com`) 92 | ).toEqual(12345); 93 | expect( 94 | await datastore.read(`ComplexEntity:indexableProperty:blue:12345`) 95 | ).toEqual(12345); 96 | }); 97 | 98 | it(`can be written to, and subsequently read from`, async () => { 99 | singletonInstance.myProperty = `new value`; 100 | expect(await singletonRepository.save(singletonInstance)).toBeTruthy(); 101 | expect(await datastore.read(`SingletonEntity:myProperty`)).toEqual( 102 | `new value` 103 | ); 104 | expect(await singletonInstance.myProperty).toEqual(`new value`); 105 | 106 | complexInstance.myProperty = `new value`; 107 | expect(await singletonRepository.save(complexInstance)).toBeTruthy(); 108 | expect(await datastore.read(`ComplexEntity:12345:myProperty`)).toEqual( 109 | `new value` 110 | ); 111 | expect(await complexInstance.myProperty).toEqual(`new value`); 112 | }); 113 | 114 | it(`can load an instance`, async () => { 115 | await singletonRepository.save(singletonInstance); 116 | let loadedInstance = await singletonRepository.load(); 117 | expect(await loadedInstance.myProperty).toEqual(`initial value`); 118 | 119 | await complexRepository.save(complexInstance); 120 | loadedInstance = await complexRepository.load(12345); 121 | expect(await loadedInstance.myProperty).toEqual(`initial value`); 122 | 123 | singletonInstance.myProperty = `new value`; 124 | await singletonRepository.save(singletonInstance); 125 | loadedInstance = await singletonRepository.load(); 126 | expect(await loadedInstance.myProperty).toEqual(`new value`); 127 | 128 | complexInstance.myProperty = `new value`; 129 | await complexRepository.save(complexInstance); 130 | loadedInstance = await complexRepository.load(12345); 131 | expect(await loadedInstance.myProperty).toEqual(`new value`); 132 | }); 133 | 134 | it(`can find an instance`, async () => { 135 | await complexRepository.save(complexInstance); 136 | const loadedInstance = await complexRepository.load(12345); 137 | const foundInstance = (await complexRepository.find( 138 | `indexableUniqueProperty`, 139 | `abc@xyz.com` 140 | )) as BaseEntity; 141 | expect(await foundInstance.myProperty).toEqual( 142 | await loadedInstance.myProperty 143 | ); 144 | expect(await foundInstance.primaryProperty).toEqual( 145 | await loadedInstance.primaryProperty 146 | ); 147 | expect(await foundInstance.indexableUniqueProperty).toEqual( 148 | await loadedInstance.indexableUniqueProperty 149 | ); 150 | }); 151 | 152 | it("throws an error when finding a non UniqueColumn", () => { 153 | expect( 154 | complexRepository.find("indexableProperty", "") 155 | ).rejects.toThrowError(); 156 | }); 157 | 158 | it(`returns null when finding for a non-existent instance`, async () => { 159 | await complexRepository.save(complexInstance); 160 | const foundInstance = await complexRepository.find( 161 | `indexableUniqueProperty`, 162 | `non-existent@email.com` 163 | ); 164 | expect(foundInstance).toBeNull(); 165 | }); 166 | 167 | it(`can save and load a Column with an array type`, async () => { 168 | const values = [1, 2, 3, 4, 5]; 169 | complexInstance.arrayProperty = values; 170 | expect(await complexInstance.arrayProperty).toEqual(values); 171 | await complexRepository.save(complexInstance); 172 | 173 | const loadedInstance = await complexRepository.load(12345); 174 | expect(await loadedInstance.arrayProperty).toEqual(values); 175 | }); 176 | 177 | it("can search", async () => { 178 | await complexRepository.save(complexInstance); 179 | const searchResults = await complexRepository.search( 180 | "indexableProperty", 181 | "blue" 182 | ); 183 | const expectedIDs = [12345]; 184 | let i = 0; 185 | for await (const searchResult of searchResults) { 186 | expect(await searchResult.primaryProperty).toEqual(expectedIDs[i]); 187 | i++; 188 | } 189 | expect(i).toBe(expectedIDs.length); 190 | }); 191 | 192 | describe(`RepositoryLoadError`, () => { 193 | it(`is thrown when loading a singleton Entity with an identifier`, async () => { 194 | await expect( 195 | (async (): Promise => { 196 | await singletonRepository.load(12345); 197 | })() 198 | ).rejects.toThrow(RepositoryLoadError); 199 | }); 200 | it(`is thrown when loading a non-singleton Entity without an identifier`, async () => { 201 | await expect( 202 | (async (): Promise => { 203 | await complexRepository.load(); 204 | })() 205 | ).rejects.toThrow(RepositoryLoadError); 206 | }); 207 | }); 208 | 209 | describe("RepositoryFindError", () => { 210 | it(`is thrown when loading a singleton Entity without an identifier`, async () => { 211 | await expect( 212 | (async (): Promise => { 213 | await singletonRepository.find("myProperty", "someValue"); 214 | })() 215 | ).rejects.toThrow(RepositoryFindError); 216 | }); 217 | }); 218 | 219 | describe(`ColumnLookupError`, () => { 220 | it(`is thrown when searching a non-existent property`, async () => { 221 | await expect( 222 | (async (): Promise => { 223 | await complexRepository.find(`fakeProperty`, 1); 224 | })() 225 | ).rejects.toThrow(ColumnLookupError); 226 | }); 227 | }); 228 | 229 | describe(`ColumnNotFindableError`, () => { 230 | it(`is thrown when searching a non IndexableColumn`, async () => { 231 | await expect( 232 | (async (): Promise => { 233 | await complexRepository.find(`myProperty`, `value`); 234 | })() 235 | ).rejects.toThrow(ColumnNotFindableError); 236 | }); 237 | }); 238 | 239 | describe(`ColumnNotFindableError`, () => { 240 | it(`is thrown when searching a non IndexableColumn`, async () => { 241 | await expect( 242 | (async (): Promise => { 243 | await complexRepository.search(`myProperty`, `value`); 244 | })() 245 | ).rejects.toThrow(ColumnNotSearchableError); 246 | }); 247 | }); 248 | 249 | describe(`EntityNotFoundError`, () => { 250 | it(`is thrown when loading a non-existent non-singleton entity`, async () => { 251 | await expect( 252 | (async (): Promise => { 253 | await complexRepository.load(`99999`); 254 | })() 255 | ).rejects.toThrow(EntityNotFoundError); 256 | }); 257 | 258 | it(`is [not?] thrown when loading a non-existent singleton entity`, async () => { 259 | // TODO: Should we throw an EntityNotFoundError here? 260 | // await expect( 261 | // (async (): Promise => { 262 | // await singletonRepository.load() 263 | // })() 264 | // ).rejects.toThrow(EntityNotFoundError) 265 | }); 266 | }); 267 | }); 268 | -------------------------------------------------------------------------------- /src/Repository/Repository.ts: -------------------------------------------------------------------------------- 1 | import "../metadata"; 2 | 3 | import { BaseEntity, EntityConstructor } from "../Entity/Entity"; 4 | import { PropertyKey } from "../Entity/Entity"; 5 | import { Value } from "../Datastore/Datastore"; 6 | import { repositoryLoad } from "./repositoryLoad"; 7 | import { repositorySearch } from "./repositorySearch"; 8 | import { repositoryFind } from "./repositoryFind"; 9 | import { repositorySave } from "./repositorySave"; 10 | 11 | export interface Repository { 12 | load(identifier?: Value): Promise; 13 | save(entity: BaseEntity): Promise; 14 | find(property: PropertyKey, identifier: Value): Promise; 15 | search( 16 | property: PropertyKey, 17 | identifier: Value 18 | ): Promise>; 19 | } 20 | 21 | export const getRepository = ( 22 | constructor: EntityConstructor 23 | ): Repository => { 24 | return { 25 | async load(identifier?: Value): Promise { 26 | return await repositoryLoad(constructor, identifier); 27 | }, 28 | async save(instance: BaseEntity): Promise { 29 | return await repositorySave(instance); 30 | }, 31 | async find(property: PropertyKey, identifier: Value): Promise { 32 | return await repositoryFind(constructor, property, identifier); 33 | }, 34 | search( 35 | property: PropertyKey, 36 | identifier: Value 37 | ): Promise> { 38 | return repositorySearch(constructor, property, identifier); 39 | }, 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/Repository/RepositoryFindError.ts: -------------------------------------------------------------------------------- 1 | import { KVORMError } from "../utils/errors"; 2 | import { EntityConstructor } from "../Entity/Entity"; 3 | 4 | export class RepositoryFindError extends KVORMError { 5 | constructor(constructor: EntityConstructor, message = `Unknown Error`) { 6 | super(`Could not find Entity, ${constructor.name}: ${message}`); 7 | this.name = `RepositoryFindError`; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Repository/RepositoryLoadError.ts: -------------------------------------------------------------------------------- 1 | import { KVORMError } from "../utils/errors"; 2 | import { EntityConstructor } from "../Entity/Entity"; 3 | 4 | export class RepositoryLoadError extends KVORMError { 5 | constructor(constructor: EntityConstructor, message = `Unknown Error`) { 6 | super(`Could not load Entity, ${constructor.name}: ${message}`); 7 | this.name = `RepositoryLoadError`; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Repository/repositoryDelete.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity } from "../Entity/Entity"; 2 | import { getConstructorDatastoreCache } from "../utils/entities"; 3 | import { getColumnMetadatas } from "../utils/columns"; 4 | import { 5 | getToOneRelationshipMetadatas, 6 | getToManyRelationshipMetadatas, 7 | getRelationshipMetadata, 8 | } from "../utils/relationships"; 9 | import { columnSet } from "../Column/columnSet"; 10 | import { repositorySave } from "./repositorySave"; 11 | import { toOneGet } from "../Relationship/toOneGet"; 12 | import { toManyGet } from "../Relationship/toManyGet"; 13 | import { removeFrom } from "../Relationship/removeFrom"; 14 | 15 | export const repositoryDelete = async ( 16 | instance: BaseEntity 17 | ): Promise => { 18 | const { constructor } = getConstructorDatastoreCache(instance); 19 | const columnMetadatas = getColumnMetadatas(constructor); 20 | 21 | for (const columnMetadata of columnMetadatas) { 22 | columnSet(instance, columnMetadata, null); 23 | } 24 | 25 | const toOneRelationshipMetadatas = getToOneRelationshipMetadatas(constructor); 26 | const toManyRelationshipMetadatas = getToManyRelationshipMetadatas( 27 | constructor 28 | ); 29 | 30 | let updatedRelation = false; 31 | 32 | for (const toOneRelationshipMetadata of toOneRelationshipMetadatas) { 33 | if (toOneRelationshipMetadata.cascade.onDelete) { 34 | const relationInstance = await toOneGet( 35 | instance, 36 | toOneRelationshipMetadata 37 | ); 38 | const relationshipConstructor = toOneRelationshipMetadata.type(); 39 | const relationshipRelationshipMetadata = getRelationshipMetadata( 40 | relationshipConstructor, 41 | toOneRelationshipMetadata.backRef 42 | ); 43 | 44 | switch (relationshipRelationshipMetadata.cardinality) { 45 | case "ToOne": 46 | updatedRelation = 47 | (await repositoryDelete(relationInstance)) || updatedRelation; 48 | break; 49 | case "ToMany": 50 | // TODO: updatedRelation = 51 | removeFrom( 52 | relationInstance, 53 | toOneRelationshipMetadata.backRef, 54 | instance 55 | ); 56 | break; 57 | } 58 | } 59 | } 60 | 61 | for (const toManyRelationshipMetadata of toManyRelationshipMetadatas) { 62 | if (toManyRelationshipMetadata.cascade.onDelete) { 63 | const relationInstances = toManyGet(instance, toManyRelationshipMetadata); 64 | const relationshipConstructor = toManyRelationshipMetadata.type(); 65 | const relationshipRelationshipMetadata = getRelationshipMetadata( 66 | relationshipConstructor, 67 | toManyRelationshipMetadata.backRef 68 | ); 69 | 70 | switch (relationshipRelationshipMetadata.cardinality) { 71 | case "ToOne": 72 | for await (const relationInstance of relationInstances) { 73 | updatedRelation = 74 | (await repositoryDelete(relationInstance)) || updatedRelation; 75 | } 76 | break; 77 | case "ToMany": 78 | for await (const relationInstance of relationInstances) { 79 | // TODO: updatedRelation = 80 | removeFrom( 81 | relationInstance, 82 | toManyRelationshipMetadata.backRef, 83 | instance 84 | ); 85 | } 86 | break; 87 | } 88 | } 89 | } 90 | 91 | return repositorySave(instance) || updatedRelation; 92 | }; 93 | -------------------------------------------------------------------------------- /src/Repository/repositoryFind.ts: -------------------------------------------------------------------------------- 1 | import { EntityConstructor, BaseEntity } from "../Entity/Entity"; 2 | import { Value } from "../Datastore/Datastore"; 3 | import { getColumnMetadata, getPrimaryColumnMetadata } from "../utils/columns"; 4 | import { PropertyKey } from "../Entity/Entity"; 5 | import { getUniqueSearchKey } from "../utils/keyGeneration"; 6 | import { repositoryLoad } from "./repositoryLoad"; 7 | import { getDatastore } from "../utils/datastore"; 8 | import { ColumnNotFindableError } from "./ColumnNotFindableError"; 9 | import { RepositoryFindError } from "./RepositoryFindError"; 10 | 11 | const assertNotSingleton = (constructor: EntityConstructor) => { 12 | const primaryColumnMetadata = getPrimaryColumnMetadata(constructor); 13 | 14 | if (primaryColumnMetadata === undefined) 15 | throw new RepositoryFindError( 16 | constructor, 17 | `Entity is a singleton, so cannot perform a find with it's repository. Try simply loading with repository.load() instead.` 18 | ); 19 | }; 20 | 21 | export const repositoryFind = async ( 22 | constructor: EntityConstructor, 23 | property: PropertyKey, 24 | identifier: Value 25 | ): Promise => { 26 | const datastore = getDatastore(constructor); 27 | const columnMetadata = getColumnMetadata(constructor, property); 28 | assertNotSingleton(constructor); 29 | 30 | if (!columnMetadata.isIndexable) 31 | throw new ColumnNotFindableError( 32 | constructor, 33 | columnMetadata, 34 | `Column is not set as isIndexable` 35 | ); 36 | 37 | if (!columnMetadata.isUnique) 38 | throw new ColumnNotFindableError( 39 | constructor, 40 | columnMetadata, 41 | `Column is not set as isUnique` 42 | ); 43 | 44 | const key = getUniqueSearchKey(constructor, columnMetadata, identifier); 45 | const primaryIdentifier = await datastore.read(key); 46 | 47 | if (primaryIdentifier !== null) { 48 | return await repositoryLoad(constructor, primaryIdentifier); 49 | } else { 50 | return null; 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/Repository/repositoryLoad.ts: -------------------------------------------------------------------------------- 1 | import { EntityConstructor, BaseEntity } from "../Entity/Entity"; 2 | import { Value, Datastore } from "../Datastore/Datastore"; 3 | import { createEmptyInstance } from "../utils/entities"; 4 | import { 5 | getPrimaryColumnMetadata, 6 | setPrimaryColumnValue, 7 | } from "../utils/columns"; 8 | import { RepositoryLoadError } from "./RepositoryLoadError"; 9 | import { getDatastore } from "../utils/datastore"; 10 | import { generatePropertyKey } from "../utils/keyGeneration"; 11 | import { EntityNotFoundError } from "./EntityNotFoundError"; 12 | import { ColumnMetadata } from "../Column/columnMetadata"; 13 | 14 | const assertIdentifierValid = ( 15 | constructor: EntityConstructor, 16 | primaryColumnMetadata: ColumnMetadata | undefined, 17 | identifier: Value 18 | ): void => { 19 | if (primaryColumnMetadata === undefined && identifier) { 20 | throw new RepositoryLoadError( 21 | constructor, 22 | `Entity is a singleton, so cannot load with an identifier.` 23 | ); 24 | } else if (primaryColumnMetadata !== undefined && !identifier) { 25 | throw new RepositoryLoadError( 26 | constructor, 27 | `Entity is not a singleton, and so requires an identifier to load with.` 28 | ); 29 | } 30 | }; 31 | 32 | const loadNonSingleton = async ( 33 | datastore: Datastore, 34 | constructor: EntityConstructor, 35 | instance: BaseEntity, 36 | primaryColumnMetadata: ColumnMetadata, 37 | identifier: Value 38 | ): Promise => { 39 | setPrimaryColumnValue(instance, identifier); 40 | const key = generatePropertyKey(instance, primaryColumnMetadata); 41 | const loadedIdentifier = await datastore.read(key); 42 | if (String(loadedIdentifier) !== identifier.toString()) 43 | throw new EntityNotFoundError(constructor, identifier); 44 | 45 | setPrimaryColumnValue(instance, loadedIdentifier); 46 | }; 47 | 48 | export const repositoryLoad = async ( 49 | constructor: EntityConstructor, 50 | identifier?: Value 51 | ): Promise => { 52 | const datastore = getDatastore(constructor); 53 | const instance = createEmptyInstance(constructor); 54 | const primaryColumnMetadata = getPrimaryColumnMetadata(constructor); 55 | 56 | assertIdentifierValid(constructor, primaryColumnMetadata, identifier); 57 | 58 | if (primaryColumnMetadata !== undefined && identifier !== undefined) { 59 | await loadNonSingleton( 60 | datastore, 61 | constructor, 62 | instance, 63 | primaryColumnMetadata, 64 | identifier 65 | ); 66 | } else { 67 | // Entity is a singleton 68 | // Should we still somehow check if it has been saved before and throw a notfound error? 69 | } 70 | 71 | return instance; 72 | }; 73 | -------------------------------------------------------------------------------- /src/Repository/repositorySave.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity } from "../Entity/Entity"; 2 | import { getConstructorDatastoreCache } from "../utils/entities"; 3 | import { getRepository } from "./Repository"; 4 | import { 5 | getToOneRelationshipMetadatas, 6 | getToManyRelationshipMetadatas, 7 | } from "../utils/relationships"; 8 | import { RelationshipMetadata } from "../Relationship/relationshipMetadata"; 9 | 10 | export const repositorySave = async ( 11 | instance: BaseEntity, 12 | { 13 | skipRelationshipMetadatas, 14 | }: { skipRelationshipMetadatas: RelationshipMetadata[] } = { 15 | skipRelationshipMetadatas: [], 16 | } 17 | ): Promise => { 18 | const { constructor, cache } = getConstructorDatastoreCache(instance); 19 | 20 | const toOneRelationshipMetadatas = getToOneRelationshipMetadatas(constructor); 21 | const toManyRelationshipMetadatas = getToManyRelationshipMetadatas( 22 | constructor 23 | ); 24 | 25 | let updatedRelation = false; 26 | 27 | for (const toOneRelationshipMetadata of toOneRelationshipMetadatas) { 28 | if ( 29 | toOneRelationshipMetadata.cascade.onUpdate && 30 | !skipRelationshipMetadatas.includes(toOneRelationshipMetadata) 31 | ) { 32 | skipRelationshipMetadatas.push(toOneRelationshipMetadata); 33 | const cachedRelationInstance = toOneRelationshipMetadata.instance.get( 34 | instance 35 | ); 36 | if (cachedRelationInstance) { 37 | updatedRelation = 38 | (await repositorySave(cachedRelationInstance, { 39 | skipRelationshipMetadatas, 40 | })) || updatedRelation; 41 | } 42 | } 43 | } 44 | 45 | for (const toManyRelationshipMetadata of toManyRelationshipMetadatas) { 46 | if ( 47 | toManyRelationshipMetadata.cascade.onUpdate && 48 | !skipRelationshipMetadatas.push(toManyRelationshipMetadata) 49 | ) { 50 | skipRelationshipMetadatas.push(toManyRelationshipMetadata); 51 | const cachedRelationInstances = 52 | toManyRelationshipMetadata.instances.get(instance) || []; 53 | for (const cachedRelationInstance of cachedRelationInstances) { 54 | updatedRelation = 55 | (await repositorySave(cachedRelationInstance, { 56 | skipRelationshipMetadatas, 57 | })) || updatedRelation; 58 | } 59 | } 60 | } 61 | 62 | return cache.sync(instance) || updatedRelation; 63 | }; 64 | -------------------------------------------------------------------------------- /src/Repository/repositorySearch.ts: -------------------------------------------------------------------------------- 1 | import { EntityConstructor, BaseEntity } from "../Entity/Entity"; 2 | import { Value } from "../Datastore/Datastore"; 3 | import { getColumnMetadata } from "../utils/columns"; 4 | import { PropertyKey } from "../Entity/Entity"; 5 | import { getIndexableSearchKey } from "../utils/keyGeneration"; 6 | import { getDatastore } from "../utils/datastore"; 7 | import { ColumnNotSearchableError } from "./ColumnNotSearchableError"; 8 | import { getHydrator, hydrateMany } from "../utils/hydrate"; 9 | 10 | export const repositorySearch = async ( 11 | constructor: EntityConstructor, 12 | property: PropertyKey, 13 | identifier: Value 14 | ): Promise> => { 15 | const datastore = getDatastore(constructor); 16 | const columnMetadata = getColumnMetadata(constructor, property); 17 | 18 | if (!columnMetadata.isIndexable) 19 | throw new ColumnNotSearchableError( 20 | constructor, 21 | columnMetadata, 22 | `Column is not set as isIndexable` 23 | ); 24 | 25 | const searchKey = getIndexableSearchKey( 26 | constructor, 27 | columnMetadata, 28 | identifier 29 | ); 30 | const hydrator = getHydrator(constructor); 31 | return hydrateMany(datastore, searchKey, hydrator); 32 | }; 33 | -------------------------------------------------------------------------------- /src/__tests__/library/datastores/libraryDatastore.testhelpers.ts: -------------------------------------------------------------------------------- 1 | import { MemoryDatastore } from "../../../MemoryDatastore/MemoryDatastore"; 2 | 3 | export const libraryDatastore = new MemoryDatastore(); 4 | -------------------------------------------------------------------------------- /src/__tests__/library/fixtures/williamShakespeare.testhelpers.ts: -------------------------------------------------------------------------------- 1 | import { Author } from "../models/Author.testhelpers"; 2 | 3 | const williamShakespeare = new Author({ 4 | firstName: `William`, 5 | lastName: `Shakespeare`, 6 | emailAddress: `william@shakespeare.com`, 7 | phoneNumber: `+1234567890`, 8 | birthYear: 1564, 9 | }); 10 | 11 | williamShakespeare.nickName = `Bill`; 12 | williamShakespeare.someUnsavedProperty = `Won't get saved!`; 13 | 14 | export { williamShakespeare }; 15 | -------------------------------------------------------------------------------- /src/__tests__/library/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Author } from "./models/Author.testhelpers"; 2 | import { williamShakespeare } from "./fixtures/williamShakespeare.testhelpers"; 3 | import { getRepository } from "../../Repository/Repository"; 4 | import { libraryDatastore } from "./datastores/libraryDatastore.testhelpers"; 5 | 6 | const repository = getRepository(Author); 7 | 8 | describe(`library`, () => { 9 | describe(`README.md example`, () => { 10 | beforeEach(() => { 11 | (async () => await repository.save(williamShakespeare))(); 12 | }); 13 | 14 | it(`async await read firstName`, async () => { 15 | expect(await williamShakespeare.firstName).toEqual(`William`); 16 | }); 17 | 18 | it(`property getters/setters`, async () => { 19 | const date = new Date(`2020-01-02T03:04:05Z`); 20 | williamShakespeare.somethingComplex = date; 21 | expect(await williamShakespeare.somethingComplex).toEqual(date); 22 | }); 23 | 24 | it(`repository saves/loads`, async () => { 25 | const loadedWilliamShakespeare = await repository.load( 26 | `william@shakespeare.com` 27 | ); 28 | expect(await loadedWilliamShakespeare.nickName).toEqual(`Bill`); 29 | }); 30 | 31 | it(`repository finds`, async () => { 32 | const foundWilliamShakespeare = await repository.find( 33 | `phoneNumber`, 34 | `+1234567890` 35 | ); 36 | 37 | expect(foundWilliamShakespeare).not.toBeNull(); 38 | expect(await foundWilliamShakespeare?.nickName).toEqual(`Bill`); 39 | 40 | const foundNonexistent = await repository.find( 41 | `phoneNumber`, 42 | `+9999999999` 43 | ); 44 | 45 | expect(foundNonexistent).toBeNull(); 46 | }); 47 | 48 | it(`repository searches`, async () => { 49 | const searchedAuthors = await repository.search(`birthYear`, 1564); 50 | 51 | let i = 0; 52 | for await (const searchedAuthor of searchedAuthors) { 53 | expect(await searchedAuthor.nickName).toEqual("Bill"); 54 | i++; 55 | } 56 | 57 | expect(i).toBe(1); 58 | }); 59 | }); 60 | 61 | describe(`the datastore`, () => { 62 | it(`is saved as expected`, () => { 63 | expect((libraryDatastore as any).data).toMatchInlineSnapshot(` 64 | Map { 65 | "Author:william@shakespeare.com:_complex" => "2020-01-02T03:04:05.000Z", 66 | "Author:william@shakespeare.com:givenName" => "William", 67 | "Author:william@shakespeare.com:lastName" => "Shakespeare", 68 | "Author:emailAddress:william@shakespeare.com" => "william@shakespeare.com", 69 | "Author:william@shakespeare.com:emailAddress" => "william@shakespeare.com", 70 | "Author:birthYear:1564:william@shakespeare.com" => "william@shakespeare.com", 71 | "Author:william@shakespeare.com:birthYear" => 1564, 72 | "Author:phoneNumber:+1234567890" => "william@shakespeare.com", 73 | "Author:william@shakespeare.com:phoneNumber" => "+1234567890", 74 | "Author:william@shakespeare.com:nickName" => "Bill", 75 | } 76 | `); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/__tests__/library/models/Author.testhelpers.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from "../../../Entity/Entity"; 2 | import { Column } from "../../../Column/Column"; 3 | import { PrimaryColumn } from "../../../Column/PrimaryColumn"; 4 | import { UniqueColumn } from "../../../Column/UniqueColumn"; 5 | import { IndexableColumn } from "../../../Column/IndexableColumn"; 6 | import { columnType } from "../../../types/columnType"; 7 | import { libraryDatastore } from "../datastores/libraryDatastore.testhelpers"; 8 | 9 | const serialize = (value: columnType): string => { 10 | if (!(value instanceof Date)) throw new Error(`Value must be a date`); 11 | return value.toISOString(); 12 | }; 13 | 14 | const deserialize = (value: string): any => new Date(value); 15 | 16 | @Entity({ datastore: libraryDatastore }) 17 | export class Author { 18 | @Column({ key: `givenName` }) 19 | public firstName: columnType; 20 | 21 | @Column() 22 | public lastName: columnType; 23 | 24 | @Column() 25 | public nickName?: columnType; 26 | 27 | @PrimaryColumn() 28 | public emailAddress: columnType; 29 | 30 | @IndexableColumn() 31 | public birthYear: columnType; 32 | 33 | @UniqueColumn() 34 | public phoneNumber: columnType; 35 | 36 | public someUnsavedProperty: any; 37 | 38 | @Column() 39 | private _complex: columnType = ``; 40 | 41 | set somethingComplex(value: columnType) { 42 | this._complex = serialize(value); 43 | } 44 | 45 | get somethingComplex(): columnType { 46 | return (async () => deserialize(await this._complex))(); 47 | } 48 | 49 | public constructor({ 50 | firstName, 51 | lastName, 52 | emailAddress, 53 | birthYear, 54 | phoneNumber, 55 | }: { 56 | firstName: string; 57 | lastName: string; 58 | emailAddress: string; 59 | birthYear: number; 60 | phoneNumber: string; 61 | }) { 62 | this.firstName = firstName; 63 | this.lastName = lastName; 64 | this.emailAddress = emailAddress; 65 | this.birthYear = birthYear; 66 | this.phoneNumber = phoneNumber; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | describe(`the universe`, () => { 2 | it(`can do math`, () => { 3 | expect(1 + 1).toEqual(2); 4 | }); 5 | }); 6 | 7 | describe("exports", () => { 8 | it("includes everything", () => { 9 | const everything = require("./index"); 10 | expect(Object.keys(everything)).toMatchInlineSnapshot(` 11 | Array [ 12 | "Datastore", 13 | "SearchStrategy", 14 | "MemoryDatastore", 15 | "Entity", 16 | "Column", 17 | "PrimaryColumn", 18 | "UniqueColumn", 19 | "IndexableColumn", 20 | "ToOne", 21 | "ToMany", 22 | "addTo", 23 | "removeFrom", 24 | "getRepository", 25 | ] 26 | `); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Datastore, Key, Value } from "./Datastore/Datastore"; 2 | export { 3 | SearchOptions, 4 | SearchResult, 5 | SearchStrategy, 6 | } from "./Datastore/Datastore"; 7 | 8 | export { MemoryDatastore } from "./MemoryDatastore/MemoryDatastore"; 9 | export { Entity } from "./Entity/Entity"; 10 | export { Column } from "./Column/Column"; 11 | export { PrimaryColumn } from "./Column/PrimaryColumn"; 12 | export { UniqueColumn } from "./Column/UniqueColumn"; 13 | export { IndexableColumn } from "./Column/IndexableColumn"; 14 | export { columnType } from "./types/columnType"; 15 | export { ToOne } from "./Relationship/ToOne"; 16 | export { toOneType } from "./types/toOneType"; 17 | export { ToMany } from "./Relationship/ToMany"; 18 | export { toManyType } from "./types/toManyType"; 19 | export { addTo } from "./Relationship/addTo"; 20 | export { removeFrom } from "./Relationship/removeFrom"; 21 | export { getRepository } from "./Repository/Repository"; 22 | -------------------------------------------------------------------------------- /src/metadata.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | -------------------------------------------------------------------------------- /src/types/columnType.test.ts: -------------------------------------------------------------------------------- 1 | import { Column } from "../Column/Column"; 2 | import { Entity } from "../Entity/Entity"; 3 | import { Datastore } from "../Datastore/Datastore"; 4 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 5 | import { columnType } from "./columnType"; 6 | 7 | describe(`columnType`, () => { 8 | let datastore: Datastore; 9 | 10 | beforeEach(() => { 11 | datastore = new MemoryDatastore(); 12 | }); 13 | 14 | it(`helps`, async () => { 15 | @Entity({ datastore }) 16 | class WithColumnType { 17 | @Column() 18 | id: columnType; 19 | 20 | constructor(id: number) { 21 | this.id = id; 22 | } 23 | } 24 | 25 | @Entity({ datastore }) 26 | class WithoutColumnType { 27 | @Column() 28 | id: number; 29 | 30 | constructor(id: number) { 31 | this.id = id; 32 | } 33 | } 34 | 35 | const instanceWithColumnType = new WithColumnType(123); 36 | const instanceWithoutColumnType = new WithoutColumnType(123); 37 | 38 | await instanceWithColumnType.id; 39 | await instanceWithoutColumnType.id; // 'await' has no effect on the type of this expression.ts(80007) 40 | 41 | instanceWithColumnType.id = 234; 42 | instanceWithoutColumnType.id = 234; 43 | }); 44 | }); 45 | 46 | type toManyType = 47 | | T[] 48 | | { 49 | push(value: T): void; 50 | }; 51 | -------------------------------------------------------------------------------- /src/types/columnType.ts: -------------------------------------------------------------------------------- 1 | export type columnType = T | Promise; 2 | -------------------------------------------------------------------------------- /src/types/toManyType.ts: -------------------------------------------------------------------------------- 1 | export type toManyType = T[] | AsyncGenerator; 2 | -------------------------------------------------------------------------------- /src/types/toOneType.ts: -------------------------------------------------------------------------------- 1 | export type toOneType = T | Promise; 2 | -------------------------------------------------------------------------------- /src/utils/cache.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from "../Cache/Cache"; 2 | import { Datastore } from "../Datastore/Datastore"; 3 | 4 | export const getCache = (datastore: Datastore): Cache => datastore.cache; 5 | -------------------------------------------------------------------------------- /src/utils/columns.test.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from "../Entity/Entity"; 2 | import { Datastore } from "../Datastore/Datastore"; 3 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 4 | import { PrimaryColumnMissingError } from "./errors"; 5 | import { getPrimaryColumnValue } from "./columns"; 6 | 7 | describe(`columns`, () => { 8 | let datastore: Datastore; 9 | beforeEach(() => { 10 | datastore = new MemoryDatastore(); 11 | }); 12 | describe(`PrimaryColumnMissingError`, () => { 13 | it(`is thrown when it does not exist`, () => { 14 | @Entity({ datastore }) 15 | class MyEntity {} 16 | 17 | const instance = new MyEntity(); 18 | 19 | expect(() => { 20 | getPrimaryColumnValue(instance); 21 | }).toThrow(PrimaryColumnMissingError); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/utils/columns.ts: -------------------------------------------------------------------------------- 1 | import "../metadata"; 2 | 3 | import { EntityConstructor, BaseEntity, PropertyKey } from "../Entity/Entity"; 4 | import { COLUMN_KEY } from "../Column/Column"; 5 | import { ColumnMetadata } from "../Column/columnMetadata"; 6 | import { ColumnLookupError, PrimaryColumnMissingError } from "./errors"; 7 | import { getConstructorDatastoreCache } from "./entities"; 8 | import { Value } from "../Datastore/Datastore"; 9 | import { getMetadatas, getMetadata, setMetadata } from "./metadata"; 10 | 11 | export const getColumnMetadatas = ( 12 | constructor: EntityConstructor 13 | ): ColumnMetadata[] => 14 | getMetadatas(COLUMN_KEY, constructor) as ColumnMetadata[]; 15 | 16 | export const getColumnMetadata = ( 17 | constructor: EntityConstructor, 18 | property: PropertyKey 19 | ): ColumnMetadata => { 20 | const columnMetadatas = getColumnMetadatas(constructor); 21 | return getMetadata(columnMetadatas, property, () => { 22 | throw new ColumnLookupError( 23 | constructor, 24 | property, 25 | `Could not find Column. Has it been defined yet?` 26 | ); 27 | }) as ColumnMetadata; 28 | }; 29 | 30 | export const setColumnMetadata = ( 31 | constructor: EntityConstructor, 32 | columnMetadata: ColumnMetadata 33 | ): void => setMetadata(COLUMN_KEY, constructor, columnMetadata); 34 | 35 | export const getPrimaryColumnMetadata = ( 36 | constructor: EntityConstructor 37 | ): ColumnMetadata | undefined => { 38 | return getColumnMetadatas(constructor).find(({ isPrimary }) => isPrimary); 39 | }; 40 | 41 | const assertHasPrimaryColumn = (constructor: EntityConstructor): void => { 42 | if (getPrimaryColumnMetadata(constructor) === undefined) 43 | throw new PrimaryColumnMissingError(constructor); 44 | }; 45 | 46 | export const getPrimaryColumnValue = ( 47 | instance: BaseEntity, 48 | { failSilently } = { failSilently: false } 49 | ): Value => { 50 | const { constructor, cache } = getConstructorDatastoreCache(instance); 51 | assertHasPrimaryColumn(constructor); 52 | return cache.getPrimaryColumnValue(instance, { failSilently }); 53 | }; 54 | 55 | export const setPrimaryColumnValue = ( 56 | instance: BaseEntity, 57 | value: Value 58 | ): void => { 59 | const { constructor, cache } = getConstructorDatastoreCache(instance); 60 | assertHasPrimaryColumn(constructor); 61 | return cache.setPrimaryColumnValue(instance, value); 62 | }; 63 | -------------------------------------------------------------------------------- /src/utils/datastore.test.ts: -------------------------------------------------------------------------------- 1 | import { Datastore, SearchStrategy } from "../Datastore/Datastore"; 2 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 3 | import { paginateSearch } from "./datastore"; 4 | 5 | describe(`paginateSearch`, () => { 6 | let datastore: Datastore; 7 | 8 | beforeEach(async () => { 9 | datastore = new MemoryDatastore(); 10 | 11 | for (let i = 0; i < 9999; i++) { 12 | await datastore.write(`KEY:${i}`, `VALUE${i}`); 13 | } 14 | }); 15 | 16 | it(`does go beyond the first page`, async () => { 17 | const results = await paginateSearch(datastore, { 18 | strategy: SearchStrategy.prefix, 19 | term: `KEY:`, 20 | }); 21 | expect(results.keys.length).toBe(9999); 22 | expect(results.keys).toContain(`KEY:0`); 23 | expect(results.keys).toContain(`KEY:1`); 24 | expect(results.keys).toContain(`KEY:9998`); 25 | expect(results.cursor).toEqual(`9998`); 26 | expect(results.hasNextPage).toBeFalsy(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/utils/datastore.ts: -------------------------------------------------------------------------------- 1 | import { EntityConstructor } from "../Entity/Entity"; 2 | import { 3 | Datastore, 4 | SearchOptions, 5 | SearchResult, 6 | Key, 7 | SearchStrategy, 8 | } from "../Datastore/Datastore"; 9 | import { getEntityMetadata } from "./entities"; 10 | import { SearchStrategyError } from "../Datastore/SearchStrategyError"; 11 | 12 | export const getDatastore = (constructor: EntityConstructor): Datastore => 13 | getEntityMetadata(constructor).datastore; 14 | 15 | export const paginateSearch = async ( 16 | datastore: Datastore, 17 | options: SearchOptions 18 | ): Promise => { 19 | let results: SearchResult = await datastore.search(options); 20 | 21 | while (results.hasNextPage) { 22 | const result = await datastore.search({ 23 | ...options, 24 | after: results.cursor, 25 | }); 26 | const { keys, cursor, hasNextPage } = result; 27 | results = { 28 | keys: [...results.keys, ...keys], 29 | cursor, 30 | hasNextPage, 31 | }; 32 | } 33 | 34 | return results; 35 | }; 36 | 37 | export async function* keysFromSearch( 38 | datastore: Datastore, 39 | options: SearchOptions 40 | ): AsyncGenerator { 41 | let cursor; 42 | let hasNextPage = true; 43 | while (hasNextPage) { 44 | const searchResults: SearchResult = await datastore.search({ 45 | ...options, 46 | after: cursor, 47 | }); 48 | const { keys: queue } = searchResults; 49 | cursor = searchResults.cursor; 50 | hasNextPage = searchResults.hasNextPage; 51 | 52 | while (queue.length > 0) { 53 | const key = queue.shift(); 54 | if (key !== undefined) yield key; 55 | } 56 | } 57 | } 58 | 59 | export const pickSearchStrategy = (datastore: Datastore): SearchStrategy => { 60 | let strategy; 61 | if (datastore.searchStrategies.indexOf(SearchStrategy.prefix) !== -1) { 62 | strategy = SearchStrategy.prefix; 63 | } 64 | 65 | if (strategy === undefined) { 66 | throw new SearchStrategyError( 67 | SearchStrategy.prefix, 68 | `Datastore does not support searching` 69 | ); 70 | } 71 | return strategy; 72 | }; 73 | -------------------------------------------------------------------------------- /src/utils/entities.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from "../Cache/Cache"; 2 | import { EntityConstructor, BaseEntity, ENTITY_KEY } from "../Entity/Entity"; 3 | import { EntityLookupError } from "./errors"; 4 | import { EntityMetadata } from "../Entity/entityMetadata"; 5 | import { getDatastore } from "./datastore"; 6 | import { getCache } from "./cache"; 7 | import { Datastore } from "../Datastore/Datastore"; 8 | 9 | export const getEntityMetadata = ( 10 | constructor: EntityConstructor 11 | ): EntityMetadata => { 12 | const entityMetadata = Reflect.getMetadata( 13 | ENTITY_KEY, 14 | constructor 15 | ) as EntityMetadata; 16 | 17 | if (entityMetadata === undefined) 18 | throw new EntityLookupError( 19 | constructor, 20 | `Could not find metadata on Entity. Has it been defined yet?` 21 | ); 22 | 23 | return entityMetadata; 24 | }; 25 | 26 | export const setEntityMetadata = ( 27 | constructor: EntityConstructor, 28 | entityMetadata: EntityMetadata 29 | ): void => Reflect.defineMetadata(ENTITY_KEY, entityMetadata, constructor); 30 | 31 | export const getConstructor = (instance: BaseEntity): EntityConstructor => 32 | instance.constructor as EntityConstructor; 33 | 34 | export const createEmptyInstance = ( 35 | constructor: EntityConstructor 36 | ): T => Object.create(constructor.prototype); 37 | 38 | export const getConstructorDatastoreCache = ( 39 | instance: BaseEntity 40 | ): { constructor: EntityConstructor; datastore: Datastore; cache: Cache } => { 41 | const constructor = getConstructor(instance); 42 | const datastore = getDatastore(constructor); 43 | const cache = getCache(datastore); 44 | 45 | return { 46 | constructor, 47 | datastore, 48 | cache, 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | import { EntityConstructor, PropertyKey } from "../Entity/Entity"; 2 | import { Key, Value } from "../Datastore/Datastore"; 3 | 4 | export class KVORMError extends Error { 5 | constructor(message: string) { 6 | super(`kv-orm Error: ${message}`); 7 | this.name = `kv-orm Error`; 8 | } 9 | } 10 | 11 | export class SetupError extends KVORMError { 12 | constructor(message: string) { 13 | super(message); 14 | this.name = `SetupError`; 15 | } 16 | } 17 | 18 | export class MetadataError extends KVORMError { 19 | constructor(message: string) { 20 | super(message); 21 | this.name = `MetadataError`; 22 | } 23 | } 24 | 25 | export class ReadOnlyError extends KVORMError { 26 | constructor( 27 | constructor: EntityConstructor, 28 | property: PropertyKey, 29 | value: Value, 30 | message = `Unknown Error` 31 | ) { 32 | super( 33 | `Could not write Value, ${value}, to Column, ${property.toString()}, on Entity, ${ 34 | constructor.name 35 | }: ${message}` 36 | ); 37 | this.name = `ReadOnlyError`; 38 | } 39 | } 40 | 41 | export class EntityLookupError extends MetadataError { 42 | constructor(constructor: EntityConstructor, message = `Unknown Error`) { 43 | super(`Error looking up Entity, ${constructor.name}: ${message}`); 44 | this.name = `EntityLookupError`; 45 | } 46 | } 47 | 48 | export class ColumnLookupError extends MetadataError { 49 | constructor( 50 | constructor: EntityConstructor, 51 | property: PropertyKey, 52 | message = `Unknown Error` 53 | ) { 54 | super( 55 | `Error looking up Column, ${property.toString()}, on Entity, ${ 56 | constructor.name 57 | }: ${message}` 58 | ); 59 | this.name = `ColumnLookupError`; 60 | } 61 | } 62 | 63 | export class RelationshipLookupError extends MetadataError { 64 | constructor( 65 | constructor: EntityConstructor, 66 | property: PropertyKey, 67 | message = `Unknown Error` 68 | ) { 69 | super( 70 | `Error looking up Relationship, ${property.toString()}, on Entity, ${ 71 | constructor.name 72 | }: ${message}` 73 | ); 74 | this.name = `RelationshipLookupError`; 75 | } 76 | } 77 | 78 | export class PrimaryColumnMissingError extends MetadataError { 79 | constructor( 80 | constructor: EntityConstructor, 81 | message = `PrimaryColumn Missing` 82 | ) { 83 | super(`PrimaryColumn not found on Entity, ${constructor.name}: ${message}`); 84 | this.name = `PrimaryColumnMissingError`; 85 | } 86 | } 87 | 88 | export class InvalidKeyError extends MetadataError { 89 | constructor(key: Key, message = `Unknown Error`) { 90 | super(`Key, ${key}, is invalid: ${message}`); 91 | this.name = `InvalidKeyError`; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/utils/hydrate.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity, EntityConstructor } from "../Entity/Entity"; 2 | import { repositoryLoad } from "../Repository/repositoryLoad"; 3 | import { Value, Datastore, Key } from "../Datastore/Datastore"; 4 | import { getPrimaryColumnMetadata } from "./columns"; 5 | import { pickSearchStrategy, keysFromSearch } from "./datastore"; 6 | import { extractValueFromSearchKey } from "./keyGeneration"; 7 | 8 | export type hydrator = (identifier: Value) => Promise; 9 | 10 | export const getHydrator = (constructor: EntityConstructor): hydrator => { 11 | return async (identifier: Value): Promise => { 12 | const primaryColumn = getPrimaryColumnMetadata(constructor); 13 | if (primaryColumn !== undefined) { 14 | return await repositoryLoad(constructor, identifier); 15 | } else { 16 | return await repositoryLoad(constructor); 17 | } 18 | }; 19 | }; 20 | 21 | export async function* hydrateMany( 22 | datastore: Datastore, 23 | searchKey: Key, 24 | hydrator: hydrator, 25 | { skip }: { skip: (primaryColumnValue: Key) => boolean } = { 26 | skip: () => false, 27 | } 28 | ): AsyncGenerator { 29 | const searchStrategy = pickSearchStrategy(datastore); 30 | 31 | const keyGenerator = keysFromSearch(datastore, { 32 | strategy: searchStrategy, 33 | term: searchKey, 34 | }); 35 | 36 | for await (const value of keyGenerator) { 37 | const primaryColumnValue = extractValueFromSearchKey( 38 | datastore, 39 | value, 40 | searchKey 41 | ); 42 | !skip(primaryColumnValue) && (yield await hydrator(primaryColumnValue)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/keyGeneration.test.ts: -------------------------------------------------------------------------------- 1 | import { Column } from "../Column/Column"; 2 | import { ColumnMetadata } from "../Column/columnMetadata"; 3 | import { 4 | generatePropertyKey, 5 | generateIndexablePropertyKey, 6 | generateOneRelationshipKey, 7 | generateManyRelationshipKey, 8 | assertKeysDoNotContainSeparator, 9 | } from "./keyGeneration"; 10 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 11 | import { Datastore } from "../Datastore/Datastore"; 12 | import { 13 | getPrimaryColumnMetadata, 14 | getColumnMetadatas, 15 | getColumnMetadata, 16 | } from "./columns"; 17 | import { Entity, EntityConstructor, BaseEntity } from "../Entity/Entity"; 18 | import { 19 | EntityLookupError, 20 | InvalidKeyError, 21 | RelationshipLookupError, 22 | } from "./errors"; 23 | import { ToOne } from "../Relationship/ToOne"; 24 | import { getToOneRelationshipMetadata } from "./relationships"; 25 | 26 | describe(`keyGeneration`, () => { 27 | let datastore: Datastore; 28 | let singletonEntityConstructor: EntityConstructor; 29 | let singletonEntityWithCustomKeysConstructor: EntityConstructor; 30 | let complexEntityConstructor: EntityConstructor; 31 | let complexEntityWithCustomKeysConstructor: EntityConstructor; 32 | 33 | beforeEach(() => { 34 | datastore = new MemoryDatastore(); 35 | 36 | @Entity({ datastore }) 37 | class SingletonEntity { 38 | @Column() 39 | public myProperty = `initial value`; 40 | 41 | @ToOne({ type: () => SingletonEntity, backRef: "" }) 42 | public relationshipProperty: undefined; 43 | } 44 | 45 | singletonEntityConstructor = SingletonEntity; 46 | 47 | @Entity({ datastore, key: `CustomSingletonEntityKey` }) 48 | class SingletonEntityWithCustomKeys { 49 | @Column({ key: `CustomPropertyKey` }) 50 | public myProperty = `initial value`; 51 | 52 | @ToOne({ 53 | type: () => SingletonEntityWithCustomKeys, 54 | key: `CustomRelationshipKey`, 55 | backRef: "", 56 | }) 57 | public relationshipProperty: undefined; 58 | } 59 | 60 | singletonEntityWithCustomKeysConstructor = SingletonEntityWithCustomKeys; 61 | 62 | @Entity({ datastore }) 63 | class ComplexEntity { 64 | @Column({ isPrimary: true }) 65 | public id = 12345; 66 | 67 | @Column() 68 | public myProperty = `initial value`; 69 | 70 | @Column({ isIndexable: true, isUnique: true }) 71 | public indexable = `abc@xyz.com`; 72 | 73 | @ToOne({ type: () => ComplexEntity, backRef: "" }) 74 | public relationshipProperty: undefined; 75 | } 76 | 77 | complexEntityConstructor = ComplexEntity; 78 | 79 | @Entity({ datastore, key: `CustomComplexEntityKey` }) 80 | class ComplexEntityWithCustomKeys { 81 | @Column({ isPrimary: true, key: `CustomPrimaryKey` }) 82 | public id = 12345; 83 | 84 | @Column({ key: `CustomPropertyKey` }) 85 | public myProperty = `initial value`; 86 | 87 | @Column({ isIndexable: true, isUnique: true, key: `CustomIndexableKey` }) 88 | public indexable = `abc@xyz.com`; 89 | 90 | @ToOne({ 91 | type: () => ComplexEntityWithCustomKeys, 92 | key: `CustomRelationshipKey`, 93 | backRef: "", 94 | }) 95 | public relationshipProperty: undefined; 96 | } 97 | 98 | complexEntityWithCustomKeysConstructor = ComplexEntityWithCustomKeys; 99 | }); 100 | 101 | describe(`generatePropertyKey`, () => { 102 | it(`generates a key for a singleton with default keys`, async () => { 103 | const instance = new singletonEntityConstructor(); 104 | const columnMetadata = getColumnMetadata( 105 | singletonEntityConstructor, 106 | `myProperty` 107 | ); 108 | expect(generatePropertyKey(instance, columnMetadata)).toEqual( 109 | `SingletonEntity:myProperty` 110 | ); 111 | }); 112 | it(`generates a key for a singleton with custom keys`, async () => { 113 | const instance = new singletonEntityWithCustomKeysConstructor(); 114 | const columnMetadata = getColumnMetadata( 115 | singletonEntityWithCustomKeysConstructor, 116 | `myProperty` 117 | ); 118 | expect(generatePropertyKey(instance, columnMetadata)).toEqual( 119 | `CustomSingletonEntityKey:CustomPropertyKey` 120 | ); 121 | }); 122 | it(`generates a key with default keys`, async () => { 123 | const instance = new complexEntityConstructor(); 124 | const primaryColumnMetadata = getPrimaryColumnMetadata( 125 | complexEntityConstructor 126 | ) as ColumnMetadata; 127 | const otherColumnMetadata = getColumnMetadata( 128 | complexEntityConstructor, 129 | `myProperty` 130 | ); 131 | const indexableColumnMetadata = getColumnMetadata( 132 | complexEntityConstructor, 133 | `indexable` 134 | ); 135 | 136 | expect(generatePropertyKey(instance, primaryColumnMetadata)).toEqual( 137 | `ComplexEntity:12345:id` 138 | ); 139 | expect(generatePropertyKey(instance, otherColumnMetadata)).toEqual( 140 | `ComplexEntity:12345:myProperty` 141 | ); 142 | expect(generatePropertyKey(instance, indexableColumnMetadata)).toEqual( 143 | `ComplexEntity:12345:indexable` 144 | ); 145 | }); 146 | it(`generates a key with custom keys`, async () => { 147 | const instance = new complexEntityWithCustomKeysConstructor(); 148 | const primaryColumnMetadata = getPrimaryColumnMetadata( 149 | complexEntityWithCustomKeysConstructor 150 | ) as ColumnMetadata; 151 | const otherColumnMetadata = getColumnMetadata( 152 | complexEntityWithCustomKeysConstructor, 153 | `myProperty` 154 | ); 155 | const indexableColumnMetadata = getColumnMetadata( 156 | complexEntityWithCustomKeysConstructor, 157 | `indexable` 158 | ); 159 | 160 | expect(generatePropertyKey(instance, primaryColumnMetadata)).toEqual( 161 | `CustomComplexEntityKey:12345:CustomPrimaryKey` 162 | ); 163 | expect(generatePropertyKey(instance, otherColumnMetadata)).toEqual( 164 | `CustomComplexEntityKey:12345:CustomPropertyKey` 165 | ); 166 | expect(generatePropertyKey(instance, indexableColumnMetadata)).toEqual( 167 | `CustomComplexEntityKey:12345:CustomIndexableKey` 168 | ); 169 | }); 170 | it(`throws an error for invalid Entities`, async () => { 171 | await expect( 172 | (async (): Promise => { 173 | // No @Entity() decorator 174 | class MyInvalidEntity { 175 | @Column() 176 | public myProperty = `initial value`; 177 | } 178 | 179 | const instance = new MyInvalidEntity(); 180 | const columnMetadata = getColumnMetadatas(MyInvalidEntity)[0]; 181 | generatePropertyKey(instance, columnMetadata); 182 | })() 183 | ).rejects.toThrow(EntityLookupError); 184 | }); 185 | }); 186 | describe(`generateIndexablePropertyKey`, () => { 187 | it(`generates a key`, () => { 188 | const instance = new complexEntityConstructor(); 189 | const indexableColumnMetadata = getColumnMetadata( 190 | complexEntityConstructor, 191 | `indexable` 192 | ); 193 | expect( 194 | generateIndexablePropertyKey( 195 | instance, 196 | indexableColumnMetadata, 197 | `abc@xyz.com` 198 | ) 199 | ).toEqual(`ComplexEntity:indexable:abc@xyz.com`); 200 | }); 201 | it(`generates a key with custom keys`, () => { 202 | const instance = new complexEntityWithCustomKeysConstructor(); 203 | const indexableColumnMetadata = getColumnMetadata( 204 | complexEntityWithCustomKeysConstructor, 205 | `indexable` 206 | ); 207 | expect( 208 | generateIndexablePropertyKey( 209 | instance, 210 | indexableColumnMetadata, 211 | `abc@xyz.com` 212 | ) 213 | ).toEqual(`CustomComplexEntityKey:CustomIndexableKey:abc@xyz.com`); 214 | }); 215 | }); 216 | describe(`generateOneRelationshipKey`, () => { 217 | it(`generates a key for a singleton`, async () => { 218 | const instance = new singletonEntityConstructor(); 219 | const relationshipMetadata = getToOneRelationshipMetadata( 220 | singletonEntityConstructor, 221 | `relationshipProperty` 222 | ); 223 | expect( 224 | generateOneRelationshipKey(instance, relationshipMetadata) 225 | ).toEqual(generatePropertyKey(instance, relationshipMetadata)); 226 | }); 227 | it(`generates a key for a singleton with custom keys`, async () => { 228 | const instance = new singletonEntityWithCustomKeysConstructor(); 229 | const relationshipMetadata = getToOneRelationshipMetadata( 230 | singletonEntityWithCustomKeysConstructor, 231 | `relationshipProperty` 232 | ); 233 | expect( 234 | generateOneRelationshipKey(instance, relationshipMetadata) 235 | ).toEqual(generatePropertyKey(instance, relationshipMetadata)); 236 | }); 237 | it(`generates a key with default keys`, async () => { 238 | const instance = new complexEntityConstructor(); 239 | const otherRelationshipMetadata = getToOneRelationshipMetadata( 240 | complexEntityConstructor, 241 | `relationshipProperty` 242 | ); 243 | 244 | expect(generatePropertyKey(instance, otherRelationshipMetadata)).toEqual( 245 | generatePropertyKey(instance, otherRelationshipMetadata) 246 | ); 247 | }); 248 | it(`generates a key with custom keys`, async () => { 249 | const instance = new complexEntityWithCustomKeysConstructor(); 250 | const otherRelationshipMetadata = getToOneRelationshipMetadata( 251 | complexEntityWithCustomKeysConstructor, 252 | `relationshipProperty` 253 | ); 254 | 255 | expect(generatePropertyKey(instance, otherRelationshipMetadata)).toEqual( 256 | generatePropertyKey(instance, otherRelationshipMetadata) 257 | ); 258 | }); 259 | }); 260 | // describe(`generateManyRelationshipKey`, () => { 261 | // let relationshipInstance: BaseEntity; 262 | 263 | // beforeEach(() => { 264 | // relationshipInstance = new complexEntityConstructor(); 265 | // }); 266 | 267 | // it(`generates a key for a singleton`, async () => { 268 | // const instance = new singletonEntityConstructor(); 269 | // const relationshipMetadata = getToOneRelationshipMetadata( 270 | // singletonEntityConstructor, 271 | // `relationshipProperty` 272 | // ); 273 | // expect( 274 | // generateManyRelationshipKey( 275 | // instance, 276 | // relationshipMetadata, 277 | // relationshipInstance 278 | // ) 279 | // ).toEqual(`SingletonEntity:relationshipProperty:12345`); 280 | // }); 281 | // it(`generates a key for a singleton with custom keys`, async () => { 282 | // const instance = new singletonEntityWithCustomKeysConstructor(); 283 | // const relationshipMetadata = getToOneRelationshipMetadata( 284 | // singletonEntityWithCustomKeysConstructor, 285 | // `relationshipProperty` 286 | // ); 287 | // expect( 288 | // generateManyRelationshipKey( 289 | // instance, 290 | // relationshipMetadata, 291 | // relationshipInstance 292 | // ) 293 | // ).toEqual(`CustomSingletonEntityKey:CustomRelationshipKey:12345`); 294 | // }); 295 | // it(`generates a key with default keys`, async () => { 296 | // const instance = new complexEntityConstructor(); 297 | // const otherRelationshipMetadata = getToOneRelationshipMetadata( 298 | // complexEntityConstructor, 299 | // `relationshipProperty` 300 | // ); 301 | 302 | // expect( 303 | // generateManyRelationshipKey( 304 | // instance, 305 | // otherRelationshipMetadata, 306 | // relationshipInstance 307 | // ) 308 | // ).toEqual(`ComplexEntity:12345:relationshipProperty:12345`); 309 | // }); 310 | // it(`generates a key with custom keys`, async () => { 311 | // const instance = new complexEntityWithCustomKeysConstructor(); 312 | // const otherRelationshipMetadata = getToOneRelationshipMetadata( 313 | // complexEntityWithCustomKeysConstructor, 314 | // `relationshipProperty` 315 | // ); 316 | 317 | // expect( 318 | // generateManyRelationshipKey( 319 | // instance, 320 | // otherRelationshipMetadata, 321 | // relationshipInstance 322 | // ) 323 | // ).toEqual(`CustomComplexEntityKey:12345:CustomRelationshipKey:12345`); 324 | // }); 325 | // }); 326 | 327 | describe(`InvalidKeyError`, () => { 328 | it(`is thrown when generating a key with the key separator in it`, () => { 329 | expect(() => { 330 | assertKeysDoNotContainSeparator(datastore, [`test:bad:key`, `:`]); 331 | }).toThrow(InvalidKeyError); 332 | }); 333 | }); 334 | }); 335 | -------------------------------------------------------------------------------- /src/utils/keyGeneration.ts: -------------------------------------------------------------------------------- 1 | import "../metadata"; 2 | 3 | import { Key, Value, Datastore } from "../Datastore/Datastore"; 4 | import { BaseEntity, EntityConstructor } from "../Entity/Entity"; 5 | import { ColumnMetadata } from "../Column/columnMetadata"; 6 | import { getPrimaryColumnMetadata, getPrimaryColumnValue } from "./columns"; 7 | import { getDatastore } from "./datastore"; 8 | import { getConstructor, getEntityMetadata } from "./entities"; 9 | import { InvalidKeyError } from "./errors"; 10 | import { Metadata } from "./metadata"; 11 | import { 12 | ToOneRelationshipMetadata, 13 | ToManyRelationshipMetadata, 14 | } from "../Relationship/relationshipMetadata"; 15 | 16 | export const assertKeysDoNotContainSeparator = ( 17 | datastore: Datastore, 18 | keys: Key[] 19 | ): void => { 20 | for (const key of keys) { 21 | if (key.toString().includes(datastore.keySeparator)) 22 | throw new InvalidKeyError(key, `Key contains Datastore's Key Separator`); 23 | } 24 | }; 25 | 26 | const getEntityKey = (constructor: EntityConstructor): Key => { 27 | const datastore = getDatastore(constructor); 28 | const entityMetadata = getEntityMetadata(constructor); 29 | assertKeysDoNotContainSeparator(datastore, [entityMetadata.key]); 30 | return entityMetadata.key; 31 | }; 32 | 33 | // Author:UUID-HERE:name 34 | // or, if singleton, ApplicationConfiguration:password 35 | export const generatePropertyKey = ( 36 | instance: BaseEntity, 37 | metadata: Metadata 38 | ): Key => { 39 | const constructor = getConstructor(instance); 40 | const datastore = getDatastore(constructor); 41 | const keys = [getEntityKey(constructor)]; 42 | const primaryColumnMetadata = getPrimaryColumnMetadata(constructor); 43 | 44 | if (primaryColumnMetadata) { 45 | keys.push(getPrimaryColumnValue(instance)); 46 | } 47 | 48 | keys.push(metadata.key); 49 | 50 | assertKeysDoNotContainSeparator(datastore, keys); 51 | return keys.join(datastore.keySeparator); 52 | }; 53 | 54 | // Author:email:abc@xyz.com:UUID-HERE 55 | // or, if isUnique, Author:email:abc@xyz.com 56 | export const generateIndexablePropertyKey = ( 57 | instance: BaseEntity, 58 | columnMetadata: ColumnMetadata, 59 | value: Value 60 | ): Key => { 61 | const constructor = getConstructor(instance); 62 | const datastore = getDatastore(constructor); 63 | 64 | const keys = [getEntityKey(constructor), columnMetadata.key, value]; 65 | 66 | if (!columnMetadata.isUnique) { 67 | keys.push(generateRelationshipKey(instance)); 68 | } 69 | 70 | assertKeysDoNotContainSeparator(datastore, keys); 71 | return keys.join(datastore.keySeparator); 72 | }; 73 | 74 | // Author:email:abc@xyz.com 75 | export const getUniqueSearchKey = ( 76 | constructor: EntityConstructor, 77 | columnMetadata: ColumnMetadata, 78 | value: Value 79 | ) => { 80 | const datastore = getDatastore(constructor); 81 | 82 | const keys = [getEntityKey(constructor), columnMetadata.key, value]; 83 | 84 | assertKeysDoNotContainSeparator(datastore, keys); 85 | return keys.join(datastore.keySeparator); 86 | }; 87 | 88 | export const getIndexableSearchKey = ( 89 | constructor: EntityConstructor, 90 | columnMetadata: ColumnMetadata, 91 | value: Value 92 | ) => { 93 | const datastore = getDatastore(constructor); 94 | 95 | return ( 96 | getUniqueSearchKey(constructor, columnMetadata, value) + 97 | datastore.keySeparator 98 | ); 99 | }; 100 | 101 | // UUID-HERE 102 | // or, if singleton, ApplicationConfiguration 103 | export const generateRelationshipKey = (instance: BaseEntity): Key => { 104 | const constructor = getConstructor(instance); 105 | const datastore = getDatastore(constructor); 106 | const primaryColumnMetadata = getPrimaryColumnMetadata(constructor); 107 | 108 | let key; 109 | if (primaryColumnMetadata) { 110 | key = getPrimaryColumnValue(instance); 111 | } else { 112 | key = getEntityKey(constructor); 113 | } 114 | 115 | assertKeysDoNotContainSeparator(datastore, [key]); 116 | return key; 117 | }; 118 | 119 | // Author:UUID-HERE:passport 120 | export const generateOneRelationshipKey = ( 121 | instance: BaseEntity, 122 | toOneRelationshipMetadata: ToOneRelationshipMetadata 123 | ): Key => generatePropertyKey(instance, toOneRelationshipMetadata); 124 | 125 | // Author:UUID-HERE:books:UUID-HERE 126 | export const generateManyRelationshipKey = ( 127 | instance: BaseEntity, 128 | toManyRelationshipMetadata: ToManyRelationshipMetadata, 129 | relationshipInstance: BaseEntity 130 | ): Key => { 131 | const constructor = getConstructor(instance); 132 | const datastore = getDatastore(constructor); 133 | const keys = [ 134 | generatePropertyKey(instance, toManyRelationshipMetadata), 135 | generateRelationshipKey(relationshipInstance), 136 | ]; 137 | return keys.join(datastore.keySeparator); 138 | }; 139 | 140 | export const generateManyRelationshipSearchKey = ( 141 | instance: BaseEntity, 142 | toManyRelationshipMetadata: ToManyRelationshipMetadata 143 | ): Key => { 144 | const constructor = getConstructor(instance); 145 | const datastore = getDatastore(constructor); 146 | 147 | return ( 148 | generatePropertyKey(instance, toManyRelationshipMetadata) + 149 | datastore.keySeparator 150 | ); 151 | }; 152 | 153 | export const extractValueFromSearchKey = ( 154 | datastore: Datastore, 155 | key: Key, 156 | searchKey: Key 157 | ): Key => key.split(searchKey)[1].split(datastore.keySeparator)[0]; 158 | -------------------------------------------------------------------------------- /src/utils/metadata.ts: -------------------------------------------------------------------------------- 1 | import "../metadata"; 2 | 3 | import { EntityConstructor, BaseEntity } from "../Entity/Entity"; 4 | import { SetupError } from "./errors"; 5 | import { Key } from "../Datastore/Datastore"; 6 | import { getColumnMetadatas } from "./columns"; 7 | import { 8 | getToOneRelationshipMetadatas, 9 | getToManyRelationshipMetadatas, 10 | } from "./relationships"; 11 | 12 | export interface Metadata { 13 | key: Key; 14 | property?: PropertyKey; // TODO: Clean-up. Separate Relationship, Column and Entity Metadatas properly 15 | } 16 | 17 | export class MetadataSetupError extends SetupError { 18 | constructor( 19 | constructor: BaseEntity, 20 | metadata: Metadata, 21 | message = `Unknown Error` 22 | ) { 23 | super( 24 | `Could not setup the Column, ${metadata.key}, on Entity, ${constructor.name}: ${message}` 25 | ); 26 | this.name = `MetadataSetupError`; 27 | } 28 | } 29 | 30 | export const assertKeyNotInUse = ( 31 | constructor: EntityConstructor, 32 | metadata: Metadata, 33 | { 34 | getMetadatas, 35 | }: { 36 | getMetadatas: (constructor: EntityConstructor) => Metadata[]; 37 | } 38 | ): void => { 39 | const metadatas = getMetadatas(constructor); 40 | const keysInUse = metadatas.map((metadata) => metadata.key); 41 | 42 | if (keysInUse.indexOf(metadata.key) !== -1) 43 | throw new MetadataSetupError( 44 | constructor, 45 | metadata, 46 | `Key is already in use` 47 | ); 48 | }; 49 | 50 | export const getMetadatas = ( 51 | key: symbol, 52 | constructor: EntityConstructor 53 | ): Metadata[] => Reflect.getMetadata(key, constructor) || []; 54 | 55 | const setMetadatas = ( 56 | key: symbol, 57 | constructor: EntityConstructor, 58 | metadatas: Metadata[] 59 | ): void => Reflect.defineMetadata(key, metadatas, constructor); 60 | 61 | export const getMetadata = ( 62 | metadatas: Metadata[], 63 | property: PropertyKey, 64 | throwError: () => void 65 | ): Metadata => { 66 | const metadata = metadatas.find(({ property: p }) => p === property); 67 | if (metadata === undefined) throwError(); 68 | 69 | return metadata as Metadata; 70 | }; 71 | 72 | export const setMetadata = ( 73 | key: symbol, 74 | constructor: EntityConstructor, 75 | metadata: Metadata 76 | ): void => { 77 | const metadatas = getMetadatas(key, constructor); 78 | metadatas.push(metadata); 79 | setMetadatas(key, constructor, metadatas); 80 | }; 81 | 82 | export const getPropertyMetadatas = (constructor: EntityConstructor) => [ 83 | ...getColumnMetadatas(constructor), 84 | ...getToOneRelationshipMetadatas(constructor), 85 | ...getToManyRelationshipMetadatas(constructor), 86 | ]; 87 | -------------------------------------------------------------------------------- /src/utils/relationships.test.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from "../Entity/Entity"; 2 | import { Datastore } from "../Datastore/Datastore"; 3 | import { MemoryDatastore } from "../MemoryDatastore/MemoryDatastore"; 4 | import { getToOneRelationshipMetadata } from "./relationships"; 5 | import { RelationshipLookupError } from "./errors"; 6 | 7 | describe(`relationships`, () => { 8 | let datastore: Datastore; 9 | beforeEach(() => { 10 | datastore = new MemoryDatastore(); 11 | }); 12 | describe(`RelationshipLookupError`, () => { 13 | it(`is thrown when it does not exist`, () => { 14 | @Entity({ datastore }) 15 | class MyEntity {} 16 | 17 | expect(() => { 18 | getToOneRelationshipMetadata(MyEntity, `fakeRelationship`); 19 | }).toThrow(RelationshipLookupError); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/utils/relationships.ts: -------------------------------------------------------------------------------- 1 | import { EntityConstructor, PropertyKey, BaseEntity } from "../Entity/Entity"; 2 | import { 3 | TOONE_RELATIONSHIP_KEY, 4 | TOMANY_RELATIONSHIP_KEY, 5 | ToOneRelationshipMetadata, 6 | ToManyRelationshipMetadata, 7 | RelationshipMetadata, 8 | } from "../Relationship/relationshipMetadata"; 9 | import { RelationshipLookupError } from "./errors"; 10 | import { getMetadatas, getMetadata, setMetadata } from "./metadata"; 11 | import { getConstructor } from "./entities"; 12 | 13 | export const getToOneRelationshipMetadatas = ( 14 | constructor: EntityConstructor 15 | ): ToOneRelationshipMetadata[] => 16 | getMetadatas( 17 | TOONE_RELATIONSHIP_KEY, 18 | constructor 19 | ) as ToOneRelationshipMetadata[]; 20 | 21 | export const getToManyRelationshipMetadatas = ( 22 | constructor: EntityConstructor 23 | ): ToManyRelationshipMetadata[] => 24 | getMetadatas( 25 | TOMANY_RELATIONSHIP_KEY, 26 | constructor 27 | ) as ToManyRelationshipMetadata[]; 28 | 29 | export const getToOneRelationshipMetadata = ( 30 | constructor: EntityConstructor, 31 | property: PropertyKey 32 | ): ToOneRelationshipMetadata => { 33 | const toOneRelationshipMetadatas = getToOneRelationshipMetadatas(constructor); 34 | return getMetadata(toOneRelationshipMetadatas, property, () => { 35 | throw new RelationshipLookupError( 36 | constructor, 37 | property, 38 | `Could not find ToOne Relationship. Has it been defined yet?` 39 | ); 40 | }) as ToOneRelationshipMetadata; 41 | }; 42 | 43 | export const getToManyRelationshipMetadata = ( 44 | constructor: EntityConstructor, 45 | property: PropertyKey 46 | ): ToManyRelationshipMetadata => { 47 | const toManyRelationshipMetadatas = getToManyRelationshipMetadatas( 48 | constructor 49 | ); 50 | return getMetadata(toManyRelationshipMetadatas, property, () => { 51 | throw new RelationshipLookupError( 52 | constructor, 53 | property, 54 | `Could not find ToMany Relationship. Has it been defined yet?` 55 | ); 56 | }) as ToManyRelationshipMetadata; 57 | }; 58 | 59 | export const getRelationshipMetadata = ( 60 | constructor: EntityConstructor, 61 | property: PropertyKey 62 | ): RelationshipMetadata => { 63 | const relationshipMetadatas = [ 64 | ...getToOneRelationshipMetadatas(constructor), 65 | ...getToManyRelationshipMetadatas(constructor), 66 | ]; 67 | 68 | return getMetadata(relationshipMetadatas, property, () => { 69 | throw new RelationshipLookupError( 70 | constructor, 71 | property, 72 | `Could not find Relationship. Has it been defined yet?` 73 | ); 74 | }) as RelationshipMetadata; 75 | }; 76 | 77 | export const setToOneRelationshipMetadata = ( 78 | constructor: EntityConstructor, 79 | toOneRelationshipMetadata: ToOneRelationshipMetadata 80 | ): void => 81 | setMetadata(TOONE_RELATIONSHIP_KEY, constructor, toOneRelationshipMetadata); 82 | 83 | export const setToManyRelationshipMetadata = ( 84 | constructor: EntityConstructor, 85 | toManyRelationshipMetadata: ToManyRelationshipMetadata 86 | ): void => 87 | setMetadata(TOMANY_RELATIONSHIP_KEY, constructor, toManyRelationshipMetadata); 88 | 89 | export async function* arrayToAsyncGenerator(array: T[]) { 90 | for (const item of array) { 91 | yield item; 92 | } 93 | } 94 | 95 | export async function* combineAsyncGenerators( 96 | a: AsyncGenerator, 97 | b: AsyncGenerator 98 | ): AsyncGenerator { 99 | for await (const item of a) { 100 | yield item; 101 | } 102 | for await (const item of b) { 103 | yield item; 104 | } 105 | } 106 | 107 | export const makeCachingGenerator = ( 108 | instance: BaseEntity, 109 | toManyRelationshipMetadata: ToManyRelationshipMetadata 110 | ) => { 111 | const constructor = getConstructor(instance); 112 | 113 | return async function* cacheGenerator(instances: AsyncGenerator) { 114 | for await (const relationshipInstance of instances) { 115 | const cachedRelationshipInstances = [ 116 | ...(toManyRelationshipMetadata.instances.get(instance) || []), 117 | relationshipInstance, 118 | ]; 119 | toManyRelationshipMetadata.instances.set( 120 | instance, 121 | cachedRelationshipInstances 122 | ); 123 | setToManyRelationshipMetadata(constructor, toManyRelationshipMetadata); 124 | 125 | yield relationshipInstance; 126 | } 127 | }; 128 | }; 129 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "ES2018" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | "declaration": true /* Generates corresponding '.d.ts' file. */, 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | "sourceMap": true /* Generates corresponding '.map' file. */, 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./dist/" /* Redirect output structure to the directory. */, 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true /* Enable all strict type-checking options. */, 27 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 28 | "strictNullChecks": true /* Enable strict null checks. */, 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 33 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [] /* List of folders to include type definitions from. */, 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | 53 | "skipLibCheck": true, 54 | /* Source Map Options */ 55 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 56 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 57 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 58 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 59 | 60 | /* Experimental Options */ 61 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 62 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ 63 | }, 64 | "exclude": [ 65 | "node_modules/**", 66 | "dist", 67 | "src/**/*.test.ts", 68 | "src/**/*.testhelpers.ts" 69 | ] 70 | } 71 | --------------------------------------------------------------------------------