├── .all-contributorsrc ├── .editorconfig ├── .env.example ├── .eslintrc.js ├── .gitignore ├── .opensource └── project.json ├── .prettierrc.json ├── .travis.yml ├── .vscode └── launch.json ├── CNAME ├── LICENSE ├── README.md ├── docgen ├── .nojekyll ├── Batches.md ├── Core_Concepts.md ├── Custom_Repositories.md ├── Manage_Data.md ├── Read_Data.md ├── Subcollections.md ├── Transactions.md ├── Validation.md ├── build.ts ├── index.html ├── sidebar.md ├── typedoc-docsify │ └── theme.js └── typedoc.json ├── jest.config.js ├── package.json ├── src ├── AbstractFirestoreRepository.ts ├── BaseFirestoreRepository.spec.ts ├── BaseFirestoreRepository.ts ├── BaseRepository.ts ├── Batch │ ├── BaseFirestoreBatchRepository.spec.ts │ ├── BaseFirestoreBatchRepository.ts │ ├── FirestoreBatch.spec.ts │ ├── FirestoreBatch.ts │ ├── FirestoreBatchSingleRepository.ts │ └── FirestoreBatchUnit.ts ├── Decorators │ ├── Collection.spec.ts │ ├── Collection.ts │ ├── CustomRepository.spec.ts │ ├── CustomRepository.ts │ ├── Ignore.spec.ts │ ├── Ignore.ts │ ├── Serialize.spec.ts │ ├── Serialize.ts │ ├── SubCollection.spec.ts │ ├── SubCollection.ts │ └── index.ts ├── Errors │ ├── ValidationError.ts │ └── index.ts ├── MetadataStorage.spec.ts ├── MetadataStorage.ts ├── MetadataUtils.ts ├── QueryBuilder.spec.ts ├── QueryBuilder.ts ├── Transaction │ ├── BaseFirestoreTransactionRepository.spec.ts │ ├── BaseFirestoreTransactionRepository.ts │ ├── FirestoreTransaction.spec.ts │ └── FirestoreTransaction.ts ├── TypeGuards.ts ├── helpers.spec.ts ├── helpers.ts ├── index.ts ├── todo.md ├── tsconfig.json ├── types.ts ├── utils.spec.ts └── utils.ts ├── test ├── BandCollection.ts ├── fixture.ts ├── functional │ ├── 1-simple_repository.spec.ts │ ├── 2-custom_repositories.spec.ts │ ├── 3-subcollections.spec.ts │ ├── 4-transactions.spec.ts │ ├── 5-batches.spec.ts │ ├── 6-document-references.spec.ts │ ├── 7-validations.spec.ts │ ├── 8-ignore-properties.spec.ts │ └── 8-serialized-properties.spec.ts ├── jest.integration.js ├── setup.ts └── tsconfig.json ├── tsconfig.json └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "wovalle", 10 | "name": "Willy Ovalle", 11 | "avatar_url": "https://avatars0.githubusercontent.com/u/7854116?v=4", 12 | "profile": "http://twitter.com/wovalle", 13 | "contributions": [ 14 | "code", 15 | "doc", 16 | "example", 17 | "ideas", 18 | "review", 19 | "test" 20 | ] 21 | }, 22 | { 23 | "login": "mamodom", 24 | "name": "Maximo Dominguez", 25 | "avatar_url": "https://avatars3.githubusercontent.com/u/5097424?v=4", 26 | "profile": "https://github.com/mamodom", 27 | "contributions": [ 28 | "ideas", 29 | "code" 30 | ] 31 | }, 32 | { 33 | "login": "jonesnc", 34 | "name": "Nathan Jones", 35 | "avatar_url": "https://avatars0.githubusercontent.com/u/1293145?v=4", 36 | "profile": "https://github.com/jonesnc", 37 | "contributions": [ 38 | "code" 39 | ] 40 | }, 41 | { 42 | "login": "skalashnyk", 43 | "name": "Sergii Kalashnyk", 44 | "avatar_url": "https://avatars3.githubusercontent.com/u/18640514?v=4", 45 | "profile": "https://github.com/skalashnyk", 46 | "contributions": [ 47 | "code" 48 | ] 49 | }, 50 | { 51 | "login": "skneko", 52 | "name": "SaltyKawaiiNeko", 53 | "avatar_url": "https://avatars1.githubusercontent.com/u/13376606?v=4", 54 | "profile": "http://skneko.moe/", 55 | "contributions": [ 56 | "code", 57 | "ideas" 58 | ] 59 | }, 60 | { 61 | "login": "z-hirschtritt", 62 | "name": "z-hirschtritt", 63 | "avatar_url": "https://avatars1.githubusercontent.com/u/35265735?v=4", 64 | "profile": "https://github.com/z-hirschtritt", 65 | "contributions": [ 66 | "code", 67 | "ideas" 68 | ] 69 | }, 70 | { 71 | "login": "joemckie", 72 | "name": "Joe McKie", 73 | "avatar_url": "https://avatars1.githubusercontent.com/u/4980618?v=4", 74 | "profile": "http://joemck.ie/", 75 | "contributions": [ 76 | "code", 77 | "ideas" 78 | ] 79 | }, 80 | { 81 | "login": "smddzcy", 82 | "name": "Samed Düzçay", 83 | "avatar_url": "https://avatars3.githubusercontent.com/u/13895224?v=4", 84 | "profile": "https://www.smddzcy.com/", 85 | "contributions": [ 86 | "code" 87 | ] 88 | }, 89 | { 90 | "login": "stefdelec", 91 | "name": "stefdelec", 92 | "avatar_url": "https://avatars1.githubusercontent.com/u/12082478?v=4", 93 | "profile": "https://github.com/stefdelec", 94 | "contributions": [ 95 | "code" 96 | ] 97 | }, 98 | { 99 | "login": "LukaszKuciel", 100 | "name": "Łukasz Kuciel", 101 | "avatar_url": "https://avatars0.githubusercontent.com/u/35846271?v=4", 102 | "profile": "http://www.innvia.com", 103 | "contributions": [ 104 | "code" 105 | ] 106 | }, 107 | { 108 | "login": "Fame513", 109 | "name": "Yaroslav Nekryach", 110 | "avatar_url": "https://avatars1.githubusercontent.com/u/2944505?v=4", 111 | "profile": "https://github.com/Fame513", 112 | "contributions": [ 113 | "code" 114 | ] 115 | }, 116 | { 117 | "login": "tomorroN", 118 | "name": "Dmytro Nikitiuk", 119 | "avatar_url": "https://avatars0.githubusercontent.com/u/40293865?v=4", 120 | "profile": "https://www.linkedin.com/in/dmytro-nikitiuk/", 121 | "contributions": [ 122 | "code" 123 | ] 124 | }, 125 | { 126 | "login": "JingWangTW", 127 | "name": "JingWangTW", 128 | "avatar_url": "https://avatars0.githubusercontent.com/u/20182367?v=4", 129 | "profile": "https://github.com/JingWangTW", 130 | "contributions": [ 131 | "code" 132 | ] 133 | }, 134 | { 135 | "login": "rinkstiekema", 136 | "name": "Rink Stiekema", 137 | "avatar_url": "https://avatars.githubusercontent.com/u/5337711?v=4", 138 | "profile": "https://github.com/rinkstiekema", 139 | "contributions": [ 140 | "code" 141 | ] 142 | }, 143 | { 144 | "login": "danieleisenhardt", 145 | "name": "Daniel", 146 | "avatar_url": "https://avatars.githubusercontent.com/u/2325519?v=4", 147 | "profile": "https://github.com/danieleisenhardt", 148 | "contributions": [ 149 | "code" 150 | ] 151 | }, 152 | { 153 | "login": "MarZab", 154 | "name": "Marko Zabreznik", 155 | "avatar_url": "https://avatars.githubusercontent.com/u/1311249?v=4", 156 | "profile": "https://zabreznik.net", 157 | "contributions": [ 158 | "code" 159 | ] 160 | }, 161 | { 162 | "login": "jomendez", 163 | "name": "Jose Mendez", 164 | "avatar_url": "https://avatars.githubusercontent.com/u/8228498?v=4", 165 | "profile": "http://jomendez.com", 166 | "contributions": [ 167 | "code" 168 | ] 169 | } 170 | ], 171 | "contributorsPerLine": 7, 172 | "projectName": "fireorm", 173 | "projectOwner": "wovalle", 174 | "repoType": "github", 175 | "repoHost": "https://github.com", 176 | "skipCi": true 177 | } 178 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | root = true 3 | charset = utf-8 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | insert_final_newline = true -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | FIRESTORE_PROJECT_ID= 2 | FIREBASE_DATABASE_URL= 3 | FIRESTORE_CLIENT_EMAIL= 4 | FIRESTORE_PRIVATE_KEY_BASE_64= 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignorePatterns: ['lib/', 'coverage/'], 3 | parserOptions: { 4 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features 5 | sourceType: 'module', // Allows for the use of imports 6 | }, 7 | env: { 8 | es6: true, 9 | node: true, 10 | }, 11 | extends: [ 12 | 'eslint:recommended', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 13 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 14 | ], 15 | overrides: [ 16 | { 17 | files: ['**/*.ts'], 18 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 19 | parserOptions: { 20 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features 21 | sourceType: 'module', // Allows for the use of imports 22 | }, 23 | extends: [ 24 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin, 25 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 26 | ], 27 | rules: { 28 | '@typescript-eslint/interface-name-prefix': 'off', 29 | '@typescript-eslint/explicit-function-return-type': 'off', 30 | '@typescript-eslint/explicit-module-boundary-types': 'off', 31 | 'no-restricted-syntax': [ 32 | 'error', 33 | { 34 | selector: 'ExportDefaultDeclaration', 35 | message: 'Prefer named exports', 36 | }, 37 | ], 38 | }, 39 | }, 40 | { 41 | files: ['**/*.spec.ts', '**/*.int-spec.ts', 'test/setup.ts'], 42 | env: { 43 | jest: true, 44 | node: true, 45 | }, 46 | }, 47 | ], 48 | }; 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | firestore.creds.json 64 | lib 65 | docs -------------------------------------------------------------------------------- /.opensource/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Fireorm: ORM for firestore", 3 | "type": "library", 4 | "platforms": ["Web", "Node"], 5 | "content": "README.md", 6 | "related": ["wovalle/fireorm"], 7 | "tabs": [ 8 | { 9 | "title": "Documentation", 10 | "href": "https://fireorm.js.org" 11 | }, 12 | { 13 | "title": "Github", 14 | "href": "https://github.com/wovalle/fireorm" 15 | }, 16 | { 17 | "title": "NPM", 18 | "href": "https://www.npmjs.com/package/fireorm" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "printWidth": 100, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "trailingComma": "es5", 7 | "arrowParens": "avoid" 8 | } 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | 5 | stages: 6 | - test 7 | - name: deploy 8 | if: branch = master AND type != pull_request 9 | 10 | jobs: 11 | include: 12 | - stage: test 13 | script: 14 | - yarn eslint '*/**/*.{js,ts}' --quiet 15 | # - commitlint-travis 16 | - yarn test 17 | - yarn test:integration 18 | - yarn add --dev codecov 19 | - yarn codecov 20 | 21 | - stage: deploy 22 | script: 23 | - yarn build 24 | - yarn build:doc 25 | - yarn semantic-release 26 | - yarn deploy:doc 27 | 28 | cache: yarn 29 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch current file w/ ts-node", 11 | "protocol": "inspector", 12 | "args": ["${relativeFile}"], 13 | "cwd": "${workspaceRoot}", 14 | "runtimeArgs": ["-r", "ts-node/register"], 15 | "internalConsoleOptions": "openOnSessionStart" 16 | }, 17 | { 18 | "type": "node", 19 | "request": "launch", 20 | "name": "Launch current integration test w/ jest", 21 | "runtimeArgs": ["--inspect-brk", "${workspaceRoot}/node_modules/.bin/jest", "--runInBand"], 22 | "args": [ 23 | "-c", 24 | "test/jest.integration.js", 25 | "test/functional/4-transactions.spec.ts", 26 | "--verbose" 27 | ], 28 | "console": "integratedTerminal", 29 | "internalConsoleOptions": "neverOpen", 30 | "port": 9229 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | fireorm.js.org 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Willy Ovalle 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fireorm🔥 2 | 3 | [![NPM Version](https://img.shields.io/npm/v/fireorm.svg?style=flat)](https://www.npmjs.com/package/fireorm) 4 | [![Build Status](https://travis-ci.com/wovalle/fireorm.svg?token=KsyisFHzgCusk2sapuJe&branch=master)](https://travis-ci.com/wovalle/fireorm) 5 | [![Typescript lang](https://img.shields.io/badge/Language-Typescript-Blue.svg)](https://www.typescriptlang.org) 6 | [![All Contributors](https://img.shields.io/badge/all_contributors-7-orange.svg?style=flat-square)](#contributors) 7 | [![License](https://img.shields.io/npm/l/fireorm.svg?style=flat)](https://www.npmjs.com/package/fireorm) 8 | 9 | Fireorm is a tiny wrapper on top of firebase-admin that makes life easier when dealing with a Firestore database. Fireorm tries to ease the development of apps that rely on Firestore at the database layer by abstracting the access layer providing a familiar repository pattern. It basically helps us not worry about Firestore details and focus on what matters: adding cool new features! 10 | 11 | You can read more about the motivations and features of fireorm [on its introductory post](https://medium.com/p/ba7734644684). Also, the [API documentation](https://wovalle.github.io/fireorm) is available. 12 | 13 | > :warning: Due to personal and professional reasons, I am unable to continue active development and support for this project. 14 | > 15 | >However, I want to assure you that the project will remain available for use and that I will still check pull requests if needed. I encourage the community to continue to use and contribute to this project, and I hope that it will continue to be a valuable resource for developers. 16 | 17 | 18 | 19 | 20 | ## Usage 21 | 22 | 1. Install the npm package: 23 | 24 | ```bash 25 | yarn add fireorm reflect-metadata #or npm install fireorm reflect-metadata 26 | 27 | # note: the reflect-metadata shim is required 28 | ``` 29 | 30 | 2. [Initialize](https://firebase.google.com/docs/firestore/quickstart#initialize) your Firestore application: 31 | 32 | ```typescript 33 | import * as admin from 'firebase-admin'; 34 | import * as fireorm from 'fireorm'; 35 | 36 | const serviceAccount = require('../firestore.creds.json'); 37 | 38 | admin.initializeApp({ 39 | credential: admin.credential.cert(serviceAccount), 40 | databaseURL: `https://${serviceAccount.project_id}.firebaseio.com`, 41 | }); 42 | 43 | const firestore = admin.firestore(); 44 | fireorm.initialize(firestore); 45 | ``` 46 | 47 | 3. Create your Firestore models: 48 | 49 | ```typescript 50 | import { Collection } from 'fireorm'; 51 | 52 | @Collection() 53 | class Todo { 54 | id: string; 55 | text: string; 56 | done: Boolean; 57 | } 58 | ``` 59 | 60 | 4. Do cool stuff with fireorm! 61 | 62 | ```typescript 63 | import { Collection, getRepository } from 'fireorm'; 64 | 65 | @Collection() 66 | class Todo { 67 | id: string; 68 | text: string; 69 | done: Boolean; 70 | } 71 | 72 | const todoRepository = getRepository(Todo); 73 | 74 | const todo = new Todo(); 75 | todo.text = "Check fireorm's Github Repository"; 76 | todo.done = false; 77 | 78 | const todoDocument = await todoRepository.create(todo); // Create todo 79 | const mySuperTodoDocument = await todoRepository.findById(todoDocument.id); // Read todo 80 | await todoRepository.update(mySuperTodoDocument); // Update todo 81 | await todoRepository.delete(mySuperTodoDocument.id); // Delete todo 82 | ``` 83 | 84 | ### Firebase Complex Data Types 85 | 86 | Firestore has support for [complex data types](https://firebase.google.com/docs/firestore/manage-data/data-types) such as GeoPoint and Reference. Full handling of complex data types is [being handled in this issue](https://github.com/wovalle/fireorm/issues/58). Temporarily, fireorm will export [Class Transformer's @Type](https://github.com/typestack/class-transformer#working-with-nested-objects) decorator. It receives a lamda where you return the type you want to cast to. [See GeoPoint Example](https://github.com/wovalle/fireorm/blob/d8f79090b7006675f2cb5014bb5ca7a9dfbfa8c1/src/BaseFirestoreRepository.spec.ts#L471-L476). 87 | 88 | #### Limitations 89 | 90 | If you want to cast GeoPoints to your custom class, it must have `latitude: number` and `longitude: number` as public class fields. Hopefully this won't be a limitation in v1. 91 | 92 | ## Development 93 | 94 | ### Initial Setup 95 | 96 | 1. Clone the project from github: 97 | 98 | ```bash 99 | git clone git@github.com:wovalle/fireorm.git 100 | ``` 101 | 102 | 2. Install the dependencies: 103 | 104 | ```bash 105 | yarn # npm install 106 | ``` 107 | 108 | ### Testing 109 | 110 | Fireorm has two types of tests: 111 | 112 | - Unit tests: `yarn test # or npm test` 113 | - Integration tests: `yarn test:integration # or npm test:integration` 114 | 115 | To be able to run the integration tests you need to [create a Firebase service account](https://firebase.google.com/docs/admin/setup#initialize_the_sdk) and declare some [environment variables](https://github.com/wovalle/fireorm/blob/master/test/setup.ts#L5-L13). 116 | 117 | Test files must follow the naming convention `*.test.ts` and use [jest](https://jestjs.io/) as the test runner. 118 | 119 | ### Committing 120 | 121 | This repo uses [Conventional Commits](https://www.conventionalcommits.org/) as the commit messages convention. 122 | 123 | ### Release a new version 124 | 125 | This repo uses [Semantic Release](https://github.com/semantic-release/semantic-release) to automatically release new versions as soon as they land on master. 126 | 127 | Commits must follow [Angular's Git Commit Guidelines](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines). 128 | 129 | Supported commit types (taken from [here](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#type)): 130 | 131 | - **feat:** A new feature 132 | - **fix:** A bug fix 133 | - **docs:** Documentation only changes 134 | - **style:** Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) 135 | - **refactor:** A code change that neither fixes a bug nor adds a feature 136 | - **perf:** A code change that improves performance 137 | - **test:** Adding missing or correcting existing tests 138 | - **chore:** Changes to the build process or auxiliary tools and libraries such as documentation generation 139 | 140 |
141 | Manual Release 142 | If, by any reason, a manual release must be done, these are the instructions: 143 | 144 | - To release a new version to npm, first we have to create a new tag: 145 | 146 | ```bash 147 | npm version [ major | minor | patch ] -m "Relasing version" 148 | git push --follow-tags 149 | ``` 150 | 151 | - Then we can publish the package to npm registry: 152 | 153 | ```bash 154 | npm publish 155 | ``` 156 | 157 | - To deploy the documentation: 158 | 159 | ```bash 160 | yarn deploy:doc # or npm deploy:doc 161 | ``` 162 | 163 |
164 | 165 | ### Documentation 166 | 167 | - Fireorm uses [typedoc](https://typedoc.org/) to automatically build the API documentation, to generate it: 168 | 169 | ```bash 170 | yarn build:doc # or npm build:doc 171 | ``` 172 | 173 | Documentation is automatically deployed on each commit to master and is hosted in [Github Pages](https://pages.github.com/) in this [link](https://wovalle.github.io/fireorm). 174 | 175 | ## Contributing 176 | 177 | Have a bug or a feature request? Please search [the issues](https://github.com/wovalle/fireorm/issues) to prevent duplication. If you couldn't find what you were looking for, [proceed to open a new one](https://github.com/wovalle/fireorm/issues/new). Pull requests are welcome! 178 | 179 | ## Contributors 180 | 181 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 |

Willy Ovalle

💻 📖 💡 🤔 👀 ⚠️

Maximo Dominguez

🤔 💻

Nathan Jones

💻

Sergii Kalashnyk

💻

SaltyKawaiiNeko

💻 🤔

z-hirschtritt

💻 🤔

Joe McKie

💻 🤔

Samed Düzçay

💻

stefdelec

💻

Łukasz Kuciel

💻

Yaroslav Nekryach

💻

Dmytro Nikitiuk

💻

JingWangTW

💻

Rink Stiekema

💻

Daniel

💻

Marko Zabreznik

💻

Jose Mendez

💻
211 | 212 | 213 | 214 | 215 | 216 | 217 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 218 | 219 | ## License 220 | 221 | MIT © [Willy Ovalle](https://github.com/wovalle). See [LICENSE](https://github.com/wovalle/fireorm/blob/master/LICENSE) for details. 222 | -------------------------------------------------------------------------------- /docgen/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wovalle/fireorm/eb29cdcb378a9ddc2a4f2e15974eeccaf23382fc/docgen/.nojekyll -------------------------------------------------------------------------------- /docgen/Batches.md: -------------------------------------------------------------------------------- 1 | # Batches 2 | 3 | Fireorm also has support for Firestore's [Batched Writes](https://firebase.google.com/docs/firestore/manage-data/transactions#batched-writes). 4 | 5 | ## Batches inside repositories 6 | 7 | Fireorm [repositories](CORE_CONCEPTS.md#FireormRepositories) have a [createBatch](Classes/BaseFirestoreRepository.md#CreateBatch) method that returns a [BatchRepository](Classes/FirestoreBatchRepository.md). The [BatchRepository](Classes/FirestoreBatchRepository.md) is an special type of repository that has methods to create, update and delete documents inside a batch. After adding all the operations that we want to run to the batch, we have to call the [commit](Classes/FirestoreBatchRepository.md#Commit) method to execute them. 8 | 9 | ```typescript 10 | import { getRepository, Collection } from 'fireorm'; 11 | import Band from './wherever-our-models-are'; 12 | 13 | const bandRepository = getRepository(Band); 14 | const dt = new Band(); 15 | dt.id = 'dream-theater'; 16 | dt.name = 'DreamTheater'; 17 | dt.formationYear = 1985; 18 | 19 | const batch = bandRepository.createBatch(); 20 | 21 | batch.create(dt); 22 | 23 | await batch.commit(); 24 | ``` 25 | 26 | ## Batches in multiple repositories 27 | 28 | Fireorm exports a [createBatch](Classes/BaseFirestoreRepository.md#CreateBatch) method that can be used to create batches with one or multiple repositories. It receives a lamda function where the first parameter corresponds to a [FirestoreBatch](Classes/FirestoreBatch.md) class. This class exposes a `getRepository` method that receives an [Model class](CORE_CONCEPTS.md#FireormModels) and returns a [BatchRepository](Classes/BatchRepository.md) of the given entity and can be used to create, update and delete documents. Once all operations are defined, we have to call the `commit` method of our BatchRepository to commit all the operations. 29 | 30 | ```typescript 31 | import { createBatch } from 'fireorm'; 32 | import { Band, Album } from './wherever-our-models-are'; 33 | 34 | const band = new Band(); 35 | band.id = 'dream-theater'; 36 | band.name = 'DreamTheater'; 37 | band.formationYear = 1985; 38 | 39 | const album1 = new Album(); 40 | album1.name = 'When Dream and Day Unite'; 41 | album1.releaseDate = new Date('1989-03-06T00:00:00.000Z'); 42 | album1.bandId = band.id; 43 | 44 | const album2 = new Album(); 45 | album2.name = 'Images and Words'; 46 | album2.releaseDate = new Date('1992-07-07T00:00:00.000Z'); 47 | album2.bandId = band.id; 48 | 49 | const batch = createBatch(); 50 | 51 | const bandBatchRepository = batch.getRepository(Band); 52 | const albumBatchRepository = batch.getRepository(Album); 53 | 54 | bandBatchRepository.create(band); 55 | albumBatchRepository.create(album1); 56 | albumBatchRepository.create(album2); 57 | 58 | await batch.commit(); 59 | ``` 60 | 61 | ## Batches in subcollections 62 | 63 | Fireorm exports a [createBatch](Classes/BaseFirestoreRepository.md#CreateBatch) method that can be used to create batches with one or multiple repositories. It receives a lamda function where the first parameter corresponds to a [FirestoreBatch](Classes/FirestoreBatch.md) class. This class exposes a `getRepository` method that receives an [Model class](CORE_CONCEPTS.md#FireormModels) and returns a [BatchRepository](Classes/BatchRepository.md) of the given entity and can be used to create, update and delete documents. Once all operations are defined, we have to call the `commit` method of our BatchRepository to commit all the operations. 64 | 65 | ```typescript 66 | import Band from './wherever-our-models-are'; 67 | import Album from './wherever-our-models-are'; 68 | 69 | const bandRepository = getRepository(Band); 70 | const band = bandRepository.findById('opeth'); 71 | 72 | // Initialize subcollection documents 73 | const firstAlbum = new Album(); 74 | firstAlbum.id: 'blackwater-park'; 75 | firstAlbum.name: 'Blackwater Park'; 76 | firstAlbum.releaseDate: new Date('2001-12-03T00:00:00.000Z'); 77 | 78 | const secondAlbum = new Album(); 79 | secondAlbum.id: 'deliverance'; 80 | secondAlbum.name: 'Deliverance'; 81 | secondAlbum.releaseDate: new Date('2002-11-12T00:00:00.000Z'); 82 | 83 | // Create a batch for the subcollection 84 | const albumsBatch = band.albums.createBatch(); 85 | 86 | // Add the subcollection entities 87 | albumsBatch.create(firstAlbum); 88 | albumsBatch.create(secondAlbum); 89 | 90 | // Commit transaction 91 | await albumsBatch.commit(); 92 | ``` 93 | 94 | ## Limitations 95 | 96 | Please be aware that Firestore has many limitations when working with BatchedWrites. You can learn more [here](https://firebase.google.com/docs/firestore/manage-data/transactions). 97 | -------------------------------------------------------------------------------- /docgen/Core_Concepts.md: -------------------------------------------------------------------------------- 1 | # Core Concepts 2 | 3 | Fireorm is just a library to simplify the way we communicate with firestore. It does not implement the underlying communication with the database (it resorts to official sdk's for that, such as [firebase-admin](https://www.npmjs.com/package/firebase-admin)). 4 | 5 | 6 | ## Firestore 7 | 8 | According to [its homepage](https://cloud.google.com/firestore), Firestore is a fast, fully managed, serverless, cloud-native NoSQL document database that simplifies storing, syncing, and querying data for your mobile, web, and IoT apps at global scale. 9 | 10 | In Firestore, data is stored in _Documents_ which are organized into _Collections_ that may also contain _SubCollections_. 11 | 12 | To take full advantage of what fireorm's have to offer, is recommended that you are familiarized with [Firestore Data Model](https://firebase.google.com/docs/firestore/data-model). 13 | 14 | ## Fireorm Models 15 | 16 | Models in fireorm are just a way to specify the shape that our data (or _Documents_) will have. Models are represented with [JavaScript Classes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes)! 17 | 18 | For example, let's pretend that we want to store information about Rock Bands:the band name, formation year and array of genres. Our Model would look like this: 19 | 20 | ```typescript 21 | class Band { 22 | id: string; 23 | name: string; 24 | formationYear: number; 25 | genres: Array; 26 | } 27 | ``` 28 | 29 | Wait, I only mentioned name, formationYear and genres in my original specification, so why does the model have a string property called `id`? Because of the way the data is stored in Firestore, **it's required that every model contain a string property called id**. If you create a model without the id property (or with another data type such as Number or Symbol) fireorm won't work correctly. 30 | 31 | ### Fireorm Collections 32 | 33 | Great, we have a model, but how can we ‘take’ our model and ‘store’ it the database? In Firestore we store data in _[Documents](https://firebase.google.com/docs/firestore/data-model#documents)_ and they are organized into _[Collections](https://firebase.google.com/docs/firestore/data-model#collections)_. To represent a Collection in our code, we'll use a fairly new JavaScript feature which Typescript lets us use super easy: [Decorators](https://www.typescriptlang.org/docs/handbook/decorators.html). 34 | 35 | To declare Collections we can just _decorate_ our model class with fireorm [Collection](globals.md#Collection) decorator and each instance of the model would act as a Firestore Document. 36 | 37 | ```typescript 38 | import { Collection } from 'fireorm'; 39 | 40 | @Collection() 41 | class Band { 42 | id: string; 43 | name: string; 44 | formationYear: number; 45 | genres: Array; 46 | } 47 | ``` 48 | 49 | See how we're importing the [Collection Decorator](globals.md#Collection) from fireorm and we're decorating our Band class with it. Internally, fireorm will treat each instance of Band as a Firestore Document. 50 | 51 | Wait, Firestore Collections must have a name. What will be the name of that collection? By default, fireorm will name the collections with the plural form of the Model name, in this case `Bands`. If you want you use your own name, you can pass a string as the first parameter of the Decorator. 52 | 53 | ```typescript 54 | @Collection('RockBands') 55 | ``` 56 | 57 | ## Fireorm Repositories 58 | 59 | One of my goals when developing this library was to create a way to use the Repository Pattern with Firestore as easily as possible. We have our models, we have our collections, but how are we supposed to make CRUD operations? That’s what Repositories are for. 60 | 61 | > In general, repositories are classes or components that encapsulate the logic required to access data sources. They centralize common data access functionality, providing better maintainability and decoupling the infrastructure or technology used to access databases from the domain model layer ([source](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/infrastructure-persistence-layer-design)). 62 | 63 | Fireorm’s Repositories provide the necessary methods to create, retrieve, update and delete documents from our Firestore collections. To create a repository from a collection we can just call fireorm’s [getRepository](Globals.md#getRepository) method. 64 | 65 | ```typescript 66 | import { Collection, getRepository } from 'fireorm'; 67 | 68 | @Collection() 69 | class Band { 70 | id: string; 71 | name: string; 72 | formationYear: number; 73 | genres: Array; 74 | } 75 | 76 | const bandRepository = getRepository(Band); 77 | ``` 78 | 79 | The variable `bandRepository` contains all the methods to interact with our `Band`. You can [retrieve](READ_DATA.md) [create](MANAGE_DATA.md#create), [update](MANAGE_DATA.md#update), [delete](MANAGE_DATA#delete) and do [complex queries](READ_DATA.md#ComplexQueries) over our Bands collection! 80 | -------------------------------------------------------------------------------- /docgen/Custom_Repositories.md: -------------------------------------------------------------------------------- 1 | # Custom Repositories 2 | 3 | By default, fireorm repositories have methods to create, read, update and delete documents, but what if we want to add extra data access logic? Fireorm supports Custom Repositories. A Custom Repository is a class that extends BaseRepository(where T is a model) and is decorated with fireorm’s [CustomRepository](Globals.md#CustomRepository) decorator. 4 | 5 | ```typescript 6 | import { BaseFirestoreRepository, CustomRepository, getRepository } from 'fireorm'; 7 | import Band from './models/Band'; 8 | 9 | @CustomRepository(Band) 10 | class CustomBandRepository extends BaseFirestoreRepository { 11 | async getProgressiveRockBands(): Promise { 12 | return this.whereArrayContains('genres', 'progressive-rock').find(); 13 | } 14 | } 15 | 16 | const bandRepository = getRepository(Band) as CustomBandRepository; 17 | const bands = await bandRepository.getProgressiveRockBands(); 18 | ``` 19 | 20 | Now, `getRepository(Band)` will return the custom repository for Band with the _getProgressiveRockBands_ method. If a model doesn’t have a custom repository, the base repository will be returned. Fireorm also provides `getCustomRepository` and `getBaseRepository` helpers if we don’t want the default behavior. 21 | 22 | ## Casting 23 | 24 | As you could see in the previous example, we had to cast the repository returned by the `getRepository` as the custom repository we wanted to use (_CustomBandRepository_). 25 | -------------------------------------------------------------------------------- /docgen/Manage_Data.md: -------------------------------------------------------------------------------- 1 | # Manage Data 2 | 3 | Now that we know how to [retrieve documents](READ_DATA.md) from Firestore, it's time to finish the rest of our CRUD operations! 4 | 5 | ## Create Documents 6 | 7 | Fireorm repositories provide a [create](Classes/BaseFirestoreRepository.md#Create) method to save new documents into Firestore! If at the moment of calling the [create](Classes/BaseFirestoreRepository.md#Create) method you don't provide an id, an autogenerated id will be used. 8 | 9 | ```typescript 10 | import { Collection, getRepository } from 'fireorm'; 11 | import Band from './wherever-our-models-are'; 12 | 13 | const bandRepository = getRepository(Band); 14 | 15 | const rush = new Band(); 16 | rush.name = 'Rush'; 17 | rush.formationYear = 1968; 18 | rush.genres = ['progressive-rock', 'hard-rock', 'heavy-metal']; 19 | 20 | await bandRepository.create(rush); 21 | ``` 22 | 23 | ## Update Documents 24 | 25 | Not all information we store in Firestore will remain unedited forever, so we need a way to edit the data we already have. No worries, Fireorm repositories provide an [update](Classes/BaseFirestoreRepository.md#Update) method to update the data we already have stored in our documents. 26 | 27 | ```typescript 28 | import { Collection, getRepository } from 'fireorm'; 29 | import Band from './wherever-our-models-are'; 30 | 31 | const bandRepository = getRepository(Band); 32 | 33 | const rush = await bandRepository.findById('rush'); 34 | 35 | rush.name = 'rush'; 36 | 37 | await bandRepository.update(rush); 38 | ``` 39 | 40 | ## Delete Documents 41 | 42 | By now you know the drill. Fireorm repositories provide an [delete](Classes/BaseFirestoreRepository.md#Delete) method to delete documents. 43 | 44 | ```typescript 45 | import { Collection, getRepository } from 'fireorm'; 46 | import Band from './wherever-our-models-are'; 47 | 48 | const bandRepository = getRepository(Band); 49 | 50 | const rush = await bandRepository.findById('rush'); 51 | 52 | await bandRepository.delete(rush); 53 | ``` 54 | -------------------------------------------------------------------------------- /docgen/Read_Data.md: -------------------------------------------------------------------------------- 1 | # Reading Data 2 | 3 | This is where fun starts! Once we have [initialized](README.md#Initialization) fireorm in our application we can start using it! 4 | 5 | We'll continue working with the Band's collection we defined in [Core Concept's](CORE_CONCEPTS.md#FireormCollections) section. 6 | 7 | ## Simple Queries 8 | 9 | Fireorm [Repositories](CORE_CONCEPTS.md#FireormRepositories) have the method [findById](Classes/Classes/BaseFirestoreRepository.md#FindById) which you can use to retrieve documents by its... id, duh. 10 | 11 | Let's imagine we have a Document in our Bands Collection in firestore with an id `red-hot-chilli-peppers`. To retrieve it we only have to use the handy [findById](Classes/BaseFirestoreRepository.md#FindById) method in our repository! 12 | 13 | ```typescript 14 | import { Collection, getRepository } from 'fireorm'; 15 | 16 | @Collection() 17 | class Band { 18 | id: string; 19 | name: string; 20 | formationYear: number; 21 | genres: Array; 22 | } 23 | 24 | const bandRepository = getRepository(Band); 25 | 26 | const band = await bandRepository.findById('red-hot-chilli-peppers'); 27 | ``` 28 | 29 | Now the variable band is an instance of our Band model that contains the information about the band! (Red Hot Chilli Peppers is awesome, btw!) 30 | 31 | ## Complex Queries 32 | 33 | Only being able to find documents by id is a bit limiting, that's why fireorm repositories provide a lot of helper functions to ease the filtering of data in queries. These are [whereEqualTo](Classes/BaseFirestoreRepository.md#WhereEqualTo), [whereGreaterThan](Classes/BaseFirestoreRepository.md#WhereGreaterThan), [whereGreaterOrEqualTha](Classes/BaseFirestoreRepository.md#WhereGreaterOrEqualThan), [whereLessThan](Classes/BaseFirestoreRepository.md#WhereLessThan), [whereLessOrEqualThan](Classes/BaseFirestoreRepository.md#WhereLessOrEqualThan), [whereArrayContains](Classes/BaseFirestoreRepository.md#WhereArrayContains), [whereIn](Classes/BaseFirestoreRepository.md#whereIn), and [whereArrayContainsAny](Classes/BaseFirestoreRepository.md#whereArrayContainsAny) methods. We can pipe as many methods as we need to perform complex queries, as long as we don’t forget to call the [find](Classes/BaseFirestoreRepository.md#Find) method at the end. 34 | 35 | ```typescript 36 | // Bands formed from 1990 onwards 37 | await bandRepository.whereGreaterOrEqualThan('formationYear', 1990).find(); 38 | 39 | // Bands whose name is Porcupine Tree 40 | await bandRepository.whereEqualTo('name', 'Porcupine Tree').find(); 41 | 42 | // Bands formed after 1985 and that play Progressive Rock 43 | await bandRepository 44 | .whereGreaterThan('formationYear', 1985) 45 | .whereArrayContains('genres', 'progressive-rock') 46 | .find(); 47 | ``` 48 | 49 | All the \*Where methods have a similar api, where the first parameter is a string that represents the field that we want to search for and the second one is the value that we want to compare to (which can be any [JavaScript primitive type](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Primitive_values)). Fireorm also provide an alternative API to make it more type safe; the first parameter can also accept a lamda function where it's first parameter is the type of the model of the repository. 50 | 51 | ```typescript 52 | // This example is exactly the same than the last one, but using the alternative API. 53 | 54 | // Bands formed from 1990 onwards 55 | await bandRepository.whereGreaterOrEqualThan(band => band.formationYear, 1990).find(); 56 | 57 | // Bands whose name is Porcupine Tree 58 | await bandRepository.whereEqualTo(band => band.name, 'Porcupine Tree').find(); 59 | 60 | // Bands formed after 1985 and that play Progressive Rock 61 | await bandRepository 62 | .whereGreaterThan(band => band.formationYear, 1985) 63 | .whereArrayContains(band => band.genres, 'progressive-rock') 64 | .find(); 65 | ``` 66 | 67 | ### Search by Document Reference 68 | 69 | In [#115](https://github.com/wovalle/fireorm/pull/105/) we added the ability to use firestore document references in queries. We can use the document reference as the value in any of the helpers function described above and we can see it in action [in this example](https://github.com/wovalle/fireorm/blob/d8f79090b7006675f2cb5014bb5ca7a9dfbfa8c1/src/BaseFirestoreRepository.spec.ts#L478-L492) or [this one](https://github.com/wovalle/fireorm/blob/master/test/functional/6-document-references.spec.ts). 70 | 71 | ```ts 72 | // Fake DocumentReference 73 | class FirestoreDocumentReference { 74 | id: string; 75 | path: string; 76 | } 77 | 78 | @Collection() 79 | class BandWithReference { 80 | id: string; 81 | name: string; 82 | formationYear: number; 83 | genres: Array; 84 | 85 | @Type(() => FirestoreDocumentReference) 86 | relatedBand?: FirestoreDocumentReference; 87 | } 88 | 89 | const pt = new Band(); 90 | pt.id = 'porcupine-tree'; 91 | pt.name = 'Porcupine Tree'; 92 | pt.formationYear = 1987; 93 | pt.genres = ['psychedelic-rock', 'progressive-rock', 'progressive-metal']; 94 | 95 | await bandRepository.create(pt); 96 | 97 | // Filter documents by a doc reference 98 | const band = await bandRepository.whereEqualTo(b => b.relatedBand, ptRef).find(); 99 | 100 | // Can also use the string api of the complex query 101 | const band = await bandRepository.whereEqualTo('relatedBand', ptRef).find(); 102 | ``` 103 | 104 | ## Order By and Limit 105 | 106 | Fireorm repositories also provide functions to order documents and limit the quantity of documents that we will retrieve. These are [orderByAscending](Classes/BaseFirestoreRepository.md#OrderByAscending), [orderByDescending](Classes/BaseFirestoreRepository.md#OrderByDescending) and [limit](Classes/BaseFirestoreRepository.md#Limit). Please be aware that you can only use one orderBy and one limit per query. 107 | 108 | ```typescript 109 | // Bands formed from 1990 onwards or 110 | await bandRepository 111 | .whereGreaterOrEqualThan(band => band.formationYear, 1990) 112 | .orderByAscending('name') 113 | .find(); 114 | 115 | // Top 10 bands whose formationYear is 1987 in ascending order by formationYear (using the alternative api) 116 | await bandRepository 117 | .whereEqualTo(band => band.formationYear, 1987) 118 | .orderByAscending(band => band.formationYear) 119 | .limit(10) 120 | .find(); 121 | 122 | // Top 3 bands formed after 1985 and that play Progressive Rock 123 | await bandRepository 124 | .whereGreaterThan(band => band.formationYear, 1985) 125 | .whereArrayContains(band => band.genres, 'progressive-rock') 126 | .limit(3) 127 | .find(); 128 | ``` 129 | 130 | ### Limitations on Complex queries 131 | 132 | Please be aware that fireorm cannot circumvent [Firestore query limitations](https://firebase.google.com/docs/firestore/query-data/queries#query_limitations), we still have to create indexes if we want to create queries that involve more than one field. 133 | -------------------------------------------------------------------------------- /docgen/Subcollections.md: -------------------------------------------------------------------------------- 1 | # SubCollections 2 | 3 | In the [core concepts](CORE_CONCEPTS.md) we learned that in Firestore we store data in _[Documents](https://firebase.google.com/docs/firestore/data-model#documents)_ and they are organized into [Collections](https://firebase.google.com/docs/firestore/data-model#collections). But in Firestore you can also add collections inside documents, they are called [Subcollections](https://firebase.google.com/docs/firestore/data-model#subcollections). 4 | 5 | To represent a SubCollection in our code, we'll make use of fireorm's [SubCollection](Globals.md#SubCollection) decorator. 6 | For example, let’s create an Albums model and add it as a Subcollection of Band 7 | 8 | ```typescript 9 | import { Collection, SubCollection, ISubCollection } from 'fireorm'; 10 | 11 | class Album { 12 | id: string; 13 | name: string; 14 | year: number; 15 | } 16 | 17 | @Collection() 18 | class Band { 19 | id: string; 20 | name: string; 21 | formationYear: number; 22 | genres: Array; 23 | 24 | @SubCollection(Album) 25 | albums?: ISubCollection; 26 | } 27 | ``` 28 | 29 | In this case we created a model called Album to store each album information: a unique id (remember, models must have an id by design!), name and year. Once the model is created, we add a _albums_ property to the existing Band model and decorate it using fireorm’s [SubCollection](Globals.md#SubCollection) decorator passing Album model as the first parameter. 30 | 31 | Notice how we didn't add the [Collection](Globals.md#Collection) Decorator to the Album class (we wanted it to be a SubCollection, not a Collection!) but added the [SubCollection](Globals.md#SubCollection) inside Band model. 32 | 33 | By default, fireorm will name the SubCollections with the plural form of the model name that was passed as first parameter (in this case, it will be named `Albums`). If you want you use your own name, you can pass an string as the second parameter of the SubCollection Decorator. 34 | 35 | ```typescript 36 | @SubCollection(Album, 'TheAlbums') 37 | ``` 38 | 39 | ## Nested SubCollections 40 | 41 | Fireorm has support for nested subcollections (subcollections inside subcollections). To represent a nested subcollection we only have to use the [SubCollection](Globals.md#SubCollection) decorator inside a model that is itself a subcollection of another model. 42 | 43 | ```typescript 44 | import { Collection, SubCollection, ISubCollection } from 'fireorm'; 45 | 46 | class Image { 47 | id: string; 48 | url: string; 49 | } 50 | 51 | class Album { 52 | id: string; 53 | name: string; 54 | year: number; 55 | 56 | @SubCollection(Image) 57 | images?: ISubCollection; 58 | } 59 | 60 | @Collection() 61 | class Band { 62 | id: string; 63 | name: string; 64 | formationYear: number; 65 | genres: Array; 66 | 67 | @SubCollection(Album) 68 | albums?: ISubCollection; 69 | } 70 | ``` 71 | 72 | In this example we have a **Band** model that has a field called `albums` that represents the **Albums** subcollection that itself has a field called `images` that represents the **Images** subcollection (Band -> Album -> Image). 73 | 74 | Please note that firestore supports [up to 100](https://firebase.google.com/docs/firestore/data-model#subcollections) nested subcollections. 75 | -------------------------------------------------------------------------------- /docgen/Transactions.md: -------------------------------------------------------------------------------- 1 | # Transactions 2 | 3 | Fireorm also has support for [Firestore Transactions](https://firebase.google.com/docs/firestore/manage-data/transactions) inside a single [repository]() and between multiple repositories. 4 | 5 | ## Transactions inside repositories 6 | 7 | Fireorm [repositories](CORE_CONCEPTS.md#FireormRepositories) have a [runTransaction](Classes/BaseFirestoreRepository.md#RunTransaction) method. It receives a lamda function where the first parameter corresponds to a [TransactionRepository](Classes/TransactionRepository.md). The [TransactionRepository](Classes/TransactionRepository.md) is an special type of repository that has methods to create, retrieve, update and delete documents inside a transaction. 8 | 9 | ```typescript 10 | import { getRepository, Collection } from 'fireorm'; 11 | import Band from './wherever-our-models-are'; 12 | 13 | const bandRepository = getRepository(Band); 14 | const dt = new Band(); 15 | dt.id = 'dream-theater'; 16 | dt.name = 'DreamTheater'; 17 | dt.formationYear = 1985; 18 | 19 | bandRepository.runTransaction(async tran => { 20 | await tran.create(dt); 21 | }); 22 | ``` 23 | 24 | ## Transactions in multiple repositories 25 | 26 | Fireorm exports a [runTransaction](Classes/BaseFirestoreRepository.md#RunTransaction) method that can be used to create transactions with one or multiple repositories. It receives a lamda function where the first parameter corresponds to a [FirestoreTransaction](Classes/FirestoreTransaction.md) class. This class exposes a `getRepository` method that receives an [Model class](CORE_CONCEPTS.md#FireormModels) and returns a [TransactionRepository](Classes/TransactionRepository.md) of the given entity and can be used to create, retrieve, update and delete documents inside a transaction. 27 | 28 | ```typescript 29 | import { runTransaction } from 'fireorm'; 30 | import { Band, Album } from './wherever-our-models-are'; 31 | 32 | const band = new Band(); 33 | band.id = 'dream-theater'; 34 | band.name = 'DreamTheater'; 35 | band.formationYear = 1985; 36 | 37 | const album1 = new Album(); 38 | album1.name = 'When Dream and Day Unite'; 39 | album1.releaseDate = new Date('1989-03-06T00:00:00.000Z'); 40 | album1.bandId = band.id; 41 | 42 | const album2 = new Album(); 43 | album2.name = 'Images and Words'; 44 | album2.releaseDate = new Date('1992-07-07T00:00:00.000Z'); 45 | album2.bandId = band.id; 46 | 47 | await runTransaction(async tran => { 48 | const bandTranRepository = tran.getRepository(Band); 49 | const albumTranRepository = tran.getRepository(Album); 50 | 51 | await bandTranRepository.create(band); 52 | await albumTranRepository.create(album1); 53 | await albumTranRepository.create(album2); 54 | }); 55 | ``` 56 | 57 | ## Returning values from transactions 58 | 59 | If you need to return data from transactions, [runTransaction](Classes/BaseFirestoreRepository.md#RunTransaction) receives a [type parameter](https://www.typescriptlang.org/docs/handbook/generics.html#using-type-parameters-in-generic-constraints) of the output value of your transaction. 60 | 61 | ```typescript 62 | import { runTransaction } from 'fireorm'; 63 | import { Band } from './wherever-our-models-are'; 64 | 65 | const band = new Band(); 66 | band.id = 'dream-theater'; 67 | band.name = 'DreamTheater'; 68 | band.formationYear = 1985; 69 | 70 | await runTransaction(async tran => { 71 | const bandTranRepository = tran.getRepository(Band); 72 | const albumTranRepository = tran.getRepository(Album); 73 | 74 | return bandTranRepository.create(band); 75 | }); 76 | ``` 77 | 78 | ## Transaction in subcollections 79 | 80 | If we create an entity inside a transactions, all of its subcollections will be automatically be a [TransactionRepository](Classes/TransactionRepository.md) that means that all of the operations done to subcollections will also be done inside transactions. Once the transaction is finished fireorm will automatically change the [TransactionRepository](Classes/TransactionRepository.md) for a normal [BaseFirestoreRepository](CORE_CONCEPTS.md#FireormRepositories) in case you need to reuse the entity. 81 | 82 | ```ts 83 | import { runTransaction } from 'fireorm'; 84 | import { Band, Album } from './wherever-our-models-are'; 85 | 86 | const band = new Band(); 87 | band.id = 'tame-impala'; 88 | band.name = 'Tame Impala'; 89 | band.formationYear = 2007; 90 | 91 | const albums = [ 92 | { 93 | id: 'currents', 94 | name: 'Currents', 95 | releaseDate: new Date('2015-07-17T00:00:00.000Z'), 96 | }, 97 | { 98 | id: 'slow-rush', 99 | name: 'The Slow Rush', 100 | releaseDate: new Date('2020-02-14T00:00:00.000Z'), 101 | }, 102 | ]; 103 | 104 | await runTransaction(async tran => { 105 | const bandTranRepository = tran.getRepository(Band); 106 | 107 | // Create the band inside transaction. 108 | // Band contains a subcollection of Albums in the field albums, so when the band is created it will contain an albums field with TransactionRepository type. 109 | const createdBand = await bandTranRepository.create(band); 110 | 111 | // Once the band is created, save the albums 112 | for (const album of albums) { 113 | await createdBand.albums.create(album); 114 | } 115 | 116 | // Outside of the transaction, albums will be a BaseFirestoreRepository 117 | return createdBand; 118 | }); 119 | ``` 120 | 121 | ## Limitations 122 | 123 | Please be aware that Firestore has many limitations when working with transactions. You can learn more [here](https://firebase.google.com/docs/firestore/manage-data/transactions). The most notable ones are that inside Transactions all the read operations must be done first (i.e. if you need to fetch some documents from firestore and edit it inside a transaction, you must fetch everything you need before doing creating/updating/deleting any document). Also, transactions cannot contain any `limit` or `orderBy` clauses (as defined [here](READ_DATA.md#orderbyandlimit)). 124 | -------------------------------------------------------------------------------- /docgen/Validation.md: -------------------------------------------------------------------------------- 1 | # Validation 2 | 3 | FireORM supports [class-validator](https://github.com/typestack/class-validator) validation decorators in any collection. 4 | 5 | As `class-validator` requires a single install per project, FireORM opts not to depend on it explicitly (doing so may result in conflicting versions). It is up to you to install it with `npm i -S class-validator`. 6 | 7 | Once installed correctly, you can write your collections like so: 8 | 9 | ```typescript 10 | import { Collection } from 'fireorm'; 11 | import { IsEmail } from 'class-validator'; 12 | 13 | @Collection() 14 | class Band { 15 | @IsEmail() 16 | contactEmail: string; 17 | } 18 | ``` 19 | 20 | Use this in the same way that you would your other collections and it will validate whenever a document is saved or updated. [Read more about managing data.](Manage_Data.md) 21 | 22 | ## Disabling validation 23 | 24 | Model validation is not enabled by default. It can be enable by initializing FireORM with the `validateModels: true` option. 25 | 26 | ```typescript 27 | initialize(firestore, { 28 | validateModels: true, 29 | }); 30 | ``` 31 | -------------------------------------------------------------------------------- /docgen/build.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | const files = ['index.html', '.nojekyll']; 5 | const ignoreFiles = ['_sidebar.md']; 6 | 7 | (async () => { 8 | const docsDir = __dirname; 9 | const outDir = path.join(__dirname, '../docs'); 10 | 11 | const filesInFolder = await fs.promises.readdir(docsDir); 12 | const mdFiles = filesInFolder.filter(f => f.endsWith('.md') && !ignoreFiles.includes(f)); 13 | 14 | await Promise.all(files.map(f => fs.promises.copyFile(`${docsDir}/${f}`, `${outDir}/${f}`))); 15 | 16 | await Promise.all(mdFiles.map(f => fs.promises.copyFile(`${docsDir}/${f}`, `${outDir}/${f}`))); 17 | 18 | // Merge Sidebar 19 | 20 | const typesSidebar = (await fs.promises.readFile(`${outDir}/_sidebar.md`)).toString(); 21 | 22 | const generalSidebar = (await fs.promises.readFile(`${docsDir}/sidebar.md`)).toString(); 23 | 24 | const fullSidebar = generalSidebar + '\n' + typesSidebar; 25 | 26 | await fs.promises.writeFile(`${outDir}/sidebar.md`, fullSidebar); 27 | 28 | console.log('Documentation copied successfully!'); 29 | })(); 30 | -------------------------------------------------------------------------------- /docgen/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | fireorm - ORM for Firestore 6 | 7 | 11 | 15 | 16 | 17 | 21 | 22 | 26 | 30 | 31 | 32 | 33 | 42 | 43 | 44 |
45 | 46 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /docgen/sidebar.md: -------------------------------------------------------------------------------- 1 | - Getting Started 2 | 3 | - [Usage](README.md) 4 | - [Core Concepts](Core_Concepts.md) 5 | 6 | - Retrieving and Managing Data 7 | 8 | - [Retrieve Data](Read_Data.md) 9 | - [Manage Data](Manage_Data.md) 10 | - [SubCollections](Subcollections.md) 11 | - [Transactions](Transactions.md) 12 | - [Batches](Batches.md) 13 | - [Custom Repositories](Custom_Repositories.md) 14 | - [Validation](Validation.md) 15 | -------------------------------------------------------------------------------- /docgen/typedoc-docsify/theme.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const { RendererEvent } = require('typedoc/dist/lib/output/events'); 3 | 4 | const MarkdownTheme = require('typedoc-plugin-markdown/dist/theme').default; 5 | 6 | class DocsifyTheme extends MarkdownTheme { 7 | constructor(renderer, basePath) { 8 | super(renderer, basePath); 9 | this.listenTo(renderer, RendererEvent.END, this.writeSummary, 1024); 10 | } 11 | 12 | writeSummary(renderer) { 13 | const outputDirectory = renderer.outputDirectory; 14 | const summaryMarkdown = this.getSummaryMarkdown(renderer); 15 | try { 16 | fs.writeFileSync(`${outputDirectory}/_sidebar.md`, summaryMarkdown); 17 | this.application.logger.write( 18 | `[typedoc-plugin-markdown] _sidebar.md written to ${outputDirectory}` 19 | ); 20 | } catch (e) { 21 | this.application.logger.write( 22 | `[typedoc-plugin-markdown] failed to write _sidebar at ${outputDirectory}` 23 | ); 24 | } 25 | } 26 | 27 | getSummaryMarkdown(renderer) { 28 | const md = []; 29 | md.push('- Types Documentation'); 30 | md.push('- [Summary](globals.md)'); 31 | this.getNavigation(renderer.project).children.forEach(rootNavigation => { 32 | if (rootNavigation.children) { 33 | md.push(` - ${rootNavigation.title}`); 34 | rootNavigation.children.forEach(item => { 35 | md.push(` - [${item.title}](${item.url})`); 36 | }); 37 | } 38 | }); 39 | return md.join('\n'); 40 | } 41 | 42 | allowedDirectoryListings() { 43 | return [ 44 | 'README.md', 45 | 'globals.md', 46 | 'classes', 47 | 'enums', 48 | 'interfaces', 49 | 'modules', 50 | 'media', 51 | '.DS_Store', 52 | 'SUMMARY.md', 53 | ]; 54 | } 55 | } 56 | 57 | module.exports = DocsifyTheme; 58 | -------------------------------------------------------------------------------- /docgen/typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "out": "docs", 3 | "name": "fireorm🔥", 4 | "readme": "README", 5 | "mode": "file", 6 | "gaId": "UA-133856278-1", 7 | "gaSite": "Fireorm Type Documentation", 8 | "excludeExternals": true, 9 | "excludeNotExported": true, 10 | "hideBreadcrumbs": true, 11 | "plugins": ["typedoc-plugin-markdown"], 12 | "theme": "docgen/typedoc-docsify", 13 | "hideProjectTitle": true, 14 | "exclude": ["*.spec.*"] 15 | } 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | coverageDirectory: './coverage/', 6 | verbose: true, 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fireorm", 3 | "description": "ORM for Firestore", 4 | "version": "0.0.0-development", 5 | "author": "Willy Ovalle ", 6 | "homepage": "https://fireorm.js.org", 7 | "license": "ISC", 8 | "main": "lib/src/index.js", 9 | "types": "lib/src/index.d.ts", 10 | "bugs": { 11 | "url": "https://github.com/wovalle/fireorm/issues" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/wovalle/fireorm.git" 16 | }, 17 | "keywords": [ 18 | "firebase", 19 | "firestore", 20 | "orm" 21 | ], 22 | "scripts": { 23 | "build": "tsc && yarn build:strict", 24 | "build:typedoc": "typedoc --options docgen/typedoc.json", 25 | "build:docsify": "yarn ts-node docgen/build.ts", 26 | "build:doc": "yarn rimraf docs && yarn build:typedoc && yarn build:docsify && cp CNAME docs/CNAME", 27 | "build:strict": "tsc -p src", 28 | "serve:doc": "yarn docsify serve docgen", 29 | "deploy:doc": "gh-pages-deploy", 30 | "build:watch": "tsc -w --incremental", 31 | "build:strict:watch": "yarn run build:strict -- -w", 32 | "lint": "eslint '*/**/*.{js,ts}'", 33 | "lint:md": "remark README.md -o README.md", 34 | "release": "semantic-release", 35 | "test": "jest src --coverage=true", 36 | "test:integration": "jest -c test/jest.integration.js", 37 | "test:watch": "jest --watch src", 38 | "t": "yarn test", 39 | "tw": "yarn test:watch", 40 | "bw": "yarn build:watch" 41 | }, 42 | "dependencies": { 43 | "class-transformer": "0.4.0", 44 | "pluralize": "^8.0.0", 45 | "ts-object-path": "^0.1.2" 46 | }, 47 | "devDependencies": { 48 | "@commitlint/cli": "^12.1.4", 49 | "@commitlint/config-conventional": "^12.1.4", 50 | "@commitlint/travis-cli": "^12.1.4", 51 | "@google-cloud/firestore": "^4.12.2", 52 | "@types/jest": "^26.0.23", 53 | "@types/pluralize": "^0.0.29", 54 | "@typescript-eslint/eslint-plugin": "^4.25.0", 55 | "@typescript-eslint/parser": "^4.25.0", 56 | "class-validator": "^0.13.1", 57 | "docsify-cli": "^4.4.3", 58 | "dotenv": "^10.0.0", 59 | "eslint": "^7.27.0", 60 | "eslint-config-prettier": "^8.3.0", 61 | "eslint-plugin-prettier": "^3.4.0", 62 | "firebase-admin": "^9.9.0", 63 | "gh-pages-deploy": "^0.5.1", 64 | "husky": "^6.0.0", 65 | "jest": "^27.0.3", 66 | "mock-cloud-firestore": "^0.12.0", 67 | "prettier": "^2.3.0", 68 | "reflect-metadata": "^0.1.13", 69 | "rimraf": "^3.0.0", 70 | "semantic-release": "^17.4.3", 71 | "ts-jest": "^27.0.1", 72 | "ts-node": "^10.0.0", 73 | "typedoc": "^0.20.36", 74 | "typedoc-plugin-markdown": "^3.8.2", 75 | "typescript": "^4.3.2" 76 | }, 77 | "peerDependencies": { 78 | "reflect-metadata": "^0.1.13" 79 | }, 80 | "files": [ 81 | "/lib", 82 | "!**/*.map", 83 | "!**/*.spec.*", 84 | "!**/examples/**", 85 | "!**/test/**" 86 | ], 87 | "commitlint": { 88 | "extends": [ 89 | "@commitlint/config-conventional" 90 | ] 91 | }, 92 | "husky": { 93 | "hooks": { 94 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 95 | } 96 | }, 97 | "release": { 98 | "branches": [ 99 | "master", 100 | "next" 101 | ], 102 | "plugins": [ 103 | "@semantic-release/npm", 104 | "@semantic-release/github", 105 | "@semantic-release/commit-analyzer", 106 | "@semantic-release/release-notes-generator" 107 | ] 108 | }, 109 | "gh-pages-deploy": { 110 | "staticpath": "docs", 111 | "prep": [ 112 | "build:doc" 113 | ], 114 | "commit": "chore(docs): updating documentation", 115 | "noprompt": true 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/BaseFirestoreRepository.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import { Query, WhereFilterOp } from '@google-cloud/firestore'; 4 | 5 | import { 6 | IRepository, 7 | IFireOrmQueryLine, 8 | IOrderByParams, 9 | IEntity, 10 | PartialBy, 11 | ITransactionRepository, 12 | ICustomQuery, 13 | } from './types'; 14 | 15 | import { getMetadataStorage } from './MetadataUtils'; 16 | import { AbstractFirestoreRepository } from './AbstractFirestoreRepository'; 17 | import { FirestoreBatch } from './Batch/FirestoreBatch'; 18 | 19 | export class BaseFirestoreRepository 20 | extends AbstractFirestoreRepository 21 | implements IRepository 22 | { 23 | async findById(id: string) { 24 | return this.firestoreColRef 25 | .doc(id) 26 | .get() 27 | .then(d => (d.exists ? this.extractTFromDocSnap(d) : null)); 28 | } 29 | 30 | async create(item: PartialBy): Promise { 31 | if (this.config.validateModels) { 32 | const errors = await this.validate(item as T); 33 | 34 | if (errors.length) { 35 | throw errors; 36 | } 37 | } 38 | 39 | if (item.id) { 40 | const found = await this.findById(item.id); 41 | if (found) { 42 | throw new Error(`A document with id ${item.id} already exists.`); 43 | } 44 | } 45 | 46 | const doc = item.id ? this.firestoreColRef.doc(item.id) : this.firestoreColRef.doc(); 47 | 48 | if (!item.id) { 49 | item.id = doc.id; 50 | } 51 | 52 | await doc.set(this.toSerializableObject(item as T)); 53 | 54 | this.initializeSubCollections(item as T); 55 | 56 | return item as T; 57 | } 58 | 59 | async update(item: T) { 60 | if (this.config.validateModels) { 61 | const errors = await this.validate(item); 62 | 63 | if (errors.length) { 64 | throw errors; 65 | } 66 | } 67 | 68 | // TODO: handle errors 69 | await this.firestoreColRef.doc(item.id).update(this.toSerializableObject(item)); 70 | return item; 71 | } 72 | 73 | async delete(id: string): Promise { 74 | // TODO: handle errors 75 | await this.firestoreColRef.doc(id).delete(); 76 | } 77 | 78 | async runTransaction(executor: (tran: ITransactionRepository) => Promise) { 79 | // Importing here to prevent circular dependency 80 | const { runTransaction } = await import('./helpers'); 81 | 82 | return runTransaction(tran => { 83 | const repository = tran.getRepository(this.path); 84 | return executor(repository); 85 | }); 86 | } 87 | 88 | createBatch() { 89 | const { firestoreRef } = getMetadataStorage(); 90 | return new FirestoreBatch(firestoreRef).getSingleRepository(this.path); 91 | } 92 | 93 | async execute( 94 | queries: Array, 95 | limitVal?: number, 96 | orderByObj?: IOrderByParams, 97 | single?: boolean, 98 | customQuery?: ICustomQuery 99 | ): Promise { 100 | let query = queries.reduce((acc, cur) => { 101 | const op = cur.operator as WhereFilterOp; 102 | return acc.where(cur.prop, op, cur.val); 103 | }, this.firestoreColRef); 104 | 105 | if (orderByObj) { 106 | query = query.orderBy(orderByObj.fieldPath, orderByObj.directionStr); 107 | } 108 | 109 | if (single) { 110 | query = query.limit(1); 111 | } else if (limitVal) { 112 | query = query.limit(limitVal); 113 | } 114 | 115 | if (customQuery) { 116 | query = await customQuery(query, this.firestoreColRef); 117 | } 118 | 119 | return query.get().then(this.extractTFromColSnap); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/BaseRepository.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Dummy class created with the sole purpose to be able to 3 | * check if other classes are instances of BaseFirestoreRepository. 4 | * Typescript is not capable to check instances of generics. 5 | * 6 | * @export 7 | * @class BaseRepository 8 | */ 9 | export class BaseRepository {} 10 | -------------------------------------------------------------------------------- /src/Batch/BaseFirestoreBatchRepository.spec.ts: -------------------------------------------------------------------------------- 1 | import { BaseFirestoreBatchRepository } from './BaseFirestoreBatchRepository'; 2 | import { getFixture } from '../../test/fixture'; 3 | import { initialize } from '../MetadataUtils'; 4 | import { Band, Album } from '../../test/BandCollection'; 5 | import { Firestore, WriteBatch } from '@google-cloud/firestore'; 6 | import { FirestoreBatchUnit } from './FirestoreBatchUnit'; 7 | import { BaseFirestoreRepository } from '../BaseFirestoreRepository'; 8 | import { getRepository } from '../helpers'; 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-var-requires 11 | const MockFirebase = require('mock-cloud-firestore'); 12 | 13 | describe('BaseFirestoreBatchRepository', () => { 14 | let bandBatchRepository: BaseFirestoreBatchRepository = null; 15 | let bandRepository: BaseFirestoreRepository = null; 16 | let firestore: Firestore; 17 | let batch: FirestoreBatchUnit; 18 | let batchStub: jest.Mocked; 19 | 20 | beforeEach(() => { 21 | const fixture = Object.assign({}, getFixture()); 22 | const firebase = new MockFirebase(fixture, { 23 | isNaiveSnapshotListenerEnabled: false, 24 | }); 25 | 26 | batchStub = { 27 | create: jest.fn(), 28 | update: jest.fn(), 29 | set: jest.fn(), 30 | delete: jest.fn(), 31 | commit: jest.fn(), 32 | }; 33 | 34 | firestore = Object.assign(firebase.firestore(), { batch: () => batchStub }); 35 | initialize(firestore); 36 | 37 | batch = new FirestoreBatchUnit(firestore); 38 | bandBatchRepository = new BaseFirestoreBatchRepository(Band, batch); 39 | bandRepository = getRepository(Band); 40 | }); 41 | 42 | describe('create', () => { 43 | it('must create items when id is passed', async () => { 44 | const entity = new Band(); 45 | entity.id = 'perfect-circle'; 46 | entity.name = 'A Perfect Circle'; 47 | entity.formationYear = 1999; 48 | entity.genres = ['alternative-rock', 'alternative-metal', 'hard-rock']; 49 | 50 | bandBatchRepository.create(entity); 51 | await batch.commit(); 52 | 53 | expect(batchStub.set.mock.calls[0][1]).toEqual({ 54 | id: 'perfect-circle', 55 | name: 'A Perfect Circle', 56 | formationYear: 1999, 57 | genres: ['alternative-rock', 'alternative-metal', 'hard-rock'], 58 | }); 59 | }); 60 | 61 | it('must create items and assign a custom id if no id is passed', async () => { 62 | const entity = new Band(); 63 | entity.name = 'The Pinapple Thief'; 64 | entity.formationYear = 1999; 65 | entity.genres = ['progressive-rock']; 66 | 67 | bandBatchRepository.create(entity); 68 | await batch.commit(); 69 | 70 | const data = batchStub.set.mock.calls[0][1] as Band; 71 | 72 | expect(typeof data.id).toEqual('string'); 73 | expect(data.name).toEqual('The Pinapple Thief'); 74 | expect(data.formationYear).toEqual(1999); 75 | expect(data.genres).toEqual(['progressive-rock']); 76 | }); 77 | 78 | it.todo('must be able to create document from anonymous object without id'); 79 | }); 80 | 81 | describe('update', () => { 82 | it('must call batch.update', async () => { 83 | const entity = new Band(); 84 | entity.id = 'perfect-circle'; 85 | entity.name = 'A Perfect Circle'; 86 | entity.formationYear = 1999; 87 | entity.genres = ['alternative-rock', 'alternative-metal', 'hard-rock']; 88 | 89 | bandBatchRepository.create(entity); 90 | await batch.commit(); 91 | 92 | entity.name = 'Un Círculo Perfecto'; 93 | bandBatchRepository.update(entity); 94 | await batch.commit(); 95 | 96 | expect(batchStub.update.mock.calls[0][1]).toEqual({ 97 | id: 'perfect-circle', 98 | name: 'Un Círculo Perfecto', 99 | formationYear: 1999, 100 | genres: ['alternative-rock', 'alternative-metal', 'hard-rock'], 101 | }); 102 | }); 103 | }); 104 | 105 | describe('delete', () => { 106 | it('must call batch.delete', async () => { 107 | const entity = new Band(); 108 | entity.id = 'perfect-circle'; 109 | entity.name = 'A Perfect Circle'; 110 | entity.formationYear = 1999; 111 | entity.genres = ['alternative-rock', 'alternative-metal', 'hard-rock']; 112 | 113 | bandBatchRepository.delete(entity); 114 | await batch.commit(); 115 | 116 | expect(batchStub.delete.mock.calls[0][1]).toEqual({ 117 | id: 'perfect-circle', 118 | name: 'A Perfect Circle', 119 | formationYear: 1999, 120 | genres: ['alternative-rock', 'alternative-metal', 'hard-rock'], 121 | }); 122 | }); 123 | }); 124 | 125 | describe('should run validations', () => { 126 | it('should run validations', async () => { 127 | initialize(firestore, { validateModels: true }); 128 | 129 | const validationBatch = new FirestoreBatchUnit(firestore); 130 | const validationBandRepository = new BaseFirestoreBatchRepository(Band, validationBatch); 131 | 132 | const entity = new Band(); 133 | entity.id = 'perfect-circle'; 134 | entity.name = 'A Perfect Circle'; 135 | entity.formationYear = 1999; 136 | entity.genres = ['alternative-rock', 'alternative-metal', 'hard-rock']; 137 | entity.contactEmail = 'Not an email'; 138 | 139 | validationBandRepository.create(entity); 140 | 141 | try { 142 | await validationBatch.commit(); 143 | } catch (error) { 144 | expect(error[0].constraints.isEmail).toEqual('Invalid email!'); 145 | } 146 | }); 147 | 148 | it('should not run validations on delete', async () => { 149 | initialize(firestore, { validateModels: true }); 150 | 151 | const validationBatch = new FirestoreBatchUnit(firestore); 152 | const validationBandRepository = new BaseFirestoreBatchRepository(Band, validationBatch); 153 | 154 | const entity = new Band(); 155 | entity.id = 'perfect-circle'; 156 | entity.name = 'A Perfect Circle'; 157 | entity.formationYear = 1999; 158 | entity.genres = ['alternative-rock', 'alternative-metal', 'hard-rock']; 159 | entity.contactEmail = 'email@apc.com'; 160 | 161 | validationBandRepository.create(entity); 162 | await validationBatch.commit(); 163 | 164 | entity.contactEmail = 'email'; 165 | validationBandRepository.delete(entity); 166 | expect(validationBatch.commit).not.toThrow(); 167 | }); 168 | 169 | it('must not validate forbidden non-whitelisted properties if the validatorOptions: {}', async () => { 170 | initialize(firestore, { validateModels: true, validatorOptions: {} }); 171 | 172 | const validationBatch = new FirestoreBatchUnit(firestore); 173 | const validationBandRepository = new BaseFirestoreBatchRepository(Band, validationBatch); 174 | 175 | let entity = new Band(); 176 | entity = { 177 | ...entity, 178 | unknownProperty: 'unknown property', 179 | } as unknown as Band; 180 | 181 | validationBandRepository.create(entity); 182 | expect(validationBatch.commit).not.toThrow(); 183 | }); 184 | 185 | it('must validate forbidden non-whitelisted properties if the validatorOptions: {whitelist: true, forbidNonWhitelisted: true}', async () => { 186 | initialize(firestore, { 187 | validateModels: true, 188 | validatorOptions: { whitelist: true, forbidNonWhitelisted: true }, 189 | }); 190 | 191 | const validationBatch = new FirestoreBatchUnit(firestore); 192 | const validationBandRepository = new BaseFirestoreBatchRepository(Band, validationBatch); 193 | 194 | let entity = new Band(); 195 | entity = { 196 | ...entity, 197 | unknownProperty: 'unknown property', 198 | } as unknown as Band; 199 | 200 | validationBandRepository.create(entity); 201 | 202 | try { 203 | await validationBatch.commit(); 204 | } catch (error) { 205 | expect(error[0].constraints.whitelistValidation).toEqual( 206 | 'property unknownProperty should not exist' 207 | ); 208 | } 209 | }); 210 | }); 211 | 212 | // eslint-disable-next-line @typescript-eslint/no-empty-function 213 | describe('should handle subcollections', () => { 214 | it('should be able to create subcollections and initialize them', async () => { 215 | const band = new Band(); 216 | band.id = '30-seconds-to-mars'; 217 | band.name = '30 Seconds To Mars'; 218 | band.formationYear = 1998; 219 | band.genres = ['alternative-rock']; 220 | 221 | const firstAlbum = new Album(); 222 | firstAlbum.id = '30-seconds-to-mars'; 223 | firstAlbum.name = '30 Seconds to Mars'; 224 | firstAlbum.releaseDate = new Date('2002-07-22'); 225 | 226 | const secondAlbum = new Album(); 227 | secondAlbum.id = 'a-beautiful-lie'; 228 | secondAlbum.name = 'A Beautiful Lie'; 229 | secondAlbum.releaseDate = new Date('2005-07-30'); 230 | 231 | const thirdAlbum = new Album(); 232 | thirdAlbum.id = 'this-is-war'; 233 | thirdAlbum.name = 'This Is War'; 234 | thirdAlbum.releaseDate = new Date('2009-12-08'); 235 | 236 | // To save in db and initialize subcollections 237 | await bandRepository.create(band); 238 | 239 | const albumsBatch = band.albums.createBatch(); 240 | 241 | albumsBatch.create(firstAlbum); 242 | albumsBatch.create(secondAlbum); 243 | albumsBatch.create(thirdAlbum); 244 | await albumsBatch.commit(); 245 | 246 | [firstAlbum, secondAlbum, thirdAlbum].forEach((album, i) => { 247 | expect(batchStub.set.mock.calls[i][1]).toEqual({ 248 | id: album.id, 249 | name: album.name, 250 | releaseDate: album.releaseDate, 251 | }); 252 | }); 253 | }); 254 | 255 | it('should be able to validate subcollections on create', async () => { 256 | const band = new Band(); 257 | band.id = '30-seconds-to-mars'; 258 | band.name = '30 Seconds To Mars'; 259 | band.formationYear = 1998; 260 | band.genres = ['alternative-rock']; 261 | 262 | const firstAlbum = new Album(); 263 | firstAlbum.id = 'invalid-album-name'; 264 | firstAlbum.name = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; 265 | firstAlbum.releaseDate = new Date('2002-07-22'); 266 | 267 | // To save in db and initialize subcollections 268 | await bandRepository.create(band); 269 | 270 | const albumsBatch = band.albums.createBatch(); 271 | albumsBatch.create(firstAlbum); 272 | 273 | try { 274 | await albumsBatch.commit(); 275 | } catch (error) { 276 | expect(error[0].constraints.length).toEqual('Name is too long'); 277 | } 278 | }); 279 | 280 | it('should be able to update subcollections', async () => { 281 | const band = await bandRepository.findById('porcupine-tree'); 282 | const album = await band.albums.findById('fear-blank-planet'); 283 | album.comment = 'Anesthethize is top 3 IMHO'; 284 | 285 | const albumsBatch = band.albums.createBatch(); 286 | albumsBatch.update(album); 287 | 288 | await albumsBatch.commit(); 289 | 290 | expect(batchStub.update.mock.calls[0][1]).toEqual({ 291 | id: album.id, 292 | name: album.name, 293 | releaseDate: album.releaseDate, 294 | comment: album.comment, 295 | }); 296 | }); 297 | 298 | it('should be able to validate subcollections on update', async () => { 299 | const pt = await bandRepository.findById('porcupine-tree'); 300 | const album = await pt.albums.findById('fear-blank-planet'); 301 | 302 | album.name = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; 303 | const albumsBatch = pt.albums.createBatch(); 304 | albumsBatch.update(album); 305 | 306 | try { 307 | await albumsBatch.commit(); 308 | } catch (error) { 309 | expect(error[0].constraints.length).toEqual('Name is too long'); 310 | } 311 | }); 312 | 313 | it('should be able to delete subcollections', async () => { 314 | const pt = await bandRepository.findById('porcupine-tree'); 315 | const album = await pt.albums.findById('fear-blank-planet'); 316 | 317 | const albumsBatch = pt.albums.createBatch(); 318 | albumsBatch.delete(album); 319 | 320 | await albumsBatch.commit(); 321 | 322 | expect(batchStub.delete.mock.calls[0][1]).toEqual({ 323 | id: album.id, 324 | name: album.name, 325 | releaseDate: album.releaseDate, 326 | }); 327 | }); 328 | }); 329 | }); 330 | -------------------------------------------------------------------------------- /src/Batch/BaseFirestoreBatchRepository.ts: -------------------------------------------------------------------------------- 1 | import { CollectionReference } from '@google-cloud/firestore'; 2 | import { IEntity, WithOptionalId, IBatchRepository, EntityConstructorOrPath } from '../types'; 3 | import { getMetadataStorage } from '../MetadataUtils'; 4 | import { MetadataStorageConfig, FullCollectionMetadata } from '../MetadataStorage'; 5 | import { FirestoreBatchUnit } from './FirestoreBatchUnit'; 6 | import { NoMetadataError } from '../Errors'; 7 | export class BaseFirestoreBatchRepository implements IBatchRepository { 8 | protected colMetadata: FullCollectionMetadata; 9 | protected colRef: CollectionReference; 10 | protected config: MetadataStorageConfig; 11 | protected path: string; 12 | 13 | constructor( 14 | protected pathOrConstructor: EntityConstructorOrPath, 15 | protected batch: FirestoreBatchUnit 16 | ) { 17 | const { getCollection, firestoreRef, config } = getMetadataStorage(); 18 | 19 | const colMetadata = getCollection(pathOrConstructor); 20 | 21 | if (!colMetadata) { 22 | throw new NoMetadataError(pathOrConstructor); 23 | } 24 | 25 | this.colMetadata = colMetadata; 26 | this.path = typeof pathOrConstructor === 'string' ? pathOrConstructor : this.colMetadata.name; 27 | this.colRef = firestoreRef.collection(this.path); 28 | this.config = config; 29 | } 30 | 31 | create = (item: WithOptionalId) => { 32 | const doc = item.id ? this.colRef.doc(item.id) : this.colRef.doc(); 33 | if (!item.id) { 34 | item.id = doc.id; 35 | } 36 | 37 | this.batch.add( 38 | 'create', 39 | item as T, 40 | doc, 41 | this.colMetadata, 42 | this.config.validateModels, 43 | this.config.validatorOptions 44 | ); 45 | }; 46 | 47 | update = (item: T) => { 48 | this.batch.add( 49 | 'update', 50 | item, 51 | this.colRef.doc(item.id), 52 | this.colMetadata, 53 | this.config.validateModels 54 | ); 55 | }; 56 | 57 | delete = (item: T) => { 58 | this.batch.add( 59 | 'delete', 60 | item, 61 | this.colRef.doc(item.id), 62 | this.colMetadata, 63 | this.config.validateModels 64 | ); 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/Batch/FirestoreBatch.spec.ts: -------------------------------------------------------------------------------- 1 | import { Firestore } from '@google-cloud/firestore'; 2 | 3 | import { initialize } from '..'; 4 | import { Collection } from '../Decorators'; 5 | import { FirestoreBatch } from './FirestoreBatch'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-var-requires 8 | const MockFirebase = require('mock-cloud-firestore'); 9 | 10 | describe('FirestoreBatch', () => { 11 | let firestore: Firestore = undefined; 12 | 13 | beforeEach(() => { 14 | const firebase = new MockFirebase(); 15 | firestore = firebase.firestore(); 16 | initialize(firestore); 17 | }); 18 | 19 | describe('getRepository', () => { 20 | it('should return a valid BatchRepository', async () => { 21 | @Collection() 22 | class Entity { 23 | id: string; 24 | } 25 | 26 | const tran = new FirestoreBatch(firestore); 27 | 28 | const bandRepository = tran.getRepository(Entity); 29 | expect(bandRepository.constructor.name).toEqual('BaseFirestoreBatchRepository'); 30 | }); 31 | }); 32 | 33 | describe('getStandaloneRepository', () => { 34 | it('should return a valid BatchStandaloneRepository', async () => { 35 | @Collection() 36 | class Entity { 37 | id: string; 38 | } 39 | 40 | const tran = new FirestoreBatch(firestore); 41 | 42 | const bandRepository = tran.getSingleRepository(Entity); 43 | expect(bandRepository.constructor.name).toEqual('FirestoreBatchSingleRepository'); 44 | }); 45 | }); 46 | 47 | describe('commit', () => { 48 | it('should throw error if no ops', () => { 49 | const tran = new FirestoreBatch(firestore); 50 | 51 | expect(tran.commit()).rejects.toThrow('Cannot commit a batch with zero operations'); 52 | }); 53 | 54 | it.todo('when calling FirestoreBatch.commit it should call FirestoreBatchUnit.commit'); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/Batch/FirestoreBatch.ts: -------------------------------------------------------------------------------- 1 | import { IEntity, EntityConstructorOrPath, IFirestoreBatch } from '../types'; 2 | import { BaseFirestoreBatchRepository } from './BaseFirestoreBatchRepository'; 3 | import { FirestoreBatchSingleRepository } from './FirestoreBatchSingleRepository'; 4 | import { Firestore } from '@google-cloud/firestore'; 5 | import { FirestoreBatchUnit } from './FirestoreBatchUnit'; 6 | 7 | // TODO: handle status where batch was already committed. 8 | export class FirestoreBatch implements IFirestoreBatch { 9 | private batch: FirestoreBatchUnit; 10 | 11 | constructor(protected firestoreRef: Firestore) { 12 | this.batch = new FirestoreBatchUnit(firestoreRef); 13 | } 14 | 15 | /** 16 | * 17 | * Returns a batch repository of T. 18 | * 19 | * @template T 20 | * @param {EntityConstructorOrPath} entity path or constructor 21 | * @returns 22 | * @memberof FirestoreBatch 23 | */ 24 | getRepository(pathOrConstructor: EntityConstructorOrPath) { 25 | return new BaseFirestoreBatchRepository(pathOrConstructor, this.batch); 26 | } 27 | 28 | /** 29 | * 30 | * Returns a batch repository of a single entity. Required to maintain 31 | * current features and will be deleted in the next major version. 32 | * 33 | * @template T 34 | * @param {EntityConstructorOrPath} entity path or constructor 35 | * @returns 36 | * @memberof FirestoreBatch 37 | */ 38 | getSingleRepository(pathOrConstructor: EntityConstructorOrPath) { 39 | return new FirestoreBatchSingleRepository(pathOrConstructor, this.batch); 40 | } 41 | 42 | /** 43 | * 44 | * Commits current batch. 45 | * 46 | * @template T 47 | * @param {Constructor} entity 48 | * @returns 49 | * @memberof FirestoreBatch 50 | */ 51 | commit = () => { 52 | return this.batch.commit(); 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /src/Batch/FirestoreBatchSingleRepository.ts: -------------------------------------------------------------------------------- 1 | import { IEntity, IFirestoreBatchSingleRepository } from '../types'; 2 | import { BaseFirestoreBatchRepository } from './BaseFirestoreBatchRepository'; 3 | 4 | /** 5 | * 6 | * This class is only needed to maintain current batch functionality 7 | * inside repositories and might be deleted in the next major version 8 | * 9 | * @export 10 | * @class FirestoreBatchRepository 11 | * @extends {FirestoreBatchSingleRepository} 12 | * @template T 13 | */ 14 | export class FirestoreBatchSingleRepository 15 | extends BaseFirestoreBatchRepository 16 | implements IFirestoreBatchSingleRepository 17 | { 18 | async commit() { 19 | await this.batch.commit(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Batch/FirestoreBatchUnit.ts: -------------------------------------------------------------------------------- 1 | import { Firestore, DocumentReference } from '@google-cloud/firestore'; 2 | import { serializeEntity } from '../utils'; 3 | import type { FullCollectionMetadata } from '../MetadataStorage'; 4 | import type { ValidationError } from '../Errors/ValidationError'; 5 | import type { IEntity, Constructor, ValidatorOptions } from '../types'; 6 | 7 | type BatchOperation = { 8 | type: 'create' | 'update' | 'delete'; 9 | item: T; 10 | ref: DocumentReference; 11 | collectionMetadata: FullCollectionMetadata; 12 | validateModels: boolean; 13 | validatorOptions?: ValidatorOptions; 14 | }; 15 | 16 | export class FirestoreBatchUnit { 17 | private status: 'pending' | 'committing' = 'pending'; 18 | public operations: BatchOperation[] = []; 19 | 20 | constructor(private firestoreRef: Firestore) {} 21 | 22 | add( 23 | type: BatchOperation['type'], 24 | item: T, 25 | ref: DocumentReference, 26 | collectionMetadata: FullCollectionMetadata, 27 | validateModels: boolean, 28 | validatorOptions?: ValidatorOptions 29 | ) { 30 | this.operations.push({ 31 | type, 32 | item, 33 | ref, 34 | collectionMetadata, 35 | validateModels, 36 | validatorOptions, 37 | }); 38 | } 39 | 40 | commit = async () => { 41 | if (this.status === 'committing') { 42 | throw new Error('This Batch is being committed'); 43 | } 44 | 45 | if (this.operations.length === 0) { 46 | throw new Error('Cannot commit a batch with zero operations'); 47 | } 48 | 49 | this.status = 'committing'; 50 | const batch = this.firestoreRef.batch(); 51 | 52 | for (const op of this.operations) { 53 | if (op.validateModels && ['create', 'update'].includes(op.type)) { 54 | const errors = await this.validate( 55 | op.item, 56 | op.collectionMetadata.entityConstructor, 57 | op.validatorOptions 58 | ); 59 | 60 | if (errors.length) { 61 | throw errors; 62 | } 63 | } 64 | 65 | const serialized = serializeEntity(op.item, op.collectionMetadata.subCollections); 66 | 67 | switch (op.type) { 68 | case 'create': 69 | batch.set(op.ref, serialized); 70 | break; 71 | case 'update': 72 | batch.update(op.ref, serialized); 73 | break; 74 | case 'delete': 75 | batch.delete(op.ref, serialized); 76 | break; 77 | } 78 | } 79 | 80 | const result = await batch.commit(); 81 | this.operations = []; 82 | this.status = 'pending'; 83 | 84 | return result; 85 | }; 86 | 87 | async validate( 88 | item: IEntity, 89 | Entity: Constructor, 90 | validatorOptions?: ValidatorOptions 91 | ): Promise { 92 | try { 93 | const classValidator = await import('class-validator'); 94 | 95 | /** 96 | * Instantiate plain objects into an entity class 97 | */ 98 | const entity = item instanceof Entity ? item : Object.assign(new Entity(), item); 99 | 100 | return classValidator.validate(entity, validatorOptions); 101 | } catch (error) { 102 | if (error.code === 'MODULE_NOT_FOUND') { 103 | throw new Error( 104 | 'It looks like class-validator is not installed. Please run `npm i -S class-validator` to fix this error, or initialize FireORM with `validateModels: false` to disable validation.' 105 | ); 106 | } 107 | 108 | throw error; 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Decorators/Collection.spec.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from './Collection'; 2 | 3 | const setCollection = jest.fn(); 4 | jest.mock('../MetadataUtils', () => ({ 5 | getMetadataStorage: () => ({ 6 | setCollection, 7 | }), 8 | })); 9 | 10 | describe('CollectionDecorator', () => { 11 | beforeEach(() => { 12 | jest.resetAllMocks(); 13 | }); 14 | 15 | it('should register collections', () => { 16 | @Collection('foo') 17 | class Entity { 18 | id: string; 19 | } 20 | 21 | expect(setCollection).toHaveBeenCalledWith({ 22 | name: 'foo', 23 | entityConstructor: Entity, 24 | }); 25 | }); 26 | 27 | it('should register collections with default name', () => { 28 | @Collection() 29 | class Entity { 30 | id: string; 31 | } 32 | 33 | expect(setCollection).toHaveBeenCalledWith({ 34 | name: 'Entities', 35 | entityConstructor: Entity, 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/Decorators/Collection.ts: -------------------------------------------------------------------------------- 1 | import { getMetadataStorage } from '../MetadataUtils'; 2 | import { plural } from 'pluralize'; 3 | import type { IEntityConstructor } from '../types'; 4 | 5 | export function Collection(entityName?: string) { 6 | return function (entityConstructor: IEntityConstructor) { 7 | const name = entityName || plural(entityConstructor.name); 8 | getMetadataStorage().setCollection({ 9 | name, 10 | entityConstructor, 11 | }); 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/Decorators/CustomRepository.spec.ts: -------------------------------------------------------------------------------- 1 | import { CustomRepository } from './CustomRepository'; 2 | import { BaseFirestoreRepository } from '../BaseFirestoreRepository'; 3 | 4 | const setRepository = jest.fn(); 5 | jest.mock('../MetadataUtils', () => ({ 6 | getMetadataStorage: () => ({ 7 | setRepository, 8 | }), 9 | })); 10 | 11 | describe('CustomRepositoryDecorator', () => { 12 | beforeEach(() => { 13 | setRepository.mockReset(); 14 | }); 15 | 16 | it('should call metadataStorage.setRepository with right params', () => { 17 | class Entity { 18 | id: string; 19 | } 20 | 21 | @CustomRepository(Entity) 22 | class EntityRepo extends BaseFirestoreRepository {} 23 | 24 | expect(setRepository).toHaveBeenCalledWith({ 25 | entity: Entity, 26 | target: EntityRepo, 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/Decorators/CustomRepository.ts: -------------------------------------------------------------------------------- 1 | import { getMetadataStorage } from '../MetadataUtils'; 2 | import { Constructor, IRepository, IEntity } from '../types'; 3 | import { BaseRepository } from '../BaseRepository'; 4 | 5 | /* 6 | Cannot enforce the type in target presumably becasuse Typescript 7 | cannot verify than the T from the entity param is the same T from 8 | the repository. Might be interesting to revisit later 9 | */ 10 | export function CustomRepository(entity: Constructor) { 11 | return function (target: BaseRepository) { 12 | getMetadataStorage().setRepository({ 13 | entity, 14 | target: target as Constructor>, 15 | }); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/Decorators/Ignore.spec.ts: -------------------------------------------------------------------------------- 1 | import { Ignore, ignoreKey } from './Ignore'; 2 | 3 | describe('IgnoreDecorator', () => { 4 | it('should decorate properties', () => { 5 | class Band { 6 | id: string; 7 | foo: string; 8 | @Ignore() 9 | bar: string; 10 | } 11 | 12 | const band = new Band(); 13 | 14 | expect(Reflect.getMetadata(ignoreKey, band, 'foo')).toBe(undefined); 15 | expect(Reflect.getMetadata(ignoreKey, band, 'bar')).toBe(true); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/Decorators/Ignore.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | export const ignoreKey = Symbol('Ignore'); 3 | 4 | export function Ignore() { 5 | return Reflect.metadata(ignoreKey, true); 6 | } 7 | -------------------------------------------------------------------------------- /src/Decorators/Serialize.spec.ts: -------------------------------------------------------------------------------- 1 | import { Serialize, serializeKey } from './Serialize'; 2 | 3 | describe('IgnoreDecorator', () => { 4 | it('should decorate properties', () => { 5 | class Address { 6 | streetName: string; 7 | zipcode: string; 8 | } 9 | 10 | class Band { 11 | id: string; 12 | name: string; 13 | @Serialize(Address) 14 | address: Address; 15 | } 16 | 17 | const band = new Band(); 18 | 19 | expect(Reflect.getMetadata(serializeKey, band, 'name')).toBe(undefined); 20 | expect(Reflect.getMetadata(serializeKey, band, 'address')).toBe(Address); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/Decorators/Serialize.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { Constructor } from '../types'; 3 | export const serializeKey = Symbol('Serialize'); 4 | 5 | export function Serialize(entityConstructor: Constructor) { 6 | return Reflect.metadata(serializeKey, entityConstructor); 7 | } 8 | -------------------------------------------------------------------------------- /src/Decorators/SubCollection.spec.ts: -------------------------------------------------------------------------------- 1 | import { SubCollection } from './SubCollection'; 2 | import { ISubCollection } from '../types'; 3 | import { Collection } from './Collection'; 4 | 5 | const setCollection = jest.fn(); 6 | jest.mock('../MetadataUtils', () => ({ 7 | getMetadataStorage: () => ({ 8 | setCollection, 9 | }), 10 | })); 11 | 12 | describe('SubCollectionDecorator', () => { 13 | beforeEach(() => { 14 | jest.resetAllMocks(); 15 | }); 16 | 17 | it('should register collections', () => { 18 | class SubEntity { 19 | public id: string; 20 | } 21 | @Collection() 22 | class Entity { 23 | id: string; 24 | 25 | @SubCollection(SubEntity, 'subs') 26 | subentity: ISubCollection; 27 | } 28 | 29 | expect(setCollection).toHaveBeenCalledWith({ 30 | name: 'subs', 31 | entityConstructor: SubEntity, 32 | parentEntityConstructor: Entity, 33 | propertyKey: 'subentity', 34 | }); 35 | }); 36 | 37 | it('should register collections with default name', () => { 38 | class SubEntity { 39 | public id: string; 40 | } 41 | 42 | @Collection() 43 | class Entity { 44 | id: string; 45 | 46 | @SubCollection(SubEntity) 47 | subentity: ISubCollection; 48 | } 49 | 50 | expect(setCollection).toHaveBeenCalledWith({ 51 | name: 'SubEntities', 52 | entityConstructor: SubEntity, 53 | parentEntityConstructor: Entity, 54 | propertyKey: 'subentity', 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/Decorators/SubCollection.ts: -------------------------------------------------------------------------------- 1 | import { getMetadataStorage } from '../MetadataUtils'; 2 | import { plural } from 'pluralize'; 3 | import { IEntityConstructor, IEntity } from '../types'; 4 | 5 | export function SubCollection(entityConstructor: IEntityConstructor, entityName?: string) { 6 | return function (parentEntity: IEntity, propertyKey: string) { 7 | getMetadataStorage().setCollection({ 8 | entityConstructor, 9 | name: entityName || plural(entityConstructor.name), 10 | parentEntityConstructor: parentEntity.constructor as IEntityConstructor, 11 | propertyKey, 12 | }); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/Decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Collection'; 2 | export * from './CustomRepository'; 3 | export * from './Ignore'; 4 | export * from './Serialize'; 5 | export * from './SubCollection'; 6 | -------------------------------------------------------------------------------- /src/Errors/ValidationError.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /** 3 | * Copied from class-validator/validation/ValidationError.d.ts 4 | */ 5 | export declare class ValidationError { 6 | /** 7 | * Object that was validated. 8 | * 9 | * OPTIONAL - configurable via the ValidatorOptions.validationError.target option 10 | */ 11 | // eslint-disable-next-line @typescript-eslint/ban-types -- External module 12 | target?: object; 13 | /** 14 | * Object's property that haven't pass validation. 15 | */ 16 | property: string; 17 | /** 18 | * Value that haven't pass a validation. 19 | * 20 | * OPTIONAL - configurable via the ValidatorOptions.validationError.value option 21 | */ 22 | value?: any; 23 | /** 24 | * Constraints that failed validation with error messages. 25 | */ 26 | constraints?: { 27 | [type: string]: string; 28 | }; 29 | /** 30 | * Contains all nested validation errors of the property. 31 | */ 32 | children?: ValidationError[]; 33 | contexts?: { 34 | [type: string]: any; 35 | }; 36 | /** 37 | * 38 | * @param shouldDecorate decorate the message with ANSI formatter escape codes for better readability 39 | * @param hasParent true when the error is a child of an another one 40 | * @param parentPath path as string to the parent of this property 41 | */ 42 | toString(shouldDecorate?: boolean, hasParent?: boolean, parentPath?: string): string; 43 | } 44 | -------------------------------------------------------------------------------- /src/Errors/index.ts: -------------------------------------------------------------------------------- 1 | import type { EntityConstructorOrPath, IEntity } from '../types'; 2 | 3 | export class NoMetadataError extends Error { 4 | constructor(pathOrConstructor: EntityConstructorOrPath) { 5 | super( 6 | `There is no metadata stored for "${ 7 | typeof pathOrConstructor === 'string' ? pathOrConstructor : pathOrConstructor.name 8 | }"` 9 | ); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/MetadataStorage.spec.ts: -------------------------------------------------------------------------------- 1 | import { MetadataStorage, CollectionMetadata, RepositoryMetadata } from './MetadataStorage'; 2 | import { BaseFirestoreRepository } from './BaseFirestoreRepository'; 3 | import { IRepository, Constructor } from './types'; 4 | 5 | describe('MetadataStorage', () => { 6 | let metadataStorage: MetadataStorage = undefined; 7 | class Entity { 8 | id: string; 9 | } 10 | 11 | class SubEntity { 12 | id: string; 13 | } 14 | 15 | class SubSubEntity { 16 | public id: string; 17 | } 18 | 19 | const col: CollectionMetadata = { 20 | entityConstructor: Entity, 21 | name: 'entity', 22 | }; 23 | 24 | const subCol: CollectionMetadata = { 25 | entityConstructor: SubEntity, 26 | name: 'subEntity', 27 | parentEntityConstructor: Entity, 28 | propertyKey: 'subEntities', 29 | }; 30 | 31 | const subSubCol: CollectionMetadata = { 32 | entityConstructor: SubSubEntity, 33 | name: 'subSubEntity', 34 | parentEntityConstructor: SubEntity, 35 | propertyKey: 'subSubEntities', 36 | }; 37 | 38 | beforeEach(() => { 39 | metadataStorage = new MetadataStorage(); 40 | }); 41 | 42 | describe('getCollection', () => { 43 | beforeEach(() => { 44 | metadataStorage.setCollection(subSubCol); 45 | metadataStorage.setCollection(subCol); 46 | metadataStorage.setCollection(col); 47 | }); 48 | 49 | it('should get Collection by string', () => { 50 | const entityMetadata = metadataStorage.getCollection('entity'); 51 | 52 | expect(entityMetadata.entityConstructor).toEqual(col.entityConstructor); 53 | expect(entityMetadata.name).toEqual(col.name); 54 | expect(entityMetadata.segments).toEqual(['entity']); 55 | expect(entityMetadata.subCollections.length).toEqual(1); 56 | }); 57 | 58 | it('should get Collection by constructor', () => { 59 | const entityMetadata = metadataStorage.getCollection(Entity); 60 | 61 | expect(entityMetadata.entityConstructor).toEqual(col.entityConstructor); 62 | expect(entityMetadata.name).toEqual(col.name); 63 | expect(entityMetadata.segments).toEqual(['entity']); 64 | expect(entityMetadata.subCollections.length).toEqual(1); 65 | }); 66 | 67 | it('should get SubCollection by string', () => { 68 | const entityMetadata = metadataStorage.getCollection( 69 | 'entity/entity-id/subEntity/subEntity-id/subSubEntity' 70 | ); 71 | 72 | expect(entityMetadata.entityConstructor).toEqual(subSubCol.entityConstructor); 73 | expect(entityMetadata.name).toEqual(subSubCol.name); 74 | expect(entityMetadata.segments).toEqual(['entity', 'subEntity', 'subSubEntity']); 75 | expect(entityMetadata.subCollections.length).toEqual(0); 76 | }); 77 | 78 | it('should get SubCollection by constructor', () => { 79 | const entityMetadata = metadataStorage.getCollection(subSubCol.entityConstructor); 80 | 81 | expect(entityMetadata.entityConstructor).toEqual(subSubCol.entityConstructor); 82 | expect(entityMetadata.name).toEqual(subSubCol.name); 83 | expect(entityMetadata.segments).toEqual(['entity', 'subEntity', 'subSubEntity']); 84 | expect(entityMetadata.subCollections.length).toEqual(0); 85 | }); 86 | 87 | it('should return null when using invalid collection path', () => { 88 | const entityMetadata = metadataStorage.getCollection('this_is_not_a_path'); 89 | expect(entityMetadata).toEqual(null); 90 | }); 91 | 92 | it('should throw error if initialized with an invalid subcollection path', () => { 93 | const entityMetadata = metadataStorage.getCollection( 94 | 'entity/entity-id/subEntity/subEntity-id/fake-path' 95 | ); 96 | expect(entityMetadata).toEqual(null); 97 | }); 98 | 99 | it('should return null when using invalid collection constructor', () => { 100 | class NewEntity { 101 | id: string; 102 | } 103 | 104 | const entityMetadata = metadataStorage.getCollection(NewEntity); 105 | expect(entityMetadata).toEqual(null); 106 | }); 107 | 108 | it('should initialize subcollection metadata', () => { 109 | const entityMetadata = metadataStorage.getCollection('entity'); 110 | 111 | expect(entityMetadata.subCollections.length).toEqual(1); 112 | expect(entityMetadata.subCollections[0].entityConstructor).toEqual(subCol.entityConstructor); 113 | expect(entityMetadata.subCollections[0].segments).toEqual(['entity', 'subEntity']); 114 | }); 115 | 116 | it('should throw error if initialized with an incomplete path', () => { 117 | expect(() => 118 | metadataStorage.getCollection('entity/entity-id/subEntity/subEntity-id') 119 | ).toThrow('Invalid collection path: entity/entity-id/subEntity/subEntity-id'); 120 | }); 121 | }); 122 | 123 | describe('setCollection', () => { 124 | it('should store collections', () => { 125 | metadataStorage.setCollection(col); 126 | const collection = metadataStorage.collections.find( 127 | c => c.entityConstructor === col.entityConstructor 128 | ); 129 | 130 | expect(collection.entityConstructor).toEqual(col.entityConstructor); 131 | expect(collection.name).toEqual(col.name); 132 | expect(collection.parentEntityConstructor).toEqual(col.parentEntityConstructor); 133 | expect(collection.propertyKey).toEqual(col.propertyKey); 134 | expect(collection.segments).toEqual([col.name]); 135 | }); 136 | 137 | it('should throw when trying to store duplicate collections', () => { 138 | metadataStorage.setCollection(col); 139 | expect(() => metadataStorage.setCollection(col)).toThrowError( 140 | `Collection with name ${col.name} has already been registered` 141 | ); 142 | }); 143 | 144 | it('should update segments for nested subcollections', () => { 145 | // Due to the order of how the decorators are evaluated, 146 | // children collections are registered first 147 | metadataStorage.setCollection(subSubCol); 148 | metadataStorage.setCollection(subCol); 149 | metadataStorage.setCollection(col); 150 | 151 | const collection = metadataStorage.collections.find( 152 | c => c.entityConstructor === subSubCol.entityConstructor 153 | ); 154 | 155 | expect(collection.segments).toEqual([col.name, subCol.name, subSubCol.name]); 156 | }); 157 | }); 158 | 159 | describe('getRepository', () => { 160 | class EntityRepository extends BaseFirestoreRepository {} 161 | 162 | const entityRepository: RepositoryMetadata = { 163 | entity: Entity, 164 | target: EntityRepository as unknown as Constructor>, 165 | }; 166 | 167 | beforeEach(() => { 168 | metadataStorage.setRepository(entityRepository); 169 | }); 170 | 171 | it('should get repositories', () => { 172 | const repo = metadataStorage.getRepository(Entity); 173 | 174 | expect(repo.entity).toEqual(entityRepository.entity); 175 | expect(repo.target).toEqual(entityRepository.target); 176 | }); 177 | 178 | it('should return null for invalid repositories', () => { 179 | class WrongEntity { 180 | id: string; 181 | } 182 | 183 | const repo = metadataStorage.getRepository(WrongEntity); 184 | expect(repo).toEqual(null); 185 | }); 186 | }); 187 | 188 | describe('setRepository', () => { 189 | class EntityRepository extends BaseFirestoreRepository {} 190 | 191 | const entityRepository: RepositoryMetadata = { 192 | entity: Entity, 193 | target: EntityRepository as unknown as Constructor>, 194 | }; 195 | 196 | it('should store repositories', () => { 197 | metadataStorage.setRepository(entityRepository); 198 | expect(metadataStorage.getRepositories().size).toEqual(1); 199 | expect(metadataStorage.getRepositories().get(entityRepository.entity).entity).toEqual(Entity); 200 | }); 201 | 202 | it('should throw when trying to store two repositories with the same entity class', () => { 203 | class EntityRepository2 extends BaseFirestoreRepository {} 204 | 205 | const entityRepository2: RepositoryMetadata = { 206 | entity: Entity, 207 | target: EntityRepository2 as unknown as Constructor>, 208 | }; 209 | 210 | metadataStorage.setRepository(entityRepository); 211 | 212 | expect(() => metadataStorage.setRepository(entityRepository2)).toThrowError( 213 | 'Cannot register a custom repository twice with two different targets' 214 | ); 215 | }); 216 | 217 | it('should throw when trying to store repositories that dont inherit from BaseRepository', () => { 218 | class EntityRepository2 {} 219 | class Entity2 { 220 | id: string; 221 | } 222 | 223 | const entityRepository2: RepositoryMetadata = { 224 | entity: Entity2, 225 | target: EntityRepository2 as unknown as Constructor>, 226 | }; 227 | 228 | metadataStorage.setRepository(entityRepository); 229 | 230 | expect(() => metadataStorage.setRepository(entityRepository2)).toThrowError( 231 | 'Cannot register a custom repository on a class that does not inherit from BaseFirestoreRepository' 232 | ); 233 | }); 234 | }); 235 | }); 236 | -------------------------------------------------------------------------------- /src/MetadataStorage.ts: -------------------------------------------------------------------------------- 1 | import { Firestore } from '@google-cloud/firestore'; 2 | import { BaseRepository } from './BaseRepository'; 3 | import type { 4 | IEntityConstructor, 5 | Constructor, 6 | IEntity, 7 | IEntityRepositoryConstructor, 8 | ValidatorOptions, 9 | } from './types'; 10 | import { arraysAreEqual } from './utils'; 11 | 12 | export interface CollectionMetadata { 13 | name: string; 14 | entityConstructor: IEntityConstructor; 15 | parentEntityConstructor?: IEntityConstructor; 16 | propertyKey?: string; 17 | } 18 | 19 | export interface SubCollectionMetadata extends CollectionMetadata { 20 | parentEntityConstructor: IEntityConstructor; 21 | propertyKey: string; 22 | } 23 | 24 | export interface CollectionMetadataWithSegments extends CollectionMetadata { 25 | segments: string[]; 26 | } 27 | 28 | export interface SubCollectionMetadataWithSegments extends SubCollectionMetadata { 29 | segments: string[]; 30 | } 31 | 32 | export interface FullCollectionMetadata extends CollectionMetadataWithSegments { 33 | subCollections: SubCollectionMetadataWithSegments[]; 34 | } 35 | export interface RepositoryMetadata { 36 | target: IEntityRepositoryConstructor; 37 | entity: IEntityConstructor; 38 | } 39 | 40 | export interface MetadataStorageConfig { 41 | validateModels: boolean; 42 | validatorOptions?: ValidatorOptions; 43 | throwOnDuplicatedCollection?: boolean; 44 | } 45 | 46 | export class MetadataStorage { 47 | readonly collections: Array = []; 48 | protected readonly repositories: Map = new Map(); 49 | 50 | public config: MetadataStorageConfig = { 51 | validateModels: false, 52 | validatorOptions: {}, 53 | throwOnDuplicatedCollection: true 54 | }; 55 | 56 | public getCollection = (pathOrConstructor: string | IEntityConstructor) => { 57 | let collection: CollectionMetadataWithSegments | undefined; 58 | 59 | // If is a path like users/user-id/messages/message-id/senders, 60 | // take all the even segments [users/messages/senders] and 61 | // look for an entity with those segments 62 | if (typeof pathOrConstructor === 'string') { 63 | const segments = pathOrConstructor.split('/'); 64 | 65 | // Return null if incomplete segment 66 | if (segments.length % 2 === 0) { 67 | throw new Error(`Invalid collection path: ${pathOrConstructor}`); 68 | } 69 | 70 | const collectionSegments = segments.reduce( 71 | (acc, cur, index) => (index % 2 === 0 ? acc.concat(cur) : acc), 72 | [] 73 | ); 74 | 75 | collection = this.collections.find(c => arraysAreEqual(c.segments, collectionSegments)); 76 | } else { 77 | collection = this.collections.find(c => c.entityConstructor === pathOrConstructor); 78 | } 79 | 80 | if (!collection) { 81 | return null; 82 | } 83 | 84 | const subCollections = this.collections.filter( 85 | s => s.parentEntityConstructor === collection?.entityConstructor 86 | ) as SubCollectionMetadataWithSegments[]; 87 | 88 | return { 89 | ...collection, 90 | subCollections, 91 | }; 92 | }; 93 | 94 | public setCollection = (col: CollectionMetadata) => { 95 | const existing = this.getCollection(col.entityConstructor); 96 | if (existing && this.config.throwOnDuplicatedCollection == true) { 97 | throw new Error(`Collection with name ${existing.name} has already been registered`); 98 | } 99 | const colToAdd = { 100 | ...col, 101 | segments: [col.name], 102 | }; 103 | 104 | this.collections.push(colToAdd); 105 | 106 | const getWhereImParent = (p: Constructor) => 107 | this.collections.filter(c => c.parentEntityConstructor === p); 108 | 109 | const colsToUpdate = getWhereImParent(col.entityConstructor); 110 | 111 | // Update segments for subcollections and subcollections of subcollections 112 | while (colsToUpdate.length) { 113 | const c = colsToUpdate.pop(); 114 | 115 | if (!c) { 116 | return; 117 | } 118 | 119 | const parent = this.collections.find(p => p.entityConstructor === c.parentEntityConstructor); 120 | c.segments = parent?.segments.concat(c.name) || []; 121 | getWhereImParent(c.entityConstructor).forEach(col => colsToUpdate.push(col)); 122 | } 123 | }; 124 | 125 | public getRepository = (param: IEntityConstructor) => { 126 | return this.repositories.get(param) || null; 127 | }; 128 | 129 | public setRepository = (repo: RepositoryMetadata) => { 130 | const savedRepo = this.getRepository(repo.entity); 131 | 132 | if (savedRepo && repo.target !== savedRepo.target) { 133 | throw new Error('Cannot register a custom repository twice with two different targets'); 134 | } 135 | 136 | if (!(repo.target.prototype instanceof BaseRepository)) { 137 | throw new Error( 138 | 'Cannot register a custom repository on a class that does not inherit from BaseFirestoreRepository' 139 | ); 140 | } 141 | 142 | this.repositories.set(repo.entity, repo); 143 | }; 144 | 145 | public getRepositories = () => { 146 | return this.repositories; 147 | }; 148 | 149 | public firestoreRef: Firestore; 150 | } 151 | -------------------------------------------------------------------------------- /src/MetadataUtils.ts: -------------------------------------------------------------------------------- 1 | import { Firestore } from '@google-cloud/firestore'; 2 | import { MetadataStorage, MetadataStorageConfig } from './MetadataStorage'; 3 | 4 | export interface IMetadataStore { 5 | metadataStorage: MetadataStorage; 6 | } 7 | 8 | export function getStore(): IMetadataStore { 9 | return global as never; 10 | } 11 | 12 | function initializeMetadataStorage() { 13 | const store = getStore(); 14 | 15 | if (!store.metadataStorage) { 16 | store.metadataStorage = new MetadataStorage(); 17 | } 18 | } 19 | 20 | /** 21 | * Return exisiting metadataStorage, otherwise create if not present 22 | */ 23 | export const getMetadataStorage = (): MetadataStorage => { 24 | const store = getStore(); 25 | initializeMetadataStorage(); 26 | 27 | return store.metadataStorage; 28 | }; 29 | 30 | export const initialize = ( 31 | firestore: Firestore, 32 | config: MetadataStorageConfig = { validateModels: false, validatorOptions: {}, throwOnDuplicatedCollection: true } 33 | ): void => { 34 | initializeMetadataStorage(); 35 | 36 | const { metadataStorage } = getStore(); 37 | 38 | metadataStorage.firestoreRef = firestore; 39 | metadataStorage.config = config; 40 | }; 41 | 42 | /** 43 | * @deprecated Use initialize. This will be removed in a future version. 44 | */ 45 | export const Initialize = initialize; 46 | -------------------------------------------------------------------------------- /src/QueryBuilder.spec.ts: -------------------------------------------------------------------------------- 1 | import { QueryBuilder } from './QueryBuilder'; 2 | import { IQueryExecutor, IFireOrmQueryLine, FirestoreOperators } from './types'; 3 | class Test { 4 | id: string; 5 | } 6 | 7 | class FakeExecutor implements IQueryExecutor { 8 | public queries: IFireOrmQueryLine[]; 9 | execute(queries: IFireOrmQueryLine[]): Promise { 10 | this.queries = queries; 11 | return Promise.resolve([]); 12 | } 13 | } 14 | 15 | describe('QueryBuilder', () => { 16 | let executor: FakeExecutor = null; 17 | beforeEach(() => { 18 | executor = new FakeExecutor(); 19 | }); 20 | 21 | it('must build query', async () => { 22 | const queryBuilder = new QueryBuilder(executor); 23 | await queryBuilder.whereEqualTo('id', '1').find(); 24 | expect(executor.queries.length).toEqual(1); 25 | expect(executor.queries[0].operator).toEqual(FirestoreOperators.equal); 26 | expect(executor.queries[0].prop).toEqual('id'); 27 | expect(executor.queries[0].val).toEqual('1'); 28 | }); 29 | 30 | it('must build array value query', async () => { 31 | const queryBuilder = new QueryBuilder(executor); 32 | await queryBuilder.whereIn('id', ['1', '2']).find(); 33 | expect(executor.queries[0].operator).toEqual(FirestoreOperators.in); 34 | expect(executor.queries[0].prop).toEqual('id'); 35 | expect(executor.queries[0].val).toEqual(['1', '2']); 36 | }); 37 | 38 | it('must pipe queries', async () => { 39 | const queryBuilder = new QueryBuilder(executor); 40 | await queryBuilder 41 | .whereEqualTo('id', '0') 42 | .whereEqualTo('id', '1') 43 | .whereEqualTo('id', '2') 44 | .find(); 45 | expect(executor.queries.length).toEqual(3); 46 | expect(executor.queries[0].val).toEqual('0'); 47 | expect(executor.queries[1].val).toEqual('1'); 48 | expect(executor.queries[2].val).toEqual('2'); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/QueryBuilder.ts: -------------------------------------------------------------------------------- 1 | import { getPath } from 'ts-object-path'; 2 | 3 | import { 4 | IQueryBuilder, 5 | IFireOrmQueryLine, 6 | IOrderByParams, 7 | IFirestoreVal, 8 | FirestoreOperators, 9 | IQueryExecutor, 10 | IEntity, 11 | IWherePropParam, 12 | ICustomQuery, 13 | } from './types'; 14 | 15 | export class QueryBuilder implements IQueryBuilder { 16 | protected queries: Array = []; 17 | protected limitVal: number; 18 | protected orderByObj: IOrderByParams; 19 | protected customQueryFunction?: ICustomQuery; 20 | protected orderByFields: Set = new Set(); 21 | 22 | constructor(protected executor: IQueryExecutor) {} 23 | 24 | private extractWhereParam = (param: IWherePropParam) => { 25 | if (typeof param === 'string') return param; 26 | return getPath unknown>(param).join('.'); 27 | }; 28 | 29 | whereEqualTo(param: IWherePropParam, val: IFirestoreVal) { 30 | this.queries.push({ 31 | prop: this.extractWhereParam(param), 32 | val, 33 | operator: FirestoreOperators.equal, 34 | }); 35 | return this; 36 | } 37 | 38 | whereNotEqualTo(param: IWherePropParam, val: IFirestoreVal) { 39 | this.queries.push({ 40 | prop: this.extractWhereParam(param), 41 | val, 42 | operator: FirestoreOperators.notEqual, 43 | }); 44 | return this; 45 | } 46 | 47 | whereGreaterThan(prop: IWherePropParam, val: IFirestoreVal) { 48 | this.queries.push({ 49 | prop: this.extractWhereParam(prop), 50 | val, 51 | operator: FirestoreOperators.greaterThan, 52 | }); 53 | return this; 54 | } 55 | 56 | whereGreaterOrEqualThan(prop: IWherePropParam, val: IFirestoreVal) { 57 | this.queries.push({ 58 | prop: this.extractWhereParam(prop), 59 | val, 60 | operator: FirestoreOperators.greaterThanEqual, 61 | }); 62 | return this; 63 | } 64 | 65 | whereLessThan(prop: IWherePropParam, val: IFirestoreVal) { 66 | this.queries.push({ 67 | prop: this.extractWhereParam(prop), 68 | val, 69 | operator: FirestoreOperators.lessThan, 70 | }); 71 | return this; 72 | } 73 | 74 | whereLessOrEqualThan(prop: IWherePropParam, val: IFirestoreVal) { 75 | this.queries.push({ 76 | prop: this.extractWhereParam(prop), 77 | val, 78 | operator: FirestoreOperators.lessThanEqual, 79 | }); 80 | return this; 81 | } 82 | 83 | whereArrayContains(prop: IWherePropParam, val: IFirestoreVal) { 84 | this.queries.push({ 85 | prop: this.extractWhereParam(prop), 86 | val, 87 | operator: FirestoreOperators.arrayContains, 88 | }); 89 | return this; 90 | } 91 | 92 | whereArrayContainsAny(prop: IWherePropParam, val: IFirestoreVal[]) { 93 | if (val.length > 10) { 94 | throw new Error(` 95 | This query supports up to 10 values. You provided ${val.length}. 96 | For details please visit: https://firebase.google.com/docs/firestore/query-data/queries#in_not-in_and_array-contains-any 97 | `); 98 | } 99 | this.queries.push({ 100 | prop: this.extractWhereParam(prop), 101 | val, 102 | operator: FirestoreOperators.arrayContainsAny, 103 | }); 104 | return this; 105 | } 106 | 107 | whereIn(prop: IWherePropParam, val: IFirestoreVal[]) { 108 | if (val.length > 10) { 109 | throw new Error(` 110 | This query supports up to 10 values. You provided ${val.length}. 111 | For details please visit: https://firebase.google.com/docs/firestore/query-data/queries#in_not-in_and_array-contains-any 112 | `); 113 | } 114 | this.queries.push({ 115 | prop: this.extractWhereParam(prop), 116 | val, 117 | operator: FirestoreOperators.in, 118 | }); 119 | return this; 120 | } 121 | 122 | whereNotIn(prop: IWherePropParam, val: IFirestoreVal[]) { 123 | if (val.length > 10) { 124 | throw new Error(` 125 | This query supports up to 10 values. You provided ${val.length}. 126 | For details please visit: https://firebase.google.com/docs/firestore/query-data/queries#in_not-in_and_array-contains-any 127 | `); 128 | } 129 | this.queries.push({ 130 | prop: this.extractWhereParam(prop), 131 | val, 132 | operator: FirestoreOperators.notIn, 133 | }); 134 | return this; 135 | } 136 | 137 | limit(limitVal: number) { 138 | if (this.limitVal) { 139 | throw new Error( 140 | 'A limit function cannot be called more than once in the same query expression' 141 | ); 142 | } 143 | this.limitVal = limitVal; 144 | return this; 145 | } 146 | 147 | orderByAscending(prop: IWherePropParam) { 148 | const fieldProp: string = typeof prop == 'string' ? prop : ''; 149 | const alreadyOrderedByField = this.orderByFields.has(fieldProp); 150 | 151 | if (this.orderByObj && alreadyOrderedByField) { 152 | throw new Error( 153 | 'An orderBy function cannot be called more than once in the same query expression' 154 | ); 155 | } 156 | 157 | if (!alreadyOrderedByField && fieldProp) { 158 | this.orderByFields.add(fieldProp); 159 | } 160 | 161 | this.orderByObj = { 162 | fieldPath: this.extractWhereParam(prop), 163 | directionStr: 'asc', 164 | }; 165 | 166 | return this; 167 | } 168 | 169 | orderByDescending(prop: IWherePropParam) { 170 | const fieldProp: string = typeof prop == 'string' ? prop : ''; 171 | const alreadyOrderedByField = this.orderByFields.has(fieldProp); 172 | 173 | if (this.orderByObj && alreadyOrderedByField) { 174 | throw new Error( 175 | 'An orderBy function cannot be called more than once in the same query expression' 176 | ); 177 | } 178 | 179 | if (!alreadyOrderedByField && fieldProp) { 180 | this.orderByFields.add(fieldProp); 181 | } 182 | 183 | this.orderByObj = { 184 | fieldPath: this.extractWhereParam(prop), 185 | directionStr: 'desc', 186 | }; 187 | 188 | return this; 189 | } 190 | 191 | find() { 192 | return this.executor.execute( 193 | this.queries, 194 | this.limitVal, 195 | this.orderByObj, 196 | false, 197 | this.customQueryFunction 198 | ); 199 | } 200 | 201 | customQuery(func: ICustomQuery) { 202 | if (this.customQueryFunction) { 203 | throw new Error('Only one custom query can be used per query expression'); 204 | } 205 | 206 | this.customQueryFunction = func; 207 | 208 | return this; 209 | } 210 | 211 | async findOne() { 212 | const queryResult = await this.executor.execute( 213 | this.queries, 214 | this.limitVal, 215 | this.orderByObj, 216 | true, 217 | this.customQueryFunction 218 | ); 219 | 220 | return queryResult.length ? queryResult[0] : null; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/Transaction/BaseFirestoreTransactionRepository.ts: -------------------------------------------------------------------------------- 1 | import { Query, Transaction, WhereFilterOp } from '@google-cloud/firestore'; 2 | 3 | import { 4 | IEntity, 5 | IFireOrmQueryLine, 6 | WithOptionalId, 7 | IQueryBuilder, 8 | ITransactionRepository, 9 | EntityConstructorOrPath, 10 | ITransactionReferenceStorage, 11 | } from '../types'; 12 | 13 | import { AbstractFirestoreRepository } from '../AbstractFirestoreRepository'; 14 | export class TransactionRepository 15 | extends AbstractFirestoreRepository 16 | implements ITransactionRepository 17 | { 18 | constructor( 19 | pathOrConstructor: EntityConstructorOrPath, 20 | private transaction: Transaction, 21 | private tranRefStorage: ITransactionReferenceStorage 22 | ) { 23 | super(pathOrConstructor); 24 | this.transaction = transaction; 25 | this.tranRefStorage = tranRefStorage; 26 | } 27 | 28 | async execute(queries: IFireOrmQueryLine[]): Promise { 29 | const query = queries.reduce((acc, cur) => { 30 | const op = cur.operator as WhereFilterOp; 31 | return acc.where(cur.prop, op, cur.val); 32 | }, this.firestoreColRef); 33 | 34 | return this.transaction 35 | .get(query) 36 | .then(c => this.extractTFromColSnap(c, this.transaction, this.tranRefStorage)); 37 | } 38 | 39 | findById(id: string) { 40 | const query = this.firestoreColRef.doc(id); 41 | 42 | return this.transaction 43 | .get(query) 44 | .then(c => 45 | c.exists ? this.extractTFromDocSnap(c, this.transaction, this.tranRefStorage) : null 46 | ); 47 | } 48 | 49 | async create(item: WithOptionalId): Promise { 50 | if (this.config.validateModels) { 51 | const errors = await this.validate(item as T); 52 | 53 | if (errors.length) { 54 | throw errors; 55 | } 56 | } 57 | 58 | const doc = item.id ? this.firestoreColRef.doc(item.id) : this.firestoreColRef.doc(); 59 | 60 | if (!item.id) { 61 | item.id = doc.id; 62 | } 63 | 64 | this.transaction.set(doc, this.toSerializableObject(item as T)); 65 | this.initializeSubCollections(item as T, this.transaction, this.tranRefStorage); 66 | 67 | return item as T; 68 | } 69 | 70 | async update(item: T): Promise { 71 | if (this.config.validateModels) { 72 | const errors = await this.validate(item); 73 | 74 | if (errors.length) { 75 | throw errors; 76 | } 77 | } 78 | 79 | const query = this.firestoreColRef.doc(item.id); 80 | this.transaction.update(query, this.toSerializableObject(item)); 81 | 82 | return item; 83 | } 84 | 85 | async delete(id: string): Promise { 86 | this.transaction.delete(this.firestoreColRef.doc(id)); 87 | } 88 | 89 | limit(): IQueryBuilder { 90 | throw new Error('`limit` is not available for transactions'); 91 | } 92 | 93 | orderByAscending(): IQueryBuilder { 94 | throw new Error('`orderByAscending` is not available for transactions'); 95 | } 96 | 97 | orderByDescending(): IQueryBuilder { 98 | throw new Error('`orderByDescending` is not available for transactions'); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Transaction/FirestoreTransaction.spec.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from '@google-cloud/firestore'; 2 | 3 | import { FirestoreTransaction } from './FirestoreTransaction'; 4 | import { initialize } from '..'; 5 | import { Collection } from '../Decorators'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-var-requires 8 | const MockFirebase = require('mock-cloud-firestore'); 9 | 10 | describe('FirestoreTransaction', () => { 11 | beforeEach(() => { 12 | const firebase = new MockFirebase(); 13 | const firestore = firebase.firestore(); 14 | initialize(firestore); 15 | }); 16 | 17 | describe('getRepository', () => { 18 | it('should return a valid TransactionRepository', async () => { 19 | @Collection() 20 | class Entity { 21 | id: string; 22 | } 23 | 24 | const innerTran = {} as Transaction; 25 | const tran = new FirestoreTransaction(innerTran, new Set()); 26 | 27 | const bandRepository = tran.getRepository(Entity); 28 | expect(bandRepository.constructor.name).toEqual('TransactionRepository'); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/Transaction/FirestoreTransaction.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from '@google-cloud/firestore'; 2 | import { TransactionRepository } from './BaseFirestoreTransactionRepository'; 3 | import { getMetadataStorage } from '../MetadataUtils'; 4 | import { 5 | IEntity, 6 | EntityConstructorOrPath, 7 | IFirestoreTransaction, 8 | ITransactionReferenceStorage, 9 | } from '../types'; 10 | 11 | const metadataStorage = getMetadataStorage(); 12 | 13 | export class FirestoreTransaction implements IFirestoreTransaction { 14 | constructor( 15 | private transaction: Transaction, 16 | private tranRefStorage: ITransactionReferenceStorage 17 | ) {} 18 | 19 | getRepository(entityOrConstructor: EntityConstructorOrPath) { 20 | if (!metadataStorage.firestoreRef) { 21 | throw new Error('Firestore must be initialized first'); 22 | } 23 | 24 | return new TransactionRepository(entityOrConstructor, this.transaction, this.tranRefStorage); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/TypeGuards.ts: -------------------------------------------------------------------------------- 1 | import { Timestamp, GeoPoint, DocumentReference } from '@google-cloud/firestore'; 2 | 3 | export function isTimestamp(x: unknown): x is Timestamp { 4 | return typeof x === 'object' && x !== null && 'toDate' in x; 5 | } 6 | 7 | export function isGeoPoint(x: unknown): x is GeoPoint { 8 | return typeof x === 'object' && x !== null && x.constructor.name === 'GeoPoint'; 9 | } 10 | 11 | export function isDocumentReference(x: unknown): x is DocumentReference { 12 | return typeof x === 'object' && x !== null && x.constructor.name === 'DocumentReference'; 13 | } 14 | 15 | export function isObject(x: unknown): x is Record { 16 | return typeof x === 'object'; 17 | } 18 | -------------------------------------------------------------------------------- /src/helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { Collection, CustomRepository } from './Decorators'; 2 | import { BaseFirestoreRepository } from './BaseFirestoreRepository'; 3 | import { getRepository, getBaseRepository, runTransaction, createBatch } from './helpers'; 4 | import { initialize } from './MetadataUtils'; 5 | import { FirestoreTransaction } from './Transaction/FirestoreTransaction'; 6 | import { FirestoreBatch } from './Batch/FirestoreBatch'; 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-var-requires 9 | const MockFirebase = require('mock-cloud-firestore'); 10 | 11 | describe('Helpers', () => { 12 | beforeEach(() => { 13 | const firebase = new MockFirebase(); 14 | const firestore = firebase.firestore(); 15 | initialize(firestore); 16 | }); 17 | 18 | it('getRepository: should get custom repositories', () => { 19 | @Collection() 20 | class Entity { 21 | id: string; 22 | } 23 | 24 | @CustomRepository(Entity) 25 | class EntityRepo extends BaseFirestoreRepository { 26 | meaningOfLife() { 27 | return 42; 28 | } 29 | } 30 | 31 | const rep = getRepository(Entity) as EntityRepo; 32 | expect(rep).toBeInstanceOf(BaseFirestoreRepository); 33 | expect(rep.meaningOfLife()).toEqual(42); 34 | }); 35 | 36 | it('should get base repositories if custom are not registered', () => { 37 | @Collection() 38 | class Entity { 39 | id: string; 40 | } 41 | 42 | const rep = getRepository(Entity); 43 | expect(rep).toBeInstanceOf(BaseFirestoreRepository); 44 | }); 45 | 46 | it('should throw if trying to get an unexistent collection', () => { 47 | class Entity { 48 | id: string; 49 | } 50 | 51 | expect(() => getRepository(Entity)).toThrow("'Entity' is not a valid collection"); 52 | }); 53 | 54 | it('should get base repository even if a custom one is registered', () => { 55 | @Collection() 56 | class Entity { 57 | id: string; 58 | } 59 | 60 | @CustomRepository(Entity) 61 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 62 | class EntityRepo extends BaseFirestoreRepository { 63 | meaningOfLife() { 64 | return 42; 65 | } 66 | } 67 | 68 | const rep = getBaseRepository(Entity); 69 | expect(rep).toBeInstanceOf(BaseFirestoreRepository); 70 | expect(rep['meaningOfLife']).toBeUndefined; 71 | }); 72 | 73 | it('should throw if trying to get an unexistent collection', () => { 74 | class Entity { 75 | id: string; 76 | } 77 | 78 | expect(() => getRepository(Entity)).toThrow("'Entity' is not a valid collection"); 79 | }); 80 | 81 | it('runTransaction: should be able to get a transaction repository', async () => { 82 | await runTransaction(async transaction => { 83 | expect(transaction).toBeInstanceOf(FirestoreTransaction); 84 | }); 85 | }); 86 | 87 | it('createBatch: should be able to get a batch repository', () => { 88 | expect(createBatch()).toBeInstanceOf(FirestoreBatch); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { getMetadataStorage } from './MetadataUtils'; 2 | import { BaseFirestoreRepository } from './BaseFirestoreRepository'; 3 | import { IEntity, EntityConstructorOrPath, ITransactionReferenceStorage } from './types'; 4 | import { FirestoreTransaction } from './Transaction/FirestoreTransaction'; 5 | import { FirestoreBatch } from './Batch/FirestoreBatch'; 6 | 7 | type RepositoryType = 'default' | 'base' | 'custom' | 'transaction'; 8 | 9 | // TODO: return transaction repo. Is it needed?!? 10 | function _getRepository( 11 | entityConstructorOrPath: EntityConstructorOrPath, 12 | repositoryType: RepositoryType 13 | ): BaseFirestoreRepository { 14 | const metadataStorage = getMetadataStorage(); 15 | 16 | if (!metadataStorage.firestoreRef) { 17 | throw new Error('Firestore must be initialized first'); 18 | } 19 | 20 | const collection = metadataStorage.getCollection(entityConstructorOrPath); 21 | 22 | const isPath = typeof entityConstructorOrPath === 'string'; 23 | const collectionName = 24 | typeof entityConstructorOrPath === 'string' 25 | ? entityConstructorOrPath 26 | : entityConstructorOrPath.name; 27 | 28 | // TODO: create tests 29 | if (!collection) { 30 | const error = isPath 31 | ? `'${collectionName}' is not a valid path for a collection` 32 | : `'${collectionName}' is not a valid collection`; 33 | throw new Error(error); 34 | } 35 | 36 | const repository = metadataStorage.getRepository(collection.entityConstructor); 37 | 38 | if (repositoryType === 'custom' && !repository) { 39 | throw new Error(`'${collectionName}' does not have a custom repository.`); 40 | } 41 | 42 | // If the collection has a parent, check that we have registered the parent 43 | if (collection.parentEntityConstructor) { 44 | const parentCollection = metadataStorage.getCollection(collection.parentEntityConstructor); 45 | 46 | if (!parentCollection) { 47 | throw new Error(`'${collectionName}' does not have a valid parent collection.`); 48 | } 49 | } 50 | 51 | if (repositoryType === 'custom' || (repositoryType === 'default' && repository)) { 52 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 53 | return new (repository?.target as any)(entityConstructorOrPath); 54 | } else { 55 | return new BaseFirestoreRepository(entityConstructorOrPath); 56 | } 57 | } 58 | 59 | export function getRepository( 60 | entityConstructorOrPath: EntityConstructorOrPath 61 | ) { 62 | return _getRepository(entityConstructorOrPath, 'default'); 63 | } 64 | 65 | /** 66 | * @deprecated Use getRepository. This will be removed in a future version. 67 | */ 68 | export const GetRepository = getRepository; 69 | 70 | export function getCustomRepository(entityOrPath: EntityConstructorOrPath) { 71 | return _getRepository(entityOrPath, 'custom'); 72 | } 73 | 74 | /** 75 | * @deprecated Use getCustomRepository. This will be removed in a future version. 76 | */ 77 | export const GetCustomRepository = getCustomRepository; 78 | 79 | export function getBaseRepository(entityOrPath: EntityConstructorOrPath) { 80 | return _getRepository(entityOrPath, 'base'); 81 | } 82 | 83 | /** 84 | * @deprecated Use getBaseRepository. This will be removed in a future version. 85 | */ 86 | export const GetBaseRepository = getBaseRepository; 87 | 88 | export const runTransaction = async (executor: (tran: FirestoreTransaction) => Promise) => { 89 | const metadataStorage = getMetadataStorage(); 90 | 91 | if (!metadataStorage.firestoreRef) { 92 | throw new Error('Firestore must be initialized first'); 93 | } 94 | 95 | return metadataStorage.firestoreRef.runTransaction(async t => { 96 | const tranRefStorage: ITransactionReferenceStorage = new Set(); 97 | const result = await executor(new FirestoreTransaction(t, tranRefStorage)); 98 | 99 | tranRefStorage.forEach(({ entity, path, propertyKey }) => { 100 | const record = entity as unknown as Record; 101 | record[propertyKey] = getRepository(path); 102 | }); 103 | 104 | return result; 105 | }); 106 | }; 107 | 108 | export const createBatch = () => { 109 | const metadataStorage = getMetadataStorage(); 110 | 111 | if (!metadataStorage.firestoreRef) { 112 | throw new Error('Firestore must be initialized first'); 113 | } 114 | 115 | return new FirestoreBatch(metadataStorage.firestoreRef); 116 | }; 117 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Decorators'; 2 | export * from './BaseFirestoreRepository'; 3 | export * from './types'; 4 | export * from './helpers'; 5 | export { initialize, Initialize } from './MetadataUtils'; 6 | 7 | // Temporary while https://github.com/wovalle/fireorm/issues/58 is being fixed 8 | export { Type } from 'class-transformer'; 9 | -------------------------------------------------------------------------------- /src/todo.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wovalle/fireorm/eb29cdcb378a9ddc2a4f2e15974eeccaf23382fc/src/todo.md -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["Decorators"], 4 | "exclude": ["**/*.spec.ts"], 5 | "compilerOptions": { 6 | "noEmit": true, 7 | "strict": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { OrderByDirection, DocumentReference, CollectionReference } from '@google-cloud/firestore'; 2 | import { Query } from '@google-cloud/firestore'; 3 | 4 | export type PartialBy = Omit & Partial>; 5 | export type PartialWithRequiredBy = Pick & Partial>; 6 | 7 | export type WithOptionalId = Pick> & 8 | Partial>; 9 | 10 | export type IFirestoreVal = string | number | Date | boolean | DocumentReference | null; 11 | 12 | export enum FirestoreOperators { 13 | equal = '==', 14 | notEqual = '!=', 15 | lessThan = '<', 16 | greaterThan = '>', 17 | lessThanEqual = '<=', 18 | greaterThanEqual = '>=', 19 | arrayContains = 'array-contains', 20 | arrayContainsAny = 'array-contains-any', 21 | in = 'in', 22 | notIn = 'not-in', 23 | } 24 | 25 | export interface IFireOrmQueryLine { 26 | prop: string; 27 | val: IFirestoreVal | IFirestoreVal[]; 28 | operator: FirestoreOperators; 29 | } 30 | 31 | export interface IOrderByParams { 32 | fieldPath: string; 33 | directionStr: OrderByDirection; 34 | } 35 | 36 | export type IQueryBuilderResult = IFireOrmQueryLine[]; 37 | 38 | export type IWherePropParam = keyof T | ((t: T) => unknown); 39 | 40 | export interface IQueryable { 41 | whereEqualTo(prop: IWherePropParam, val: IFirestoreVal): IQueryBuilder; 42 | whereNotEqualTo(prop: IWherePropParam, val: IFirestoreVal): IQueryBuilder; 43 | whereGreaterThan(prop: IWherePropParam, val: IFirestoreVal): IQueryBuilder; 44 | whereGreaterOrEqualThan(prop: IWherePropParam, val: IFirestoreVal): IQueryBuilder; 45 | whereLessThan(prop: IWherePropParam, val: IFirestoreVal): IQueryBuilder; 46 | whereLessOrEqualThan(prop: IWherePropParam, val: IFirestoreVal): IQueryBuilder; 47 | whereArrayContains(prop: IWherePropParam, val: IFirestoreVal): IQueryBuilder; 48 | whereArrayContainsAny(prop: IWherePropParam, val: IFirestoreVal[]): IQueryBuilder; 49 | whereIn(prop: IWherePropParam, val: IFirestoreVal[]): IQueryBuilder; 50 | whereNotIn(prop: IWherePropParam, val: IFirestoreVal[]): IQueryBuilder; 51 | find(): Promise; 52 | findOne(): Promise; 53 | customQuery(func: ICustomQuery): IQueryBuilder; 54 | } 55 | 56 | export interface IOrderable { 57 | orderByAscending(prop: IWherePropParam): IQueryBuilder; 58 | orderByDescending(prop: IWherePropParam): IQueryBuilder; 59 | } 60 | 61 | export interface ILimitable { 62 | limit(limitVal: number): IQueryBuilder; 63 | } 64 | 65 | export type IQueryBuilder = IQueryable & IOrderable & ILimitable; 66 | 67 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 68 | export type ICustomQuery = ( 69 | query: Query, 70 | firestoreColRef: CollectionReference 71 | ) => Promise; 72 | 73 | export interface IQueryExecutor { 74 | execute( 75 | queries: IFireOrmQueryLine[], 76 | limitVal?: number, 77 | orderByObj?: IOrderByParams, 78 | single?: boolean, 79 | customQuery?: ICustomQuery 80 | ): Promise; 81 | } 82 | 83 | export interface IBatchRepository { 84 | create(item: WithOptionalId): void; 85 | update(item: T): void; 86 | delete(item: T): void; 87 | } 88 | 89 | export interface ISingleBatchRepository extends IBatchRepository { 90 | commit(): Promise; 91 | } 92 | 93 | export interface IFirestoreBatchSingleRepository extends IBatchRepository { 94 | commit(): Promise; 95 | } 96 | 97 | export interface IFirestoreBatch { 98 | getRepository(entity: Constructor): IBatchRepository; 99 | getSingleRepository( 100 | pathOrConstructor: EntityConstructorOrPath 101 | ): IFirestoreBatchSingleRepository; 102 | 103 | commit(): Promise; 104 | } 105 | 106 | export interface IBaseRepository { 107 | findById(id: string): Promise; 108 | create(item: PartialBy): Promise; 109 | update(item: PartialWithRequiredBy): Promise>; 110 | delete(id: string): Promise; 111 | } 112 | 113 | export type IRepository = IBaseRepository & 114 | IQueryBuilder & 115 | IQueryExecutor; 116 | 117 | export type ITransactionRepository = IRepository; 118 | 119 | export interface ITransactionReference { 120 | entity: T; 121 | propertyKey: string; 122 | path: string; 123 | } 124 | 125 | export type ITransactionReferenceStorage = Set; 126 | 127 | // TODO: shouldn't this be in IRepository? 128 | export type ISubCollection = IRepository & { 129 | createBatch: () => IFirestoreBatchSingleRepository; 130 | runTransaction(executor: (tran: ITransactionRepository) => Promise): Promise; 131 | }; 132 | 133 | export interface IEntity { 134 | id: string; 135 | } 136 | 137 | export type Constructor = { new (): T }; 138 | export type EntityConstructorOrPathConstructor = { new (): T }; 139 | export type IEntityConstructor = Constructor; 140 | export type IEntityRepositoryConstructor = Constructor>; 141 | export type EntityConstructorOrPath = Constructor | string; 142 | 143 | export interface IFirestoreTransaction { 144 | getRepository(entityOrConstructor: EntityConstructorOrPath): IRepository; 145 | } 146 | 147 | /** 148 | * Taken From: class-validator/validation/ValidationError.d.ts 149 | * 150 | * Options passed to validator during validation. 151 | */ 152 | export interface ValidatorOptions { 153 | /** 154 | * If set to true then validator will skip validation of all properties that are undefined in the validating object. 155 | */ 156 | skipUndefinedProperties?: boolean; 157 | /** 158 | * If set to true then validator will skip validation of all properties that are null in the validating object. 159 | */ 160 | skipNullProperties?: boolean; 161 | /** 162 | * If set to true then validator will skip validation of all properties that are null or undefined in the validating object. 163 | */ 164 | skipMissingProperties?: boolean; 165 | /** 166 | * If set to true validator will strip validated object of any properties that do not have any decorators. 167 | * 168 | * Tip: if no other decorator is suitable for your property use @Allow decorator. 169 | */ 170 | whitelist?: boolean; 171 | /** 172 | * If set to true, instead of stripping non-whitelisted properties validator will throw an error 173 | */ 174 | forbidNonWhitelisted?: boolean; 175 | /** 176 | * Groups to be used during validation of the object. 177 | */ 178 | groups?: string[]; 179 | /** 180 | * If set to true, the validation will not use default messages. 181 | * Error message always will be undefined if its not explicitly set. 182 | */ 183 | dismissDefaultMessages?: boolean; 184 | /** 185 | * ValidationError special options. 186 | */ 187 | validationError?: { 188 | /** 189 | * Indicates if target should be exposed in ValidationError. 190 | */ 191 | target?: boolean; 192 | /** 193 | * Indicates if validated value should be exposed in ValidationError. 194 | */ 195 | value?: boolean; 196 | }; 197 | /** 198 | * Settings true will cause fail validation of unknown objects. 199 | */ 200 | forbidUnknownValues?: boolean; 201 | } 202 | -------------------------------------------------------------------------------- /src/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { Ignore, Serialize } from './Decorators'; 2 | import { IEntity } from './types'; 3 | import { extractAllGetters, serializeEntity } from './utils'; 4 | 5 | describe('Utils', () => { 6 | describe('extractAllGetter', () => { 7 | describe('Class', () => { 8 | it('should return only getters not data property', () => { 9 | class ClassTest { 10 | public a = 'a'; 11 | private _c; 12 | 13 | public get b() { 14 | return 'b'; 15 | } 16 | } 17 | 18 | const b = new ClassTest(); 19 | 20 | const extracted = extractAllGetters(b as unknown as Record); 21 | expect(extracted).toEqual({ b: 'b' }); 22 | }); 23 | 24 | it('should return only getters not method', () => { 25 | class ClassTest { 26 | public get b() { 27 | return 'b'; 28 | } 29 | 30 | public a() { 31 | return 'a method'; 32 | } 33 | } 34 | 35 | const b = new ClassTest(); 36 | 37 | const extracted = extractAllGetters(b as unknown as Record); 38 | expect(extracted).toEqual({ b: 'b' }); 39 | }); 40 | }); 41 | 42 | it('should return only getters which return undefined', () => { 43 | class ClassTest { 44 | public a = 'a'; 45 | 46 | public get b() { 47 | return 'b'; 48 | } 49 | 50 | public get c() { 51 | return undefined; 52 | } 53 | } 54 | 55 | const b = new ClassTest(); 56 | 57 | const extracted = extractAllGetters(b as unknown as Record); 58 | expect(extracted).toEqual({ b: 'b' }); 59 | }); 60 | }); 61 | 62 | describe('serializeEntity', () => { 63 | it('should not return properties with an Ignore() decorator', () => { 64 | class Band implements IEntity { 65 | id: string; 66 | name: string; 67 | @Ignore() 68 | temporaryName: string; 69 | } 70 | 71 | const rhcp = new Band(); 72 | rhcp.name = 'Red Hot Chili Peppers'; 73 | rhcp.temporaryName = 'Tony Flow and the Miraculously Majestic Masters of Mayhem'; 74 | 75 | expect(serializeEntity(rhcp, [])).toHaveProperty('name'); 76 | expect(serializeEntity(rhcp, [])).not.toHaveProperty('temporaryName'); 77 | }); 78 | 79 | it('should not return getter properties with an Ignore() decorator', () => { 80 | class Band implements IEntity { 81 | id: string; 82 | name: string; 83 | 84 | get removeFirstLetterOfName() { 85 | if (!this.name) return ''; 86 | return this.name.charAt(0); 87 | } 88 | 89 | @Ignore() 90 | get capitalizedName() { 91 | if (!this.name) return ''; 92 | return this.name.charAt(0).toUpperCase() + this.name.slice(1); 93 | } 94 | } 95 | 96 | const rhcp = new Band(); 97 | rhcp.name = 'red Hot Chili Peppers'; 98 | 99 | expect(serializeEntity(rhcp, [])).toHaveProperty('name'); 100 | expect(serializeEntity(rhcp, [])).toHaveProperty('removeFirstLetterOfName'); 101 | expect(serializeEntity(rhcp, [])).not.toHaveProperty('capitalizedName'); 102 | }); 103 | 104 | it('should serialize object properties with the @Serialize() decorator', () => { 105 | class Address { 106 | streetName: string; 107 | number: number; 108 | numberAddition: string; 109 | } 110 | 111 | class Band implements IEntity { 112 | id: string; 113 | name: string; 114 | @Serialize(Address) 115 | address: Address; 116 | } 117 | 118 | const address = new Address(); 119 | address.streetName = 'Baker St.'; 120 | address.number = 211; 121 | address.numberAddition = 'B'; 122 | 123 | const band = new Band(); 124 | band.name = 'the Speckled Band'; 125 | band.address = address; 126 | 127 | expect(serializeEntity(band, [])).toHaveProperty('name'); 128 | expect(serializeEntity(band, []).address).not.toBeInstanceOf(Address); 129 | expect(serializeEntity(band, []).address['number']).toBe(211); 130 | }); 131 | 132 | it('should serialize object array properties with the @Serialize() decorator', () => { 133 | class Address { 134 | streetName: string; 135 | number: number; 136 | numberAddition: string; 137 | } 138 | 139 | class Band implements IEntity { 140 | id: string; 141 | name: string; 142 | @Serialize(Address) 143 | addresses: Address[]; 144 | } 145 | 146 | const address = new Address(); 147 | address.streetName = 'Baker St.'; 148 | address.number = 211; 149 | address.numberAddition = 'B'; 150 | 151 | const address2 = new Address(); 152 | address2.streetName = 'Baker St.'; 153 | address2.number = 211; 154 | address2.numberAddition = 'C'; 155 | 156 | const band = new Band(); 157 | band.name = 'the Speckled Band'; 158 | band.addresses = [address, address2]; 159 | 160 | expect(serializeEntity(band, [])).toHaveProperty('name'); 161 | expect(serializeEntity(band, []).addresses[0]).not.toBeInstanceOf(Address); 162 | expect(serializeEntity(band, []).addresses[0]['numberAddition']).toBe('B'); 163 | expect(serializeEntity(band, []).addresses[1]['numberAddition']).toBe('C'); 164 | }); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { ignoreKey, serializeKey } from './Decorators'; 2 | import { SubCollectionMetadata } from './MetadataStorage'; 3 | import { IEntity } from '.'; 4 | 5 | /** 6 | * Extract getters and object in form of data properties 7 | * @param {T} Entity object 8 | * @returns {Object} with only data properties 9 | */ 10 | export function extractAllGetters(obj: Record) { 11 | const prototype = Object.getPrototypeOf(obj); 12 | const fromInstanceObj = Object.keys(obj); 13 | const fromInstance = Object.getOwnPropertyNames(obj); 14 | const fromPrototype = Object.getOwnPropertyNames(Object.getPrototypeOf(obj)); 15 | 16 | const keys = [...fromInstanceObj, ...fromInstance, ...fromPrototype]; 17 | 18 | const getters = keys 19 | .map(key => Object.getOwnPropertyDescriptor(prototype, key)) 20 | .map((descriptor, index) => { 21 | if (descriptor && typeof descriptor.get === 'function') { 22 | return keys[index]; 23 | } else { 24 | return undefined; 25 | } 26 | }) 27 | .filter(d => d !== undefined); 28 | 29 | return getters.reduce>((accumulator, currentValue) => { 30 | if (typeof currentValue === 'string' && obj[currentValue]) { 31 | accumulator[currentValue] = obj[currentValue]; 32 | } 33 | return accumulator; 34 | }, {}); 35 | } 36 | 37 | /** 38 | * Returns a serializable object from entity 39 | * 40 | * @template T 41 | * @param {T} Entity object 42 | * @param {SubCollectionMetadata[]} subColMetadata Subcollection 43 | * metadata to remove runtime-created fields 44 | * @returns {Object} Serialiable object 45 | */ 46 | export function serializeEntity( 47 | obj: Partial, 48 | subColMetadata: SubCollectionMetadata[] 49 | ): Record { 50 | const objectGetters = extractAllGetters(obj as Record); 51 | 52 | const serializableObj = { ...obj, ...objectGetters }; 53 | 54 | subColMetadata.forEach(scm => { 55 | delete serializableObj[scm.propertyKey]; 56 | }); 57 | 58 | Object.entries(serializableObj).forEach(([propertyKey, propertyValue]) => { 59 | if (Reflect.getMetadata(ignoreKey, obj, propertyKey) === true) { 60 | delete serializableObj[propertyKey]; 61 | } 62 | if (Reflect.getMetadata(serializeKey, obj, propertyKey) !== undefined) { 63 | if (Array.isArray(propertyValue)) { 64 | (serializableObj as { [key: string]: unknown })[propertyKey] = propertyValue.map(element => 65 | serializeEntity(element, []) 66 | ); 67 | } else { 68 | (serializableObj as { [key: string]: unknown })[propertyKey] = serializeEntity( 69 | propertyValue as Partial, 70 | [] 71 | ); 72 | } 73 | } 74 | }); 75 | 76 | return serializableObj; 77 | } 78 | 79 | /** 80 | * Returns true if arrays are equal 81 | * 82 | * @export 83 | * @param {Array} arr1 84 | * @param {Array} arr2 85 | * @returns {boolean} 86 | */ 87 | export function arraysAreEqual(arr1: unknown[], arr2: unknown[]): boolean { 88 | if (arr1.length !== arr2.length) { 89 | return false; 90 | } 91 | 92 | return arr1.every((a, i) => a === arr2[i]); 93 | } 94 | -------------------------------------------------------------------------------- /test/BandCollection.ts: -------------------------------------------------------------------------------- 1 | import { Collection, SubCollection } from '../src/Decorators'; 2 | import { Serialize } from '../src/Decorators/Serialize'; 3 | import { 4 | Agent, 5 | Album as AlbumEntity, 6 | AlbumImage as AlbumImageEntity, 7 | Coordinates, 8 | FirestoreDocumentReference, 9 | } from './fixture'; 10 | import { ISubCollection } from '../src/types'; 11 | import { Type } from '../src'; 12 | import { IsEmail, IsOptional, Length } from 'class-validator'; 13 | 14 | // Why I do this? Because by using the instance of Album 15 | // located in fixture.ts, you have the risk to reuse the 16 | // same class in many tests and every method that depends 17 | // in the instance of the class being unique might clash 18 | // with each other (happened with getRepository) 19 | // 20 | // Hours lost debugging this: 2 21 | 22 | class AlbumImage extends AlbumImageEntity {} 23 | 24 | export class Album extends AlbumEntity { 25 | @Length(1, 50, { message: 'Name is too long' }) 26 | name: string; 27 | 28 | @SubCollection(AlbumImage, 'images') 29 | images?: ISubCollection; 30 | } 31 | 32 | @Collection('bands') 33 | export class Band { 34 | id: string; 35 | name: string; 36 | formationYear: number; 37 | lastShow: Date; 38 | 39 | @IsOptional() 40 | @IsEmail({}, { message: 'Invalid email!' }) 41 | contactEmail?: string; 42 | 43 | // Todo create fireorm bypass decorator 44 | @Type(() => Coordinates) 45 | lastShowCoordinates: Coordinates; 46 | genres: Array; 47 | 48 | @SubCollection(Album, 'albums') 49 | albums?: ISubCollection; 50 | 51 | @Type(() => FirestoreDocumentReference) 52 | relatedBand?: FirestoreDocumentReference; 53 | 54 | @Serialize(Agent) 55 | agents: Agent[]; 56 | 57 | getLastShowYear() { 58 | return this.lastShow.getFullYear(); 59 | } 60 | 61 | getPopularGenre() { 62 | return this.genres[0]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /test/fixture.ts: -------------------------------------------------------------------------------- 1 | import { IEntity } from '../src'; 2 | import { Serialize } from '../src/Decorators/Serialize'; 3 | 4 | export class Coordinates { 5 | latitude: number; 6 | longitude: number; 7 | } 8 | 9 | export class FirestoreDocumentReference { 10 | id: string; 11 | path: string; 12 | } 13 | 14 | export class AlbumImage { 15 | id: string; 16 | url: string; 17 | } 18 | 19 | export class Album { 20 | id: string; 21 | name: string; 22 | releaseDate: Date; 23 | comment?: string; 24 | } 25 | 26 | export class Website { 27 | url: string; 28 | } 29 | 30 | export class Agent { 31 | name: string; 32 | @Serialize(Website) 33 | website: Website; 34 | } 35 | 36 | export class Band { 37 | id: string; 38 | name: string; 39 | formationYear: number; 40 | lastShow: Date; 41 | lastShowCoordinates?: Coordinates; 42 | genres: Array; 43 | } 44 | 45 | export const getInitialData = () => { 46 | return [ 47 | { 48 | id: 'porcupine-tree', 49 | name: 'Porcupine Tree', 50 | formationYear: 1987, 51 | lastShow: new Date('2010-10-14'), 52 | lastShowCoordinates: { latitude: 51.5009088, longitude: -0.1795547 }, 53 | genres: ['psychedelic-rock', 'progressive-rock', 'progressive-metal'], 54 | albums: [ 55 | { 56 | id: 'lightbulb-sun', 57 | name: 'Lightbulb Sun', 58 | releaseDate: new Date('2000-05-22'), 59 | images: [ 60 | { 61 | id: 'album-artwork', 62 | url: 'http://lorempixel.com/100/100', 63 | }, 64 | ], 65 | }, 66 | { 67 | id: 'in-absentia', 68 | name: 'In Absentia', 69 | releaseDate: new Date('2002-09-24'), 70 | images: [ 71 | { 72 | id: 'album-artwork', 73 | url: 'http://lorempixel.com/100/100', 74 | }, 75 | ], 76 | }, 77 | { 78 | id: 'deadwing', 79 | name: 'Deadwing', 80 | releaseDate: new Date('2005-03-25'), 81 | images: [ 82 | { 83 | id: 'album-artwork', 84 | url: 'http://lorempixel.com/100/100', 85 | }, 86 | ], 87 | }, 88 | { 89 | id: 'fear-blank-planet', 90 | name: 'Fear of a Blank Planet', 91 | releaseDate: new Date('2007-04-16'), 92 | images: [ 93 | { 94 | id: 'album-artwork', 95 | url: 'http://lorempixel.com/100/100', 96 | }, 97 | { 98 | id: 'album-cover', 99 | url: 'http://lorempixel.com/100/100', 100 | }, 101 | ], 102 | }, 103 | ], 104 | }, 105 | { 106 | id: 'pink-floyd', 107 | name: 'Pink Floyd', 108 | formationYear: 1965, 109 | lastShow: new Date('1981-06-17'), 110 | genres: ['psychedelic-rock', 'progressive-rock', 'space-rock'], 111 | albums: [ 112 | { 113 | id: 'dark-side-moon', 114 | name: 'The Dark Side of the Moon', 115 | releaseDate: new Date('1973-03-01'), 116 | images: [ 117 | { 118 | id: 'album-artwork', 119 | url: 'http://lorempixel.com/100/100', 120 | }, 121 | ], 122 | }, 123 | { 124 | id: 'wish-you-were-here', 125 | name: 'Wish You Were Here', 126 | releaseDate: new Date('1975-09-12'), 127 | images: [ 128 | { 129 | id: 'album-artwork', 130 | url: 'http://lorempixel.com/100/100', 131 | }, 132 | ], 133 | }, 134 | { 135 | id: 'animals', 136 | name: 'Animals', 137 | releaseDate: new Date('1977-01-23'), 138 | images: [ 139 | { 140 | id: 'album-artwork', 141 | url: 'http://lorempixel.com/100/100', 142 | }, 143 | ], 144 | }, 145 | { 146 | id: 'the-wall', 147 | name: 'The Wall', 148 | releaseDate: new Date('1979-11-30'), 149 | images: [], 150 | }, 151 | ], 152 | }, 153 | { 154 | id: 'red-hot-chili-peppers', 155 | name: 'Red Hot Chili Peppers', 156 | formationYear: 1983, 157 | lastShow: null, 158 | genres: ['funk-rock', 'funk-metal', 'alternative-rock'], 159 | albums: [ 160 | { 161 | id: 'californication', 162 | name: 'Californication', 163 | releaseDate: new Date('1999-06-08'), 164 | images: [ 165 | { 166 | id: 'album-artwork', 167 | url: 'http://lorempixel.com/100/100', 168 | }, 169 | ], 170 | }, 171 | { 172 | id: 'by-the-way', 173 | name: 'By the Way', 174 | releaseDate: new Date('2002-07-09'), 175 | images: [ 176 | { 177 | id: 'album-artwork', 178 | url: 'http://lorempixel.com/100/100', 179 | }, 180 | ], 181 | }, 182 | { 183 | id: 'stadium-arcadium', 184 | name: 'Stadium Arcadium', 185 | releaseDate: new Date('2006-05-09'), 186 | images: [ 187 | { 188 | id: 'album-artwork', 189 | url: 'http://lorempixel.com/100/100', 190 | }, 191 | ], 192 | }, 193 | ], 194 | }, 195 | { 196 | id: 'the-speckled-band', 197 | name: 'the Speckled Band', 198 | formationYear: 1892, 199 | lastShow: null, 200 | genres: [], 201 | albums: [], 202 | agents: [ 203 | { 204 | name: 'Mycroft Holmes', 205 | website: { 206 | url: 'en.wikipedia.org/wiki/Mycroft_Holmes', 207 | }, 208 | }, 209 | { 210 | name: 'Arthur Conan Doyle', 211 | website: { 212 | url: 'en.wikipedia.org/wiki/Arthur_Conan_Doyle', 213 | }, 214 | }, 215 | ], 216 | }, 217 | ]; 218 | }; 219 | 220 | const getCollectionBoilerplate = (entity: string, hash: Record) => ({ 221 | __collection__: { 222 | [entity]: { 223 | __doc__: hash, 224 | }, 225 | }, 226 | }); 227 | 228 | export const getBandFixture = () => { 229 | const initialData = getInitialData(); 230 | 231 | const objectifyList = (arr: Array, cb?) => 232 | arr.reduce((acc, cur) => ({ ...acc, [cur.id]: cb ? cb(cur) : cur }), {}); 233 | 234 | return objectifyList(initialData, ({ albums, ...rest }) => ({ 235 | ...rest, 236 | ...getCollectionBoilerplate( 237 | 'albums', 238 | objectifyList(albums, ({ images, ...album }) => ({ 239 | ...album, 240 | ...getCollectionBoilerplate('images', objectifyList(images)), 241 | })) 242 | ), 243 | })); 244 | }; 245 | 246 | export const getFixture = () => { 247 | return getCollectionBoilerplate('bands', getBandFixture()); 248 | }; 249 | -------------------------------------------------------------------------------- /test/functional/1-simple_repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { getRepository, Collection } from '../../src'; 2 | import { Band as BandEntity } from '../fixture'; 3 | import { getUniqueColName } from '../setup'; 4 | 5 | describe('Integration test: Simple Repository', () => { 6 | @Collection(getUniqueColName('band-simple-repository')) 7 | class Band extends BandEntity { 8 | extra?: { website: string }; 9 | } 10 | 11 | test('should do crud operations', async () => { 12 | const bandRepository = getRepository(Band); 13 | // Create a band 14 | const dt = new Band(); 15 | dt.id = 'dream-theater'; 16 | dt.name = 'DreamTheater'; 17 | dt.formationYear = 1985; 18 | dt.genres = ['progressive-metal', 'progressive-rock']; 19 | dt.extra = { 20 | website: 'www.dreamtheater.net', 21 | }; 22 | 23 | const savedBand = await bandRepository.create(dt); 24 | expect(savedBand.name).toEqual(dt.name); 25 | expect(savedBand.id).toEqual(dt.id); 26 | expect(savedBand.formationYear).toEqual(dt.formationYear); 27 | expect(savedBand.genres).toEqual(dt.genres); 28 | 29 | // Create a band without an id 30 | const devinT = new Band(); 31 | devinT.name = 'Devin Townsend Project'; 32 | devinT.formationYear = 2009; 33 | devinT.genres = ['progressive-metal', 'extreme-metal']; 34 | 35 | const savedBandWithoutId = await bandRepository.create(devinT); 36 | expect(savedBandWithoutId.name).toEqual(devinT.name); 37 | expect(savedBandWithoutId.id).toEqual(devinT.id); 38 | 39 | // Read a band 40 | const foundBand = await bandRepository.findById(dt.id); 41 | expect(foundBand.id).toEqual(dt.id); 42 | expect(foundBand.name).toEqual(dt.name); 43 | 44 | // Update a band 45 | dt.name = 'Dream Theater'; 46 | const updatedDt = await bandRepository.update(dt); 47 | const updatedDtInDb = await bandRepository.findById(dt.id); 48 | expect(updatedDt.name).toEqual(dt.name); 49 | expect(updatedDtInDb.name).toEqual(dt.name); 50 | 51 | // Filter a band by subfield 52 | const byWebsite = await bandRepository 53 | .whereEqualTo(a => a.extra.website, 'www.dreamtheater.net') 54 | .find(); 55 | expect(byWebsite[0].id).toEqual('dream-theater'); 56 | 57 | // Find one band matching some criteria 58 | const byWebsiteOne = await bandRepository.whereEqualTo(a => a.name, 'Dream Theater').findOne(); 59 | expect(byWebsiteOne.id).toEqual('dream-theater'); 60 | 61 | // Find two bands matching some criteria 62 | const whereIn = await bandRepository 63 | .whereIn(a => a.name, ['Dream Theater', 'Devin Townsend Project']) 64 | .find(); 65 | expect(whereIn.length).toEqual(2); 66 | 67 | // Find two bands matching some criteria 68 | const whereArrayIn = await bandRepository 69 | .whereArrayContainsAny(a => a.genres, ['progressive-metal']) 70 | .find(); 71 | expect(whereArrayIn.length).toEqual(2); 72 | 73 | // Should be able to run transactions 74 | await bandRepository.runTransaction(async tran => { 75 | const band = await tran.findById('dream-theater'); 76 | band.name = 'Teatro del sueño'; 77 | await tran.update(band); 78 | }); 79 | 80 | const updated = await bandRepository.findById('dream-theater'); 81 | expect(updated.name).toEqual('Teatro del sueño'); 82 | 83 | // Delete a band 84 | await bandRepository.delete(dt.id); 85 | const deletedBand = await bandRepository.findById(dt.id); 86 | expect(deletedBand).toEqual(null); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/functional/2-custom_repositories.spec.ts: -------------------------------------------------------------------------------- 1 | import { getInitialData, Band as BandEntity } from '../fixture'; 2 | import { CustomRepository, BaseFirestoreRepository, getRepository, Collection } from '../../src'; 3 | import { getUniqueColName } from '../setup'; 4 | 5 | describe('Integration test: Custom Repository', () => { 6 | @Collection(getUniqueColName('band-custom-repository')) 7 | class Band extends BandEntity {} 8 | 9 | @CustomRepository(Band) 10 | class CustomRockBandRepository extends BaseFirestoreRepository { 11 | filterByGenre(genre: string) { 12 | return this.whereArrayContains('genres', genre); 13 | } 14 | } 15 | 16 | // getRepository will return the custom repository of Band 17 | // (if it has the @CustomRepository decorator). Since typescript 18 | // cannot guess dynamic types, we'll have to cast it to the 19 | // custom repository 20 | let rockBandRepository: CustomRockBandRepository = null; 21 | 22 | beforeEach(async () => { 23 | // see comment above 24 | rockBandRepository = getRepository(Band) as CustomRockBandRepository; 25 | 26 | const seed = getInitialData().map(b => rockBandRepository.create(b)); 27 | await Promise.all(seed); 28 | }); 29 | 30 | it('should use custom repository', async () => { 31 | const band = new Band(); 32 | band.id = 'opeth'; 33 | band.name = 'Opeth'; 34 | band.formationYear = 1989; 35 | band.genres = ['progressive-death-metal', 'progressive-metal', 'progressive-rock']; 36 | 37 | await rockBandRepository.create(band); 38 | 39 | // Filter bands with genre progressive-rock, check that since we didn't 40 | // called .find in the repository method, we have to do it here 41 | const progressiveRockBands = await rockBandRepository.filterByGenre('progressive-rock').find(); 42 | 43 | const [first, second, third] = progressiveRockBands 44 | .map(b => b.name) 45 | .sort((a, b) => a.localeCompare(b)); 46 | 47 | expect(progressiveRockBands.length).toEqual(3); 48 | expect(first).toEqual('Opeth'); 49 | expect(second).toEqual('Pink Floyd'); 50 | expect(third).toEqual('Porcupine Tree'); 51 | 52 | // Filter progressive-rock bands formed in 1989 53 | const millenialProgressiveRockBands = await rockBandRepository 54 | .filterByGenre('progressive-rock') 55 | .whereEqualTo('formationYear', 1989) 56 | .find(); 57 | 58 | expect(millenialProgressiveRockBands.length).toEqual(1); 59 | expect(progressiveRockBands[0].name).toEqual('Opeth'); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/functional/3-subcollections.spec.ts: -------------------------------------------------------------------------------- 1 | import { Band as BandEntity, Album as AlbumEntity, getInitialData } from '../fixture'; 2 | import { getRepository, Collection, SubCollection, BaseFirestoreRepository } from '../../src'; 3 | import { getUniqueColName } from '../setup'; 4 | 5 | describe('Integration test: SubCollections', () => { 6 | class Album extends AlbumEntity {} 7 | 8 | @Collection(getUniqueColName('band-with-subcollections')) 9 | class FullBand extends BandEntity { 10 | @SubCollection(Album) 11 | albums: BaseFirestoreRepository; 12 | } 13 | 14 | let fullBandRepository: BaseFirestoreRepository = null; 15 | 16 | beforeEach(async () => { 17 | fullBandRepository = getRepository(FullBand); 18 | const seed = getInitialData().map(({ albums, ...band }) => ({ 19 | band, 20 | albums, 21 | })); 22 | 23 | for (const s of seed) { 24 | const band = new FullBand(); 25 | band.id = s.band.id; 26 | band.name = s.band.name; 27 | band.genres = s.band.genres; 28 | band.formationYear = s.band.formationYear; 29 | band.lastShow = s.band.lastShow; 30 | band.lastShowCoordinates = null; 31 | 32 | await fullBandRepository.create(band); 33 | 34 | const albums = s.albums.map(a => { 35 | const album = new Album(); 36 | album.id = a.id; 37 | album.releaseDate = a.releaseDate; 38 | album.name = a.name; 39 | 40 | return album; 41 | }); 42 | 43 | await Promise.all(albums.map(a => band.albums.create(a))); 44 | } 45 | }); 46 | 47 | it('should do crud with subcollections', async () => { 48 | const rush = new FullBand(); 49 | rush.id = 'rush'; 50 | rush.name = 'Rush'; 51 | rush.formationYear = 1968; 52 | rush.genres = ['progressive-rock', 'hard-rock', 'heavy-metal']; 53 | 54 | const repo = await fullBandRepository.create(rush); 55 | 56 | // Inserting some albums (subcollections) 57 | const secondAlbum = new Album(); 58 | secondAlbum.id = 'fly-by-night'; 59 | secondAlbum.name = 'Fly by Night'; 60 | secondAlbum.releaseDate = new Date('1975-02-15'); 61 | 62 | const fourthAlbum = new Album(); 63 | fourthAlbum.id = '2112'; 64 | fourthAlbum.name = '2112'; 65 | fourthAlbum.releaseDate = new Date('1976-04-01'); 66 | 67 | const eighthAlbum = new Album(); 68 | eighthAlbum.id = 'moving-pictures'; 69 | eighthAlbum.name = 'Moving Pictures'; 70 | eighthAlbum.releaseDate = new Date('1982-02-12'); 71 | 72 | const batch = repo.albums.createBatch(); 73 | 74 | await rush.albums.create(secondAlbum); 75 | batch.create(fourthAlbum); 76 | batch.create(eighthAlbum); 77 | 78 | await batch.commit(); 79 | 80 | // Retrieving albums before 1980 81 | const albumsBefore1980 = await rush.albums 82 | .whereLessThan('releaseDate', new Date('1980-01-01')) 83 | .find(); 84 | 85 | expect(albumsBefore1980.length).toEqual(2); 86 | expect(albumsBefore1980[0].id).toEqual('fly-by-night'); 87 | expect(albumsBefore1980[1].id).toEqual('2112'); 88 | 89 | // Updating album 90 | const movingPictures = await rush.albums.findById('moving-pictures'); 91 | movingPictures.releaseDate = new Date('1981-02-12'); 92 | await rush.albums.update(movingPictures); 93 | const updated = await rush.albums.findById('moving-pictures'); 94 | expect(updated.releaseDate).toEqual(movingPictures.releaseDate); 95 | 96 | // Deleting an album 97 | await rush.albums.delete('moving-pictures'); 98 | const updatedAlbums = await rush.albums.find(); 99 | expect(updatedAlbums.length).toEqual(2); 100 | 101 | // Updating parent collection 102 | rush.genres = rush.genres.slice(0, 2); 103 | await fullBandRepository.update(rush); 104 | const updatedRush = await fullBandRepository.findById('rush'); 105 | expect(updatedRush.genres).toEqual(rush.genres); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /test/functional/4-transactions.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getRepository, 3 | Collection, 4 | runTransaction, 5 | BaseFirestoreRepository, 6 | SubCollection, 7 | ISubCollection, 8 | } from '../../src'; 9 | import { Band as BandEntity, Album as AlbumEntity } from '../fixture'; 10 | import { getUniqueColName } from '../setup'; 11 | 12 | describe('Integration test: Transactions', () => { 13 | class Album extends AlbumEntity {} 14 | 15 | @Collection(getUniqueColName('band-with-transactions')) 16 | class Band extends BandEntity { 17 | extra?: { website: string }; 18 | 19 | @SubCollection(Album) 20 | albums?: ISubCollection; 21 | } 22 | 23 | let bandRepository: BaseFirestoreRepository = null; 24 | 25 | beforeEach(() => { 26 | bandRepository = getRepository(Band); 27 | }); 28 | 29 | it('should do CRUD operations inside transactions in repositories', async () => { 30 | // Create a band 31 | const dt = new Band(); 32 | dt.id = 'dream-theater'; 33 | dt.name = 'DreamTheater'; 34 | dt.formationYear = 1985; 35 | dt.genres = ['progressive-metal', 'progressive-rock']; 36 | dt.extra = { 37 | website: 'www.dreamtheater.net', 38 | }; 39 | 40 | // Create another band 41 | const ti = new Band(); 42 | ti.id = 'tame-impala'; 43 | ti.name = 'Tame Impala'; 44 | ti.formationYear = 2007; 45 | ti.genres = ['psychedelic-pop', 'psychedelic-rock', 'neo-psychedelia']; 46 | ti.extra = { 47 | website: 'www.tameimpala.com', 48 | }; 49 | 50 | // Transactions can return data 51 | const savedBand = await bandRepository.runTransaction(async tran => { 52 | await tran.create(ti); 53 | return tran.create(dt); 54 | }); 55 | 56 | expect(savedBand.name).toEqual(dt.name); 57 | expect(savedBand.id).toEqual(dt.id); 58 | expect(savedBand.formationYear).toEqual(dt.formationYear); 59 | expect(savedBand.genres).toEqual(dt.genres); 60 | 61 | // Create a band without an id inside transactions 62 | const devinT = new Band(); 63 | devinT.name = 'Devin Townsend Project'; 64 | devinT.formationYear = 2009; 65 | devinT.genres = ['progressive-metal', 'extreme-metal']; 66 | 67 | await bandRepository.runTransaction(async tran => { 68 | const savedBandWithoutId = await tran.create(devinT); 69 | expect(savedBandWithoutId.name).toEqual(devinT.name); 70 | expect(savedBandWithoutId.id).toEqual(devinT.id); 71 | }); 72 | 73 | // Read a band inside transaction 74 | await bandRepository.runTransaction(async tran => { 75 | const foundBand = await tran.findById(dt.id); 76 | expect(foundBand.id).toEqual(dt.id); 77 | expect(foundBand.name).toEqual(dt.name); 78 | }); 79 | 80 | // Update a band inside transaction 81 | await bandRepository.runTransaction(async tran => { 82 | const dream = await tran.findById(dt.id); 83 | 84 | dream.name = 'Dream Theater'; 85 | const updatedDt = await tran.update(dream); 86 | expect(updatedDt.name).toEqual(dream.name); 87 | }); 88 | 89 | // Verify what was done inside the last transaction 90 | const bandOutsideTransaction = await bandRepository.findById(dt.id); 91 | expect(bandOutsideTransaction.name).toEqual('Dream Theater'); 92 | 93 | // Filter a band by subfield inside transaction 94 | await bandRepository.runTransaction(async tran => { 95 | const byWebsite = await tran 96 | .whereEqualTo(a => a.extra.website, 'www.dreamtheater.net') 97 | .find(); 98 | expect(byWebsite[0].id).toEqual('dream-theater'); 99 | }); 100 | 101 | // Delete a band 102 | await bandRepository.runTransaction(async tran => { 103 | await tran.delete(dt.id); 104 | }); 105 | 106 | const deletedBand = await bandRepository.findById(dt.id); 107 | expect(deletedBand).toEqual(null); 108 | }); 109 | 110 | it('should do CRUD operations inside transactions', async () => { 111 | // Create a band 112 | const dt = new Band(); 113 | dt.id = 'dream-theater'; 114 | dt.name = 'DreamTheater'; 115 | dt.formationYear = 1985; 116 | dt.genres = ['progressive-metal', 'progressive-rock']; 117 | dt.extra = { 118 | website: 'www.dreamtheater.net', 119 | }; 120 | 121 | // Create another band 122 | const ti = new Band(); 123 | ti.id = 'tame-impala'; 124 | ti.name = 'Tame Impala'; 125 | ti.formationYear = 2007; 126 | ti.genres = ['psychedelic-pop', 'psychedelic-rock', 'neo-psychedelia']; 127 | ti.extra = { 128 | website: 'www.tameimpala.com', 129 | }; 130 | 131 | const savedBand = await runTransaction(async tran => { 132 | const bandTranRepository = tran.getRepository(Band); 133 | await bandTranRepository.create(ti); 134 | return bandTranRepository.create(dt); 135 | }); 136 | 137 | expect(savedBand.name).toEqual(dt.name); 138 | expect(savedBand.id).toEqual(dt.id); 139 | expect(savedBand.formationYear).toEqual(dt.formationYear); 140 | expect(savedBand.genres).toEqual(dt.genres); 141 | 142 | // Create a band without an id inside transactions 143 | const devinT = new Band(); 144 | devinT.name = 'Devin Townsend Project'; 145 | devinT.formationYear = 2009; 146 | devinT.genres = ['progressive-metal', 'extreme-metal']; 147 | 148 | await runTransaction(async tran => { 149 | const bandTranRepository = tran.getRepository(Band); 150 | 151 | const savedBandWithoutId = await bandTranRepository.create(devinT); 152 | expect(savedBandWithoutId.name).toEqual(devinT.name); 153 | expect(savedBandWithoutId.id).toEqual(devinT.id); 154 | }); 155 | 156 | // Read a band inside transaction 157 | await runTransaction(async tran => { 158 | const bandTranRepository = tran.getRepository(Band); 159 | 160 | const foundBand = await bandTranRepository.findById(dt.id); 161 | expect(foundBand.id).toEqual(dt.id); 162 | expect(foundBand.name).toEqual(dt.name); 163 | }); 164 | 165 | // Update a band inside transaction 166 | const updatedBand = await runTransaction(async tran => { 167 | const bandTranRepository = tran.getRepository(Band); 168 | 169 | const dream = await bandTranRepository.findById(dt.id); 170 | 171 | dream.name = 'Dream Theater'; 172 | const updatedDt = await bandTranRepository.update(dream); 173 | expect(updatedDt.name).toEqual(dream.name); 174 | return updatedDt; 175 | }); 176 | 177 | // Verify what was done inside the last transaction 178 | const bandOutsideTransaction = await bandRepository.findById(dt.id); 179 | expect(bandOutsideTransaction.name).toEqual('Dream Theater'); 180 | expect(updatedBand.name).toEqual('Dream Theater'); 181 | 182 | // Filter a band by subfield inside transaction 183 | await runTransaction(async tran => { 184 | const bandTranRepository = tran.getRepository(Band); 185 | 186 | const byWebsite = await bandTranRepository 187 | .whereEqualTo(a => a.extra.website, 'www.dreamtheater.net') 188 | .find(); 189 | expect(byWebsite[0].id).toEqual('dream-theater'); 190 | }); 191 | 192 | // Delete a band 193 | await runTransaction(async tran => { 194 | const bandTranRepository = tran.getRepository(Band); 195 | 196 | await bandTranRepository.delete(dt.id); 197 | }); 198 | 199 | const deletedBand = await bandRepository.findById(dt.id); 200 | expect(deletedBand).toEqual(null); 201 | }); 202 | 203 | it('should do CRUD operations inside subcollections', async () => { 204 | // Create another band 205 | const band = new Band(); 206 | band.id = 'tame-impala'; 207 | band.name = 'Tame Impala'; 208 | band.formationYear = 2007; 209 | band.genres = ['psychedelic-pop', 'psychedelic-rock', 'neo-psychedelia']; 210 | band.extra = { 211 | website: 'www.tameimpala.com', 212 | }; 213 | 214 | const albums = [ 215 | { 216 | id: 'currents', 217 | name: 'Currents', 218 | releaseDate: new Date('2015-07-17T00:00:00.000Z'), 219 | }, 220 | { 221 | id: 'slow-rush', 222 | name: 'The Slow Rush', 223 | releaseDate: new Date('2020-02-14T00:00:00.000Z'), 224 | }, 225 | ]; 226 | 227 | await runTransaction(async tran => { 228 | const bandTranRepository = tran.getRepository(Band); 229 | const created = await bandTranRepository.create(band); 230 | 231 | for (const a of albums) { 232 | await created.albums.create(a); 233 | } 234 | 235 | return created; 236 | }); 237 | 238 | const savedBand = await bandRepository.findById(band.id); 239 | 240 | expect(savedBand.name).toEqual(band.name); 241 | expect(savedBand.id).toEqual(band.id); 242 | 243 | const createdAlbums = await band.albums.find(); 244 | const orderedAlbums = createdAlbums.sort((a, b) => a.name.localeCompare(b.name)); 245 | 246 | expect(orderedAlbums.length).toEqual(2); 247 | expect(orderedAlbums[0].name).toEqual(albums[0].name); 248 | expect(orderedAlbums[1].name).toEqual(albums[1].name); 249 | 250 | // Update albums inside transaction 251 | 252 | await band.albums.runTransaction(async tran => { 253 | for (const album of createdAlbums) { 254 | album.comment = 'Edited'; 255 | await tran.update(album); 256 | } 257 | }); 258 | 259 | // Verify what was done inside the last transaction 260 | const editedAlbums = await band.albums.whereEqualTo(a => a.comment, 'Edited').find(); 261 | expect(editedAlbums.length).toEqual(2); 262 | 263 | await band.albums.runTransaction(async tran => { 264 | for (const album of createdAlbums) { 265 | await tran.delete(album.id); 266 | } 267 | }); 268 | 269 | const deletedAlbums = await band.albums.find(); 270 | expect(deletedAlbums.length).toEqual(0); 271 | }); 272 | }); 273 | -------------------------------------------------------------------------------- /test/functional/5-batches.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getRepository, 3 | Collection, 4 | createBatch, 5 | BaseFirestoreRepository, 6 | ISubCollection, 7 | SubCollection, 8 | } from '../../src'; 9 | import { Album as AlbumEntity, Band as BandEntity } from '../fixture'; 10 | import { getUniqueColName } from '../setup'; 11 | 12 | describe('Integration test: Batches', () => { 13 | class Album extends AlbumEntity {} 14 | @Collection(getUniqueColName('band-in-batch')) 15 | class Band extends BandEntity { 16 | extra?: { website: string }; 17 | 18 | @SubCollection(Album) 19 | albums?: ISubCollection; 20 | } 21 | 22 | let bandRepository: BaseFirestoreRepository = null; 23 | 24 | beforeEach(() => { 25 | bandRepository = getRepository(Band); 26 | }); 27 | 28 | it('should do CRUD operations inside batches in repositories', async () => { 29 | // Array of bands to batch-insert 30 | const bands = [ 31 | { 32 | name: 'Opeth', 33 | formationYear: 1989, 34 | genres: [ 35 | 'progressive-death-metal', 36 | 'progressive-metal', 37 | 'progressive-rock', 38 | 'custom-genre', 39 | ], 40 | lastShow: new Date(), 41 | extra: { 42 | website: '', 43 | }, 44 | }, 45 | { 46 | name: '30 Seconds To Mars', 47 | formationYear: 1998, 48 | genres: ['alternative-rock', 'custom-genre'], 49 | lastShow: new Date(), 50 | extra: { 51 | website: '', 52 | }, 53 | }, 54 | ]; 55 | 56 | const batch = bandRepository.createBatch(); 57 | bands.forEach(b => batch.create(b)); 58 | 59 | await batch.commit(); 60 | 61 | // Assert that bands were actually created 62 | const createdBands = await bandRepository 63 | .whereArrayContains(b => b.genres, 'custom-genre') 64 | .find(); 65 | 66 | const orderedBands = createdBands.sort((a, b) => b.name.localeCompare(a.name)); 67 | 68 | expect(orderedBands.length).toEqual(2); 69 | expect(orderedBands[0].name).toEqual(bands[0].name); 70 | expect(orderedBands[1].name).toEqual(bands[1].name); 71 | 72 | // Update website for all bands with an update batch 73 | const updateBatch = bandRepository.createBatch(); 74 | 75 | createdBands.forEach(b => { 76 | b.extra = { website: 'https://fake.web' }; 77 | updateBatch.update(b); 78 | }); 79 | 80 | await updateBatch.commit(); 81 | 82 | // Assert that bands were actually updated 83 | const updatedBands = await bandRepository 84 | .whereArrayContains(b => b.genres, 'custom-genre') 85 | .find(); 86 | 87 | expect(updatedBands.length).toEqual(2); 88 | expect(updatedBands[0].extra.website).toEqual('https://fake.web'); 89 | expect(updatedBands[1].extra.website).toEqual('https://fake.web'); 90 | 91 | // Delete bands with an delete batch 92 | const deleteBatch = bandRepository.createBatch(); 93 | 94 | createdBands.forEach(b => deleteBatch.delete(b)); 95 | 96 | await deleteBatch.commit(); 97 | 98 | // Assert that bands were actually deleted 99 | const deletedBands = await bandRepository 100 | .whereArrayContains(b => b.genres, 'custom-genre') 101 | .find(); 102 | 103 | expect(deletedBands.length).toEqual(0); 104 | }); 105 | 106 | it('should do CRUD operations inside batches', async () => { 107 | // Array of bands to batch-insert 108 | const bands: Band[] = [ 109 | { 110 | name: 'Opeth', 111 | formationYear: 1989, 112 | genres: [ 113 | 'progressive-death-metal', 114 | 'progressive-metal', 115 | 'progressive-rock', 116 | 'custom-genre', 117 | ], 118 | lastShow: new Date(), 119 | id: 'opeth', 120 | }, 121 | { 122 | name: '30 Seconds To Mars', 123 | formationYear: 1998, 124 | genres: ['alternative-rock', 'custom-genre'], 125 | lastShow: new Date(), 126 | id: '30-seconds-to-mars', 127 | }, 128 | ]; 129 | 130 | const batch = createBatch(); 131 | const bandBatchRepository = batch.getRepository(Band); 132 | bands.forEach(b => bandBatchRepository.create(b)); 133 | 134 | await batch.commit(); 135 | 136 | // Assert that bands were actually created 137 | const createdBands = await bandRepository 138 | .whereArrayContains(b => b.genres, 'custom-genre') 139 | .find(); 140 | 141 | const orderedBands = createdBands.sort((a, b) => b.name.localeCompare(a.name)); 142 | 143 | expect(orderedBands.length).toEqual(2); 144 | expect(orderedBands[0].name).toEqual(bands[0].name); 145 | expect(orderedBands[1].name).toEqual(bands[1].name); 146 | 147 | // Update website for all bands with an update batch 148 | const updateBatch = createBatch(); 149 | const bandUpdateBatch = updateBatch.getRepository(Band); 150 | 151 | createdBands.forEach(b => { 152 | b.extra = { website: 'https://fake.web' }; 153 | bandUpdateBatch.update(b); 154 | }); 155 | 156 | await updateBatch.commit(); 157 | 158 | // Assert that bands were actually updated 159 | const updatedBands = await bandRepository 160 | .whereArrayContains(b => b.genres, 'custom-genre') 161 | .find(); 162 | 163 | expect(updatedBands.length).toEqual(2); 164 | expect(updatedBands[0].extra.website).toEqual('https://fake.web'); 165 | expect(updatedBands[1].extra.website).toEqual('https://fake.web'); 166 | 167 | // Delete bands with an delete batch 168 | const deleteBatch = createBatch(); 169 | const bandDeleteBatch = deleteBatch.getRepository(Band); 170 | 171 | createdBands.forEach(b => bandDeleteBatch.delete(b)); 172 | 173 | await deleteBatch.commit(); 174 | 175 | // Assert that bands were actually deleted 176 | const deletedBands = await bandRepository 177 | .whereArrayContains(b => b.genres, 'custom-genre') 178 | .find(); 179 | 180 | expect(deletedBands.length).toEqual(0); 181 | }); 182 | 183 | it('should do CRUD operations in subcollections', async () => { 184 | const bandRepository = getRepository(Band); 185 | 186 | const band = await bandRepository.create({ 187 | name: 'Opeth', 188 | formationYear: 1989, 189 | genres: ['progressive-death-metal', 'progressive-metal', 'progressive-rock', 'custom-genre'], 190 | lastShow: new Date(), 191 | extra: { 192 | website: '', 193 | }, 194 | }); 195 | 196 | const albums = [ 197 | { 198 | id: 'blackwater-park', 199 | name: 'Blackwater Park', 200 | releaseDate: new Date('2001-12-03T00:00:00.000Z'), 201 | }, 202 | { 203 | id: 'deliverance', 204 | name: 'Deliverance', 205 | releaseDate: new Date('2002-11-12T00:00:00.000Z'), 206 | }, 207 | ]; 208 | 209 | const albumsBatch = band.albums.createBatch(); 210 | albums.forEach(a => albumsBatch.create(a)); 211 | 212 | await albumsBatch.commit(); 213 | 214 | // Assert that the subcollection was actually created 215 | const createdAlbums = await band.albums.find(); 216 | 217 | const orderedAlbums = createdAlbums.sort((a, b) => a.name.localeCompare(b.name)); 218 | 219 | expect(orderedAlbums.length).toEqual(2); 220 | expect(orderedAlbums[0].name).toEqual(albums[0].name); 221 | expect(orderedAlbums[1].name).toEqual(albums[1].name); 222 | 223 | // Update comment for all albums in an update batch 224 | const updateBatch = band.albums.createBatch(); 225 | 226 | createdAlbums.forEach(a => { 227 | a.comment = 'edited album'; 228 | updateBatch.update(a); 229 | }); 230 | 231 | await updateBatch.commit(); 232 | 233 | // Assert that subcollection was actually updated 234 | const updatedAlbums = await band.albums.whereEqualTo(a => a.comment, 'edited album').find(); 235 | 236 | expect(updatedAlbums.length).toEqual(2); 237 | 238 | // Delete subcollections with an delete batch 239 | const deleteBatch = band.albums.createBatch(); 240 | createdAlbums.forEach(a => deleteBatch.delete(a)); 241 | 242 | await deleteBatch.commit(); 243 | 244 | // Assert that the subcollection items were actually deleted 245 | const deletedBands = await band.albums.find(); 246 | expect(deletedBands.length).toEqual(0); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /test/functional/6-document-references.spec.ts: -------------------------------------------------------------------------------- 1 | import { getRepository, Collection, Type, BaseFirestoreRepository } from '../../src'; 2 | import { Band as BandEntity, FirestoreDocumentReference } from '../fixture'; 3 | import { getUniqueColName } from '../setup'; 4 | import { Firestore } from '@google-cloud/firestore'; 5 | 6 | describe('Integration test: Using Document References', () => { 7 | const colName = getUniqueColName('document-references'); 8 | @Collection(colName) 9 | class Band extends BandEntity { 10 | @Type(() => FirestoreDocumentReference) 11 | relatedBand?: FirestoreDocumentReference; 12 | } 13 | 14 | let firestore: Firestore = null; 15 | let bandRepository: BaseFirestoreRepository = null; 16 | 17 | beforeEach(() => { 18 | bandRepository = getRepository(Band); 19 | 20 | /* 21 | * Yes, this is a hack. 22 | * Since the firestore initialization is being done in 23 | * setup.ts, I'm just storing the firestore instance 24 | * so I can use the sdk to store references. 25 | * After fireorm/issues/58 this will be removed 26 | */ 27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 28 | firestore = (global as any).firestoreRef as Firestore; 29 | }); 30 | 31 | it('should work with document references', async () => { 32 | const pt = new Band(); 33 | pt.id = 'porcupine-tree'; 34 | pt.name = 'Porcupine Tree'; 35 | pt.formationYear = 1987; 36 | pt.genres = ['psychedelic-rock', 'progressive-rock', 'progressive-metal']; 37 | 38 | await bandRepository.create(pt); 39 | const ptRef = firestore.collection(colName).doc(pt.id); 40 | 41 | const sw = new Band(); 42 | sw.id = 'steven-wilson'; 43 | sw.name = 'Steven Wilson'; 44 | sw.formationYear = 1987; 45 | sw.genres = ['progressive-rock', 'progressive-metal', 'psychedelic-rock']; 46 | 47 | await bandRepository.create(sw); 48 | 49 | // Manually storing arbitrary reference 50 | await firestore.collection(colName).doc('steven-wilson').update({ relatedBand: ptRef }); 51 | 52 | // Is able to retrieve documents with references 53 | const swFromDb = await bandRepository.whereEqualTo(b => b.name, 'Steven Wilson').findOne(); 54 | 55 | expect(swFromDb.formationYear).toEqual(1987); 56 | 57 | // Is able to filter documents by references 58 | const band = await bandRepository.whereEqualTo(b => b.relatedBand, ptRef).find(); 59 | 60 | expect(band.length).toEqual(1); 61 | expect(band[0].name).toEqual('Steven Wilson'); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/functional/7-validations.spec.ts: -------------------------------------------------------------------------------- 1 | import { getRepository, Collection, initialize } from '../../src'; 2 | import { Band as BandEntity } from '../fixture'; 3 | import { getUniqueColName } from '../setup'; 4 | import { IsEmail } from 'class-validator'; 5 | import { Firestore } from '@google-cloud/firestore'; 6 | 7 | describe('Integration test: Validations', () => { 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | const firestore = (global as any).firestoreRef as Firestore; 10 | initialize(firestore, { validateModels: true }); 11 | 12 | @Collection(getUniqueColName('validations')) 13 | class Band extends BandEntity { 14 | @IsEmail() 15 | contactEmail: string; 16 | } 17 | 18 | const bandRepository = getRepository(Band); 19 | 20 | it('should do crud operations with validations', async () => { 21 | // Should create a band when passing a valid email 22 | const dt = new Band(); 23 | dt.id = 'dream-theater'; 24 | dt.name = 'DreamTheater'; 25 | dt.contactEmail = 'dream@theater.com'; 26 | await bandRepository.create(dt); 27 | 28 | const foundBand = await bandRepository.findById(dt.id); 29 | expect(foundBand.id).toEqual(dt.id); 30 | expect(foundBand.contactEmail).toEqual(dt.contactEmail); 31 | 32 | // Should update a band when passing a valid email 33 | dt.contactEmail = 'mail@example.com'; 34 | await bandRepository.update(dt); 35 | 36 | const updatedDtInDb = await bandRepository.findById(dt.id); 37 | expect(updatedDtInDb.contactEmail).toEqual('mail@example.com'); 38 | 39 | // Should throw when trying to create a band with an invalid email 40 | const sw = new Band(); 41 | sw.id = 'steven-wilson'; 42 | sw.name = 'Steven Wilson'; 43 | sw.contactEmail = 'stevenwilson.com'; 44 | 45 | expect(bandRepository.create(sw)).rejects.toBeTruthy(); 46 | 47 | // Should throw when trying to update a band with an invalid email 48 | dt.contactEmail = 'dreamtheater.com'; 49 | 50 | try { 51 | await bandRepository.update(dt); 52 | expect(false).toBeTruthy(); 53 | } catch (err) { 54 | expect(err).toBeTruthy(); 55 | } 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/functional/8-ignore-properties.spec.ts: -------------------------------------------------------------------------------- 1 | import { Collection, getRepository, Ignore } from '../../src'; 2 | import { Band as BandEntity } from '../fixture'; 3 | import { getUniqueColName } from '../setup'; 4 | 5 | describe('Integration test: Ignore Properties', () => { 6 | @Collection(getUniqueColName('band-simple-repository')) 7 | class Band extends BandEntity { 8 | @Ignore() 9 | temporaryName: string; 10 | } 11 | 12 | test('should ignore properties decorated with Ignore()', async () => { 13 | const bandRepository = getRepository(Band); 14 | // Create a band 15 | const dt = new Band(); 16 | dt.id = 'dream-theater'; 17 | dt.name = 'DreamTheater'; 18 | dt.temporaryName = 'Drömteater'; 19 | 20 | await bandRepository.create(dt); 21 | 22 | // Read a band 23 | const foundBand = await bandRepository.findById(dt.id); 24 | expect(foundBand.id).toEqual(dt.id); 25 | expect(foundBand.name).toEqual(dt.name); 26 | expect(foundBand).not.toHaveProperty('temporaryName'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/functional/8-serialized-properties.spec.ts: -------------------------------------------------------------------------------- 1 | import { Collection, getRepository } from '../../src'; 2 | import { Serialize } from '../../src/Decorators/Serialize'; 3 | import { Band as BandEntity } from '../fixture'; 4 | import { getUniqueColName } from '../setup'; 5 | 6 | describe('Integration test: Serialized properties', () => { 7 | class Website { 8 | url: string; 9 | } 10 | 11 | class Manager { 12 | name: string; 13 | @Serialize(Website) 14 | website: Website; 15 | } 16 | 17 | @Collection(getUniqueColName('band-serialized-repository')) 18 | class Band extends BandEntity { 19 | @Serialize(Website) 20 | website: Website; 21 | } 22 | 23 | @Collection(getUniqueColName('band-serialized-repository')) 24 | class DeepBand extends BandEntity { 25 | @Serialize(Manager) 26 | manager: Manager; 27 | } 28 | 29 | @Collection(getUniqueColName('band-serialized-repository')) 30 | class FancyBand extends BandEntity { 31 | @Serialize(Website) 32 | websites: Website[]; 33 | } 34 | 35 | test('should instantiate serialized objects with the correct class upon retrieval', async () => { 36 | const bandRepository = getRepository(Band); 37 | const dt = new Band(); 38 | dt.name = 'DreamTheater'; 39 | dt.formationYear = 1985; 40 | dt.genres = ['progressive-metal', 'progressive-rock']; 41 | dt.website = new Website(); 42 | dt.website.url = 'www.dreamtheater.net'; 43 | 44 | await bandRepository.create(dt); 45 | 46 | const retrievedBand = await bandRepository.findById(dt.id); 47 | 48 | expect(retrievedBand.website).toBeInstanceOf(Website); 49 | expect(retrievedBand.website.url).toEqual('www.dreamtheater.net'); 50 | }); 51 | 52 | test('should instantiate serialized objects with the correct class upon retrieval recursively', async () => { 53 | const bandRepository = getRepository(DeepBand); 54 | const sb = new DeepBand(); 55 | sb.name = 'the Speckled Band'; 56 | sb.formationYear = 1931; 57 | sb.genres = ['justice', 'karma']; 58 | sb.manager = new Manager(); 59 | sb.manager.name = 'Mycroft Holmes'; 60 | sb.manager.website = new Website(); 61 | sb.manager.website.url = 'en.wikipedia.org/wiki/Mycroft_Holmes'; 62 | 63 | await bandRepository.create(sb); 64 | 65 | const retrievedBand = await bandRepository.findById(sb.id); 66 | 67 | expect(retrievedBand.manager).toBeInstanceOf(Manager); 68 | expect(retrievedBand.manager.name).toEqual('Mycroft Holmes'); 69 | expect(retrievedBand.manager.website).toBeInstanceOf(Website); 70 | expect(retrievedBand.manager.website.url).toEqual('en.wikipedia.org/wiki/Mycroft_Holmes'); 71 | }); 72 | 73 | test('should instantiate serialized objects arrays with the correct class upon retrieval', async () => { 74 | const bandRepository = getRepository(FancyBand); 75 | const dt = new FancyBand(); 76 | dt.name = 'DreamTheater'; 77 | dt.formationYear = 1985; 78 | dt.genres = ['progressive-metal', 'progressive-rock']; 79 | dt.websites = [new Website(), new Website()]; 80 | dt.websites[0].url = 'http://www.dreamtheater.net'; 81 | dt.websites[1].url = 'https://www.dreamtheater.net'; 82 | 83 | await bandRepository.create(dt); 84 | 85 | const retrievedBand = await bandRepository.findById(dt.id); 86 | 87 | expect(retrievedBand.websites[0]).toBeInstanceOf(Website); 88 | expect(retrievedBand.websites[1]).toBeInstanceOf(Website); 89 | expect(retrievedBand.websites[0].url).toEqual('http://www.dreamtheater.net'); 90 | expect(retrievedBand.websites[1].url).toEqual('https://www.dreamtheater.net'); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /test/jest.integration.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/functional'], 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | setupFiles: ['dotenv/config'], 6 | setupFilesAfterEnv: ['./setup.ts'], 7 | verbose: true, 8 | }; 9 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import * as admin from 'firebase-admin'; 2 | import { initialize } from '../src'; 3 | 4 | console.log('Running Integration Test Setup'); 5 | 6 | const serviceAccount = { 7 | projectId: process.env.FIRESTORE_PROJECT_ID, 8 | databaseUrl: process.env.FIREBASE_DATABASE_URL, 9 | privateKey: Buffer.from(process.env.FIRESTORE_PRIVATE_KEY_BASE_64, 'base64').toString('ascii'), 10 | clientEmail: process.env.FIRESTORE_CLIENT_EMAIL, 11 | }; 12 | 13 | admin.initializeApp({ 14 | credential: admin.credential.cert(serviceAccount), 15 | databaseURL: serviceAccount.databaseUrl, 16 | }); 17 | 18 | const firestore = admin.firestore(); 19 | // To understand this, see 5-document-references.spec.ts 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | (global as any).firestoreRef = firestore; 22 | 23 | const uniqueCollections = []; 24 | jest.setTimeout(10000); // 10 seconds 25 | 26 | beforeEach(() => { 27 | initialize(firestore); 28 | expect.hasAssertions(); 29 | }); 30 | 31 | afterAll(async () => { 32 | console.info('Deleting collections', uniqueCollections); 33 | const batch = firestore.batch(); 34 | 35 | if (process.env.FIRESTORE_DELETE_ALL_COLLECTIONS) { 36 | const cols = await firestore.listCollections(); 37 | for (const col of cols.map(c => c.id)) { 38 | const docs = await firestore.collection(col).listDocuments(); 39 | docs.forEach(d => batch.delete(d)); 40 | } 41 | } else { 42 | for (const uc of uniqueCollections) { 43 | const docs = await firestore.collection(uc).listDocuments(); 44 | 45 | for (const doc of docs) { 46 | const albums = await doc.collection('albums').listDocuments(); 47 | albums.forEach(a => batch.delete(a)); 48 | batch.delete(doc); 49 | } 50 | } 51 | } 52 | 53 | await batch.commit(); 54 | }); 55 | 56 | export const getUniqueColName = (col: string) => { 57 | const unique = `${col}#${new Date().getTime()}`; 58 | uniqueCollections.push(unique); 59 | console.log(`Now using collection: ${unique}`); 60 | return unique; 61 | }; 62 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "include": ["./**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2015", "es2017"], 4 | "noImplicitReturns": true, 5 | "sourceMap": true, 6 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 7 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 8 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 9 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 10 | "strictPropertyInitialization": false, 11 | "esModuleInterop": true, 12 | "declaration": true, 13 | "outDir": "lib" 14 | }, 15 | "compileOnSave": true, 16 | "include": ["src", "examples"] 17 | } 18 | --------------------------------------------------------------------------------